Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ff498720b | |||
| b132bab393 | |||
| 6a4afc9f0e | |||
| ac8e284d80 | |||
| 95600e728d | |||
| 4fe53c0a07 | |||
| 0588d162d6 | |||
| 53369424e6 | |||
| 70672321a1 | |||
| e7a33ff68e | |||
| 458b2c9544 | |||
| 3d949154a2 | |||
| 7b53b00b15 | |||
| fe34ce88f9 | |||
| 40e2ea45a0 | |||
| 19a1de625b | |||
| dea8880b9b | |||
| 80c5f36479 | |||
| 4bf738d8d2 | |||
| a1c8314391 |
@@ -331,6 +331,43 @@ def check_cli_instances(root, app, fixtures):
|
||||
wait_states(count, lambda _: True, "original players survive independent resolved GUI quits")
|
||||
print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit")
|
||||
|
||||
def check_stream_queue_instance():
|
||||
session = (root / "stream-queue-session").resolve()
|
||||
session.mkdir(mode=0o700)
|
||||
paths = [session / "first.jsonl", session / "second.jsonl"]
|
||||
urls = [reported_urls[0], canonical] # Deliberately not alphabetical URL order.
|
||||
manifest = session / "queue.json"
|
||||
manifest.write_text(json.dumps({"version": 1, "items": [
|
||||
{"url": url, "progress_file": str(path)} for url, path in zip(urls, paths)]}))
|
||||
invoke(["--new-instance", "--no-config", "--queue-file", str(manifest)])
|
||||
states = wait_states(1, lambda snapshots: all(
|
||||
state["queueURLs"] == urls and state["queueIndex"] == 0 and not state["busy"]
|
||||
and state["status"] == 3 and state["hasVideo"] and state["position"] > 0.2
|
||||
for state in snapshots.values()), "actual queue CLI delivers ordered native stream playback")
|
||||
pid = next(iter(states))
|
||||
if paths[1].exists():
|
||||
raise RuntimeError("unselected queue entry must not create or write its report")
|
||||
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
|
||||
and state["status"] == 3, "native queue manual next", action="queueAdvance", offset=1)
|
||||
input_request(pid, 1, lambda state: state["queueIndex"] == 0 and not state["busy"]
|
||||
and state["status"] == 3, "native queue previous revisits owned report", action="queueAdvance", offset=-1)
|
||||
states = input_request(pid, 1, lambda state: state["status"] == 2 and not state["busy"],
|
||||
"pause queue first item", action="pause")
|
||||
target = states[pid]["duration"] - 0.3
|
||||
input_request(pid, 1, lambda state: at_position(state, target),
|
||||
"acknowledged queue end seek", action="seek", position=target)
|
||||
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
|
||||
and state["status"] == 3 and state["queueURLs"] == urls,
|
||||
"natural end advances without losing supplied queue", action="play")
|
||||
quit_instance(pid, 1)
|
||||
for path, url in zip(paths, urls):
|
||||
rows = wait_report(path, {url}, lambda rows: bool(rows) and rows[-1]["event"] == "closed",
|
||||
"queue quit drains independent stable report")
|
||||
if len(rows) < 4:
|
||||
raise RuntimeError(f"queue revisit must append without clobbering earlier records: {path}: {rows}")
|
||||
wait_states(0, lambda _: True, "queue GUI quit leaves no stale instance")
|
||||
print("PASS actual queue CLI: ordered lazy streams, manual next/previous, natural end advance and private report revisits")
|
||||
|
||||
resolved_fixtures(fixtures)
|
||||
with StartupStreamingServer(fixtures) as server:
|
||||
if extractor and runtime:
|
||||
@@ -365,9 +402,16 @@ def check_cli_instances(root, app, fixtures):
|
||||
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
||||
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
|
||||
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
|
||||
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
|
||||
if not extractor or not runtime:
|
||||
raise RuntimeError("stream queue CLI smoke requires bundled extractor and runtime fixtures")
|
||||
check_stream_queue_instance()
|
||||
return
|
||||
if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1":
|
||||
check_resolved_instances(server, 0)
|
||||
return
|
||||
if extractor and runtime:
|
||||
check_stream_queue_instance()
|
||||
movie = fixtures / "cache.mp4"
|
||||
audio = root / "audio with spaces.wav"
|
||||
shutil.copy2(fixtures / "audio.wav", audio)
|
||||
@@ -721,6 +765,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
|
||||
launch = None
|
||||
try:
|
||||
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
|
||||
check_cli_instances(root, app, fixtures)
|
||||
sys.exit(0)
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(descriptor, selectors.EVENT_READ)
|
||||
launch = subprocess.Popen(
|
||||
|
||||
@@ -925,6 +925,8 @@ private enum CLIInstanceCheck {
|
||||
case "queue":
|
||||
model.showsQueue = command["visible"] as! Bool
|
||||
model.revealControls()
|
||||
case "queueAdvance":
|
||||
model.advanceQueue(command["offset"] as! Int)
|
||||
case "caption":
|
||||
guard let view = window.contentView, let selector = captionSelector(in: view),
|
||||
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
|
||||
@@ -1031,6 +1033,8 @@ private enum CLIInstanceCheck {
|
||||
"editorText": editorText,
|
||||
"compact": model.isCompact,
|
||||
"showsQueue": model.showsQueue,
|
||||
"queueURLs": model.queue.items.map(\.url.absoluteString),
|
||||
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
|
||||
"window": windowSnapshot(windows.first),
|
||||
"onTop": model.isOnTop,
|
||||
]
|
||||
|
||||
@@ -201,8 +201,9 @@ struct CastPlaybackCheck {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle")
|
||||
require(playbackSliderHitCheck(), "Progress thumb centers on the cursor for clicks and drags, clamped to track endpoints")
|
||||
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" {
|
||||
print("PASS: native volume slider tracking preserves user input and idle receiver feedback")
|
||||
print("PASS: centered progress clicks and drags, endpoint clamping, and volume tracking feedback")
|
||||
return
|
||||
}
|
||||
let source = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
@@ -210,7 +211,7 @@ struct CastPlaybackCheck {
|
||||
let suite = "terebi.cast-playback-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
let model = PlayerModel(defaults: defaults, skipClock: { 100 })
|
||||
defer { model.shutdown() }
|
||||
if CommandLine.arguments.count == 5 {
|
||||
if CommandLine.arguments[3] == "queue" {
|
||||
@@ -248,6 +249,17 @@ struct CastPlaybackCheck {
|
||||
require(model.setPlayback(playing: false), "remote pause accepted")
|
||||
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
requirePlaybackPower(system: false, display: false, "receiver pause permits sleep")
|
||||
let opening = MiruroInterval(kind: .intro, start: 23, end: 24.25, episode: 1)
|
||||
model.supplyMetadataForCheck(MiruroMetadata(title: nil, intervals: [opening]), episode: 1)
|
||||
require(model.seekRelative(24 - model.state.position), "enter opening on receiver")
|
||||
try await until(model, "receiver offers opening only after acknowledging seek") {
|
||||
!model.state.seek_pending && model.segmentToSkip == opening
|
||||
}
|
||||
require(model.skipSegment(), "segment skip queues receiver seek")
|
||||
try await until(model, "receiver acknowledges segment end without starting playback") {
|
||||
!model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 &&
|
||||
model.state.status == PLAYER_PAUSED && model.segmentToSkip == nil
|
||||
}
|
||||
model.stopCasting()
|
||||
try await until(model, "local decoder restores receiver position paused") {
|
||||
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct MiruroMetadataChecks {
|
||||
struct Failure: Error { let message: String }
|
||||
struct Promise { let id: Int }
|
||||
static let mediaID = "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw"
|
||||
static let canonical = "/watch/\(mediaID)/the-apothecary-diaries-season-3?ep=1"
|
||||
|
||||
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 do not expose untrusted URLs")
|
||||
return
|
||||
}
|
||||
throw Failure(message: "Accepted \(message)")
|
||||
}
|
||||
|
||||
// Sanitized observed watch schema; every envelope deliberately owns a fresh devalue table.
|
||||
static func table(_ root: Any) -> [Any] {
|
||||
var result: [Any] = []
|
||||
func append(_ value: Any) -> Int {
|
||||
let index = result.count
|
||||
result.append(NSNull())
|
||||
if let promise = value as? Promise {
|
||||
let reference = append(promise.id)
|
||||
result[index] = ["Promise", reference] as [Any]
|
||||
} else if let object = value as? [String: Any] {
|
||||
var references: [String: Int] = [:]
|
||||
for key in object.keys.sorted() { references[key] = append(object[key]!) }
|
||||
result[index] = references
|
||||
} else if let array = value as? [Any] {
|
||||
let references = array.map { append($0) }
|
||||
result[index] = references
|
||||
} else {
|
||||
result[index] = value
|
||||
}
|
||||
return index
|
||||
}
|
||||
_ = append(root)
|
||||
return result
|
||||
}
|
||||
|
||||
static func wire(_ envelopes: [[String: Any]]) throws -> Data {
|
||||
var body = Data()
|
||||
for envelope in envelopes {
|
||||
body.append(try JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]))
|
||||
body.append(10)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
static func initial(media: Any = ["id": mediaID, "title": ["english": "The Apothecary Diaries Season 3"]],
|
||||
episodes: Any = Promise(id: 23)) -> [String: Any] {
|
||||
["type": "data", "nodes": [NSNull(), ["type": "data", "data": table([
|
||||
"media": media, "episodeNumber": 1, "episodesData": episodes,
|
||||
"sourcesData": Promise(id: 99), "seasonsData": Promise(id: 8)
|
||||
])]]]
|
||||
}
|
||||
|
||||
static func chunk(_ payload: Any, id: Int = 23) -> [String: Any] {
|
||||
["type": "chunk", "id": id, "data": table(payload)]
|
||||
}
|
||||
|
||||
static var rows: [[String: Any]] { [
|
||||
["episode": 1, "type": "op", "start": 63.313, "end": 153.237],
|
||||
["episode": 1, "type": "ed", "start": 1335.252, "end": 1425.384]
|
||||
] }
|
||||
|
||||
static func payload(_ intervals: Any = rows) -> [String: Any] {
|
||||
["currentEpisode": ["number": 1, "title": "Locusts"],
|
||||
"episodes": [["number": 2, "title": "Not selected"]], "aniskip": intervals]
|
||||
}
|
||||
|
||||
static func decode(_ envelopes: [[String: Any]]) throws -> MiruroMetadata {
|
||||
try MiruroMetadataResolver.decode(wire(envelopes), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
|
||||
static func checks() async throws {
|
||||
let unrelated: [String: Any] = ["type": "chunk", "id": 99,
|
||||
"data": [["streams": 1], ["UnsupportedSourceTag", 999999]]]
|
||||
let fixtures = [initial(), chunk(payload()), unrelated, chunk(["ignored": "season"], id: 8)]
|
||||
let metadata = try decode(fixtures)
|
||||
try require(metadata.title == "The Apothecary Diaries Season 3 — Episode 1: Locusts", "descriptive media and selected episode title")
|
||||
try require(metadata.intervals.count == 2, "observed fractional opening and ending decoded")
|
||||
try require(metadata.intervals[0].start == 63.313 && metadata.intervals[1].end == 1425.384, "fractional seconds preserved")
|
||||
try require(metadata.intervals.allSatisfy { $0.votes == nil && $0.provider == nil && $0.referenceLength == nil }, "absent provenance is not invented")
|
||||
let reordered = try decode([unrelated, chunk(payload()), initial()])
|
||||
try require(reordered.title == metadata.title && reordered.intervals == metadata.intervals, "independent and out-of-order promise IDs")
|
||||
let inline = try decode([initial(episodes: payload())])
|
||||
try require(inline.intervals == metadata.intervals, "inline episode metadata")
|
||||
let titleOnly = try decode([initial()])
|
||||
try require(titleOnly.title == "The Apothecary Diaries Season 3 — Episode 1" && titleOnly.intervals.isEmpty, "unresolved optional episode promise preserves series title")
|
||||
let rejected = try decode([initial(), ["type": "chunk", "id": 23, "error": ["message": "secret"]]])
|
||||
try require(rejected.title == titleOnly.title && rejected.intervals.isEmpty, "rejected episode promise preserves title")
|
||||
let duplicated = try decode([initial(), chunk(payload()), chunk(payload())])
|
||||
try require(duplicated.title == titleOnly.title && duplicated.intervals.isEmpty, "ambiguous duplicate promise is not selected")
|
||||
let segmentsOnly = try decode([initial(media: NSNull()), chunk(["aniskip": rows])])
|
||||
try require(segmentsOnly.title == nil && segmentsOnly.intervals.count == 2, "interval enrichment does not require title")
|
||||
let episodeOnly = try decode([initial(media: NSNull()), chunk(payload())])
|
||||
try require(episodeOnly.title == "Episode 1: Locusts", "episode title without media title")
|
||||
let fallbackTitle = try decode([initial(media: ["title": ["english": " ", "romaji": "Kusuriya"]]), chunk(payload())])
|
||||
try require(fallbackTitle.title?.hasPrefix("Kusuriya —") == true, "title language fallback")
|
||||
let selected = try decode([initial(), chunk(["currentEpisode": ["number": 2, "title": "Wrong"],
|
||||
"episodes": [["number": 2, "title": "Wrong"], ["number": 1, "title": "Correct"]]])])
|
||||
try require(selected.title?.hasSuffix(": Correct") == true, "episode titles are selected by returned number")
|
||||
let mixedRows: [Any] = [NSNull(), "bad", ["episode": 1, "type": "op", "start": "bad", "end": 100],
|
||||
["episode": true, "type": "op", "start": 0, "end": 100],
|
||||
["episode": 1, "type": "recap", "start": 0, "end": 100]] + rows
|
||||
let partial = try decode([initial(), chunk(payload(mixedRows))])
|
||||
try require(partial.title == metadata.title && partial.intervals == metadata.intervals, "malformed rows and unknown kinds do not poison usable metadata")
|
||||
try reject("wrong returned media identity") {
|
||||
_ = try decode([initial(media: ["id": "different", "title": "Wrong"]), chunk(payload())])
|
||||
}
|
||||
try reject("oversized body") {
|
||||
_ = try MiruroMetadataResolver.decode(Data(repeating: 32, count: MiruroMetadataResolver.bodyLimit + 1), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
try reject("deep JSON") {
|
||||
_ = try MiruroMetadataResolver.decode(Data((String(repeating: "[", count: 65) + "0" + String(repeating: "]", count: 65)).utf8), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
try reject("malformed JSON") {
|
||||
_ = try MiruroMetadataResolver.decode(Data("{broken".utf8), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
let cycle: [String: Any] = ["type": "chunk", "id": 23, "data": [["Promise", 1], 23]]
|
||||
let cyclic = try decode([initial(), cycle])
|
||||
try require(cyclic.title == titleOnly.title && cyclic.intervals.isEmpty, "promise cycles are depth bounded and optional")
|
||||
let amplified: [String: Any] = ["type": "chunk", "id": 23,
|
||||
"data": [["aniskip": 1], Array(repeating: 2, count: 70000), ["type": 3], "op"]]
|
||||
let bounded = try decode([initial(), amplified])
|
||||
try require(bounded.title == titleOnly.title && bounded.intervals.isEmpty, "repeated-reference expansion is bounded")
|
||||
|
||||
try normalizationChecks()
|
||||
try redirectChecks()
|
||||
let responseURL = URL(string: "https://barelystarted.miruro.tv\(canonical)")!
|
||||
for mime in ["application/json", "text/sveltekit-data"] {
|
||||
try MiruroMetadataResolver.validateResponse(HTTPURLResponse(url: responseURL, statusCode: 200,
|
||||
httpVersion: nil, headerFields: ["Content-Type": mime])!)
|
||||
}
|
||||
for (status, type, size) in [(403, "text/html", "12"), (302, "application/json", "12"),
|
||||
(200, "text/html", "12"), (200, "application/json", "2097153")] {
|
||||
try reject("protection, redirect or oversized response") {
|
||||
try MiruroMetadataResolver.validateResponse(HTTPURLResponse(url: responseURL, statusCode: status,
|
||||
httpVersion: nil, headerFields: ["Content-Type": type, "Content-Length": size])!)
|
||||
}
|
||||
}
|
||||
let cancelled = Task {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await MiruroMetadataResolver.resolve(mediaID: mediaID, episode: 1)
|
||||
}
|
||||
do { _ = try await cancelled.value; throw Failure(message: "Cancellation accepted") }
|
||||
catch is CancellationError {}
|
||||
print("PASS: native optional metadata protocol, independent promises, safe redirects, bounded decoding and interval normalization")
|
||||
}
|
||||
|
||||
static func normalizationChecks() throws {
|
||||
func interval(_ kind: MiruroInterval.Kind = .intro, _ start: Double = 0, _ end: Double = 10,
|
||||
episode: Int = 1, votes: Double? = nil, provider: String? = nil,
|
||||
length: Double? = nil) -> MiruroInterval {
|
||||
MiruroInterval(kind: kind, start: start, end: end, episode: episode,
|
||||
provider: provider, votes: votes, referenceLength: length)
|
||||
}
|
||||
let valid = interval(.intro, 10, 100)
|
||||
for duration in [0, -1, Double.nan, .infinity] {
|
||||
try require(MiruroMetadata(title: nil, intervals: [valid]).visibleIntervals(episode: 1, duration: duration).isEmpty, "wait for finite positive actual duration")
|
||||
}
|
||||
let invalid = [interval(.intro, -10, 0), interval(.intro, 100, 110),
|
||||
interval(.intro, 10, 10), interval(.intro, 20, 10),
|
||||
interval(.intro, .nan, 10), interval(.intro, 0, .infinity),
|
||||
interval(episode: 2), interval(votes: -1), interval(votes: .nan),
|
||||
interval(provider: "SomeCrunchyrollProvider"), interval(length: 106)]
|
||||
try require(MiruroMetadata(title: nil, intervals: invalid).visibleIntervals(episode: 1, duration: 100).isEmpty, "bounds, episode, votes, provenance and reference-length rejection")
|
||||
let metadata = MiruroMetadata(title: nil, intervals: [valid])
|
||||
try require(metadata.visibleIntervals(episode: 1, duration: 100) == [valid], "end equal to duration allowed")
|
||||
try require(metadata.visibleIntervals(episode: 1, duration: 99) == [interval(.intro, 10, 99)], "duration shrink clips the visible interval")
|
||||
try require(metadata.visibleIntervals(episode: 1, duration: 120) == [valid] && metadata.intervals == [valid], "duration growth restores unchanged raw timestamps")
|
||||
let overlapping = MiruroMetadata(title: nil, intervals: [
|
||||
interval(.intro, -5.5, 10.25), interval(.outro, 90.75, 110.5)])
|
||||
try require(overlapping.visibleIntervals(episode: 1, duration: 100) == [
|
||||
interval(.intro, 0, 10.25), interval(.outro, 90.75, 100)], "partial overlaps clip at both playback boundaries")
|
||||
try require(overlapping.visibleIntervals(episode: 1, duration: 120) == [
|
||||
interval(.intro, 0, 10.25), interval(.outro, 90.75, 110.5)], "growth recomputes clipping from raw timestamps")
|
||||
try require(MiruroMetadata(title: nil, intervals: [interval(.intro, -10, 110)])
|
||||
.visibleIntervals(episode: 1, duration: 100) == [interval(.intro, 0, 100)], "interval spanning the video clips to the playable range")
|
||||
let first = interval(.intro, 5, 15, votes: 4), tied = interval(.intro, 20, 30, votes: 4)
|
||||
let ending = interval(.outro, 12, 40, votes: 2)
|
||||
let ranked = MiruroMetadata(title: nil, intervals: [interval(), first, tied, ending, interval(.outro, 50, 60, votes: 1)])
|
||||
try require(ranked.visibleIntervals(episode: 1, duration: 100) == [first, ending], "descending votes, stable ties, intro then outro, no merging overlap")
|
||||
let optional = [interval(length: 105), interval(.outro, 80, 100, length: 0)]
|
||||
try require(MiruroMetadata(title: nil, intervals: optional).visibleIntervals(episode: 1, duration: 100) == optional, "five-percent equality and absent/nonpositive length handling")
|
||||
let raw = try decode([initial(), chunk(payload([
|
||||
["episode": 1, "type": "mixed-op", "start": 1, "end": 10, "votes": 3, "provider": "aniskip", "episode_length": 100],
|
||||
["episode": 1, "type": "mixed-ed", "start": 90, "end": 100]
|
||||
]))])
|
||||
try require(raw.intervals.count == 2 && raw.intervals[0].rawKind == "mixed-op" && raw.intervals[0].votes == 3 && raw.intervals[0].provider == "aniskip" && raw.intervals[0].referenceLength == 100, "evidenced mixed kinds and optional fields preserved")
|
||||
}
|
||||
|
||||
static func redirectChecks() throws {
|
||||
let envelope = try wire([["type": "redirect", "location": canonical]])
|
||||
guard case .redirect(let location) = try MiruroMetadataResolver.decodeRoute(envelope, mediaID: mediaID, episode: 1) else {
|
||||
throw Failure(message: "HTTP-200 route redirect not recognized")
|
||||
}
|
||||
var route = try MiruroMetadataResolver.Route(mediaID: mediaID, episode: 1)
|
||||
try require(route.url.path.hasSuffix("/episode/__data.json"), "placeholder data route")
|
||||
try route.follow(location)
|
||||
try require(route.url.path.hasSuffix("/the-apothecary-diaries-season-3/__data.json") && route.url.query == "ep=1", "canonical data route preserves episode")
|
||||
try reject("redirect loop") { try route.follow(canonical) }
|
||||
for unsafe in ["https://evil.example\(canonical)", "//evil.example\(canonical)",
|
||||
"http://barelystarted.miruro.tv\(canonical)", "https://user:secret@barelystarted.miruro.tv\(canonical)",
|
||||
canonical.replacingOccurrences(of: mediaID, with: "other"), canonical.replacingOccurrences(of: "ep=1", with: "ep=2"),
|
||||
canonical + "&ep=1", canonical + "#secret", canonical.replacingOccurrences(of: "ep=1", with: "ep=%31"),
|
||||
canonical.replacingOccurrences(of: "/watch/", with: "/info/"), "/", canonical + "\n",
|
||||
canonical.replacingOccurrences(of: "/the-apothecary", with: "/%74he-apothecary")] {
|
||||
try reject("unsafe canonical redirect") {
|
||||
var route = try MiruroMetadataResolver.Route(mediaID: mediaID, episode: 1)
|
||||
try route.follow(unsafe)
|
||||
}
|
||||
}
|
||||
var bounded = try MiruroMetadataResolver.Route(mediaID: mediaID, episode: 1)
|
||||
for index in 0..<MiruroMetadataResolver.redirectLimit { try bounded.follow("/watch/\(mediaID)/slug-\(index)?ep=1") }
|
||||
try reject("too many redirects") { try bounded.follow("/watch/\(mediaID)/last?ep=1") }
|
||||
}
|
||||
|
||||
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 metadata = try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode)
|
||||
// JSON escaping prevents terminal control injection; source payloads/URLs are never printed.
|
||||
let projection: [String: Any] = ["title": metadata.title as Any? ?? NSNull(),
|
||||
"intervals": metadata.intervals.map { row -> [String: Any] in
|
||||
["kind": row.kind == .intro ? "intro" : "outro", "episode": row.episode, "start": row.start, "end": row.end]
|
||||
}]
|
||||
let data = try JSONSerialization.data(withJSONObject: projection, options: [.sortedKeys])
|
||||
print(String(decoding: data, as: UTF8.self))
|
||||
} else if args.isEmpty {
|
||||
try await checks()
|
||||
} else {
|
||||
throw Failure(message: "Usage: miruro-metadata-check [--live EPISODE_URL]")
|
||||
}
|
||||
} catch {
|
||||
let message = (error as? Failure)?.message ?? "Miruro optional metadata check failed."
|
||||
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,28 @@ struct PlaybackQueueChecks {
|
||||
"replace discards previous queue and starts new selection")
|
||||
queue.replace([])
|
||||
require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue")
|
||||
let youtube = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/fixture/episode?ep=2")!
|
||||
queue.replaceStreams([(url: miruro, label: "Episode 2"), (url: youtube, label: "YouTube")])
|
||||
require(queue.items.map(\.url) == [miruro, youtube], "stream order is preserved without file filtering or sorting")
|
||||
require(queue.currentItem?.displayName == "Episode 2", "stream rows have descriptive labels")
|
||||
let streamID = queue.currentID!
|
||||
queue.updateCurrentLabel("Resolved episode title")
|
||||
require(queue.currentID == streamID && queue.currentItem?.displayName == "Resolved episode title",
|
||||
"metadata updates labels without changing queue identity")
|
||||
require(queue.advance(1) == youtube && queue.advance(-1) == miruro, "stream navigation uses supplied order")
|
||||
queue.move(fromOffsets: IndexSet(integer: 0), toOffset: 2)
|
||||
require(queue.currentID == streamID && queue.items.map(\.url) == [youtube, miruro], "stream reorder preserves selection")
|
||||
require(queue.remove(streamID) == nil && queue.items.map(\.url) == [youtube], "removing current final stream stops")
|
||||
let retainedID = queue.currentID
|
||||
queue.appendStreams([(url: miruro, label: "Last episode"), (url: youtube, label: "Last video")])
|
||||
require(queue.items.map(\.url) == [youtube, miruro, youtube] && queue.currentID == retainedID,
|
||||
"stream append retains stopped selection and exact caller order")
|
||||
queue.select(queue.items[0].id)
|
||||
let playingID = queue.currentID
|
||||
queue.appendStreams([(url: miruro, label: "Again")])
|
||||
require(queue.currentID == playingID && queue.items.last?.url == miruro,
|
||||
"stream append preserves active identity")
|
||||
print("playback queue checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ struct PlayerCommandChecks {
|
||||
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")
|
||||
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
|
||||
require(try opened([generic.absoluteString]) == generic &&
|
||||
opened(["--url", generic.absoluteString]) == generic,
|
||||
"positional and explicit third-party URLs reach extraction unchanged")
|
||||
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
|
||||
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
|
||||
"independent local launches preserve relative paths and spaces")
|
||||
@@ -134,9 +138,99 @@ struct PlayerCommandChecks {
|
||||
fatalError("FAIL: FIFO source accepted")
|
||||
} catch {}
|
||||
|
||||
let queueFile = directory.appendingPathComponent("queue.json")
|
||||
let resolvedDirectory = realpath(directory.path, nil)!
|
||||
defer { free(resolvedDirectory) }
|
||||
let privateDirectory = URL(fileURLWithPath: String(cString: resolvedDirectory), isDirectory: true)
|
||||
require(chmod(privateDirectory.path, 0o700) == 0, "queue reports require a private owned parent")
|
||||
let youtubeReport = privateDirectory.appendingPathComponent("youtube.jsonl").path
|
||||
let miruroReport = privateDirectory.appendingPathComponent("miruro.jsonl").path
|
||||
let entries: [[String: Any]] = [
|
||||
["url": miruro.absoluteString, "progress_file": miruroReport],
|
||||
["url": canonical.absoluteString, "progress_file": youtubeReport]
|
||||
]
|
||||
func saveQueue(_ value: [String: Any]) throws {
|
||||
try JSONSerialization.data(withJSONObject: value).write(to: queueFile)
|
||||
}
|
||||
try saveQueue(["version": 1, "items": entries])
|
||||
guard case .launchQueue(let deliveredQueue, let queueIndependent, let queueBypass) =
|
||||
try PlayerCommand.parse(["--new-instance", "--no-config", "--queue-file", queueFile.path]) else {
|
||||
fatalError("FAIL: expected stream queue launch")
|
||||
}
|
||||
require(deliveredQueue == queueFile && queueIndependent && queueBypass, "queue CLI preserves independent delivery")
|
||||
guard case .enqueue(let appendedQueue, let enqueueBypass) =
|
||||
try PlayerCommand.parse(["--enqueue", "--no-config", "--queue-file", queueFile.path]) else {
|
||||
fatalError("FAIL: expected running-player queue delivery")
|
||||
}
|
||||
require(appendedQueue == queueFile && enqueueBypass, "enqueue forwards its manifest without requiring a new instance")
|
||||
let escapedFile = privateDirectory.appendingPathComponent("queue & #?.json")
|
||||
require(try StreamQueue.enqueueFile(from: StreamQueue.enqueueURL(for: escapedFile)) == escapedFile,
|
||||
"native URL delivery round trips absolute paths and URL metacharacters")
|
||||
for text in ["terebi-enqueue://queue?file=relative.json", "terebi-enqueue://other?file=/tmp/a",
|
||||
"terebi-enqueue://queue?file=/tmp/a&file=/tmp/b", "terebi-enqueue://queue?file=/tmp/a#extra"] {
|
||||
do { _ = try StreamQueue.enqueueFile(from: URL(string: text)!); fatalError("FAIL: invalid enqueue URL accepted") }
|
||||
catch {}
|
||||
}
|
||||
let parsedQueue = try StreamQueue.load(from: queueFile)
|
||||
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
|
||||
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
||||
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
|
||||
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
|
||||
let genericQueue = try StreamQueue.load(from: queueFile)
|
||||
require(genericQueue.entries[0].url == generic &&
|
||||
genericQueue.entries[0].identity == generic.absoluteString &&
|
||||
!genericQueue.entries[0].label.contains("YouTube"),
|
||||
"third-party queues retain page identity without a YouTube label")
|
||||
let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport]
|
||||
let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"),
|
||||
"progress_file": youtubeReport]
|
||||
let invalidQueues: [[String: Any]] = [
|
||||
["version": 2, "items": entries], ["version": true, "items": entries],
|
||||
["version": 1, "items": []], ["version": 1],
|
||||
["version": 1, "items": Array(repeating: entries[0], count: 4097)],
|
||||
["version": 1, "items": [entries[1], duplicateYouTube]],
|
||||
["version": 1, "items": [entries[0], duplicateMiruro]],
|
||||
["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]],
|
||||
["version": 1, "items": [entries[0], ["url": canonical.absoluteString,
|
||||
"progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]],
|
||||
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]],
|
||||
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]]
|
||||
]
|
||||
for document in invalidQueues {
|
||||
try saveQueue(document)
|
||||
do {
|
||||
_ = try PlayerCommand.parse(["--new-instance", "--queue-file", queueFile.path])
|
||||
fatalError("FAIL: malformed queue must never launch or degrade to single playback")
|
||||
} catch {}
|
||||
do {
|
||||
_ = try PlayerCommand.parse(["--enqueue", "--queue-file", queueFile.path])
|
||||
fatalError("FAIL: malformed enqueue must never launch or partially deliver")
|
||||
} catch {}
|
||||
}
|
||||
try saveQueue(["version": 1, "items": entries])
|
||||
for arguments in [
|
||||
["--queue-file", queueFile.path], ["--new-instance", "--queue-file"],
|
||||
["--new-instance", "--queue-file", "queue.json"],
|
||||
["--new-instance", "--queue-file", queueFile.path, "--url", canonical.absoluteString],
|
||||
["--new-instance", "--queue-file", queueFile.path, "--progress-file", report.path],
|
||||
["--enqueue"], ["--enqueue", "--url", canonical.absoluteString],
|
||||
["--enqueue", "--new-instance", "--queue-file", queueFile.path],
|
||||
["--enqueue", "--enqueue", "--queue-file", queueFile.path],
|
||||
["--enqueue", "--queue-file", queueFile.path, "--progress-file", report.path],
|
||||
["--enqueue", "--queue-file", "relative.json"],
|
||||
["--new-instance", "--queue-file", queueFile.path, "--queue-file", queueFile.path]
|
||||
] {
|
||||
do { _ = try PlayerCommand.parse(arguments); fatalError("FAIL: invalid queue option combination") }
|
||||
catch {}
|
||||
}
|
||||
require(chmod(privateDirectory.path, 0o755) == 0, "make report parent nonprivate for rejection check")
|
||||
do { _ = try StreamQueue.load(from: queueFile); fatalError("FAIL: public report directory accepted") }
|
||||
catch {}
|
||||
require(chmod(privateDirectory.path, 0o700) == 0, "restore private check directory")
|
||||
|
||||
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
||||
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"],
|
||||
["file://remote-host/video.mp4"], ["ftp://example.com/video.mp4"],
|
||||
["--new-instance", "--new-instance"], ["--new-instance", "--url"],
|
||||
["--new-instance", "--"], ["--new-instance", movie.path, movie.path],
|
||||
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
|
||||
|
||||
@@ -55,7 +55,7 @@ static NSArray<NSDictionary *> *records(const std::string &path) {
|
||||
"schema version is numeric one");
|
||||
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
||||
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "canonical consumer identity");
|
||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
|
||||
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
||||
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
||||
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
||||
@@ -240,13 +240,26 @@ int main(int argc, char **argv) {
|
||||
for (NSUInteger i = 0; i < rows.count; ++i)
|
||||
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
|
||||
|
||||
require(ProgressReporter::validMediaURL(firstURL) &&
|
||||
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted");
|
||||
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA",
|
||||
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
|
||||
const std::string genericURL = "https://video.example/watch?label=\"quoted\"&id=123#part";
|
||||
require(ProgressReporter::validMediaURL(genericURL) &&
|
||||
ProgressReporter::validMediaURL("http://127.0.0.1:8080/video.mp4"),
|
||||
"HTTP(S) page identities are accepted beyond YouTube");
|
||||
for (const char *invalid : {"", "file:///tmp/video.mp4", "ftp://example.com/video",
|
||||
"https://", "https://user:secret@example.com/video", "https://example.com:0/video",
|
||||
"https://example.com:65536/video", "https://example.com/video\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "unsafe page identities are rejected");
|
||||
const auto genericPath = root + "/generic.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(genericPath, [] { return 0.; });
|
||||
reporter.begin(genericURL);
|
||||
reporter.observe(sample(PLAYER_PLAYING, 25));
|
||||
reporter.finish();
|
||||
require(reporter.error().empty(), "generic page progress drains successfully");
|
||||
}
|
||||
rows = records(genericPath);
|
||||
require(rows.count == 2, "generic page has initial and terminal snapshots");
|
||||
expect(rows[0], @"progress", genericURL, 25, true);
|
||||
expect(rows[1], @"closed", genericURL, 25, true);
|
||||
|
||||
require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") &&
|
||||
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
|
||||
@@ -314,6 +327,107 @@ int main(int argc, char **argv) {
|
||||
require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization");
|
||||
require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination");
|
||||
|
||||
const auto queueFirst = root + "/queue-first.jsonl", queueSecond = root + "/queue-second.jsonl";
|
||||
{
|
||||
playback::ProgressReports reports;
|
||||
reports.registerFile(firstURL, queueFirst);
|
||||
reports.registerFile(secondURL, queueSecond);
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 12));
|
||||
reports.begin(secondURL);
|
||||
reports.observe(sample(PLAYER_ENDED, 100));
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 24));
|
||||
reports.begin("miruro:unregistered:1");
|
||||
reports.observe(sample(PLAYER_PAUSED, 55));
|
||||
reports.finish();
|
||||
require(reports.error().empty(), "queue reports can revisit their owned session without reopening arbitrary files");
|
||||
}
|
||||
auto firstRows = records(queueFirst), secondRows = records(queueSecond);
|
||||
require(firstRows.count == 4 && secondRows.count == 2, "queue switches drain old reports and revisits append");
|
||||
expect(firstRows[0], @"progress", firstURL, 12, true);
|
||||
expect(firstRows[2], @"progress", firstURL, 24, true);
|
||||
expect(secondRows[0], @"ended", secondURL, 100, true);
|
||||
const auto retired = root + "/retired.jsonl", replacement = root + "/replacement.jsonl";
|
||||
{
|
||||
playback::ProgressReports reports;
|
||||
reports.registerFile(firstURL, retired);
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 12));
|
||||
bool accepted = true;
|
||||
try { reports.registerFile(firstURL, replacement); } catch (...) { accepted = false; }
|
||||
require(accepted, "a removed identity can register a fresh reporting session");
|
||||
auto retiredRows = records(retired);
|
||||
require(retiredRows.count == 2, "replacement drains the retired writer");
|
||||
expect(retiredRows[1], @"closed", firstURL, 12, true);
|
||||
NSData *retiredBytes = [NSData dataWithContentsOfFile:nativePath(retired)];
|
||||
reports.observe(sample(PLAYER_PAUSED, 50));
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 24));
|
||||
reports.finish();
|
||||
require([[NSData dataWithContentsOfFile:nativePath(retired)] isEqual:retiredBytes],
|
||||
"replacement never appends to or overwrites the retired report");
|
||||
auto replacementRows = records(replacement);
|
||||
require(replacementRows.count == 2, "fresh session starts only when its media begins");
|
||||
expect(replacementRows[0], @"progress", firstURL, 24, true);
|
||||
expect(replacementRows[1], @"closed", firstURL, 24, true);
|
||||
bool refused = false;
|
||||
try { reports.registerFile(secondURL, retired); } catch (...) { refused = true; }
|
||||
require(refused, "retired report paths remain reserved");
|
||||
}
|
||||
const auto liveSingle = root + "/live-single.jsonl", liveNext = root + "/live-next.jsonl";
|
||||
const auto rollbackPath = root + "/rollback.jsonl";
|
||||
{
|
||||
playback::ProgressReports reports(liveSingle);
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 7));
|
||||
const char *identities[] = {secondURL.c_str()}, *destinations[] = {liveNext.c_str()};
|
||||
reports.registerFiles(identities, destinations, 1);
|
||||
reports.observe(sample(PLAYER_PAUSED, 8));
|
||||
const char *conflictingIDs[] = {firstURL.c_str(), secondURL.c_str()};
|
||||
const auto unused = root + "/unused.jsonl";
|
||||
const char *conflictingPaths[] = {rollbackPath.c_str(), liveNext.c_str()};
|
||||
bool refused = false;
|
||||
try { reports.registerFiles(conflictingIDs, conflictingPaths, 2); } catch (...) { refused = true; }
|
||||
require(refused, "conflicting later entry rejects the complete registration batch");
|
||||
reports.registerFile("miruro:new:1", rollbackPath);
|
||||
reports.begin(secondURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 3));
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 9));
|
||||
reports.begin("miruro:unregistered:1");
|
||||
reports.observe(sample(PLAYER_PAUSED, 55));
|
||||
NSData *singleBytes = [NSData dataWithContentsOfFile:nativePath(liveSingle)];
|
||||
reports.registerFile(firstURL, unused);
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 11));
|
||||
reports.finish();
|
||||
require([[NSData dataWithContentsOfFile:nativePath(liveSingle)] isEqual:singleBytes],
|
||||
"re-adding the original standalone identity leaves its old report unchanged");
|
||||
auto reboundRows = records(unused);
|
||||
require(reboundRows.count == 2, "standalone identity can start a fresh queue report");
|
||||
expect(reboundRows[0], @"progress", firstURL, 11, true);
|
||||
expect(reboundRows[1], @"closed", firstURL, 11, true);
|
||||
require(reports.error().empty(), "enqueue preserves single reporting and supports owned report revisits");
|
||||
}
|
||||
auto singleRows = records(liveSingle), nextRows = records(liveNext);
|
||||
require(singleRows.count >= 4 && nextRows.count == 2, "active single report survives registration and queued report stays isolated");
|
||||
for (NSDictionary *row in singleRows) require([row[@"media_url"] isEqual:nativePath(firstURL)], "queued identity never leaks into original single report");
|
||||
for (NSDictionary *row in nextRows) require([row[@"media_url"] isEqual:nativePath(secondURL)], "queued report has only its registered identity");
|
||||
const auto replacedPath = root + "/replaced.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(replacedPath, [] { return 100.; });
|
||||
reporter.begin(firstURL);
|
||||
reporter.observe(sample(PLAYER_PAUSED, 1));
|
||||
reporter.finish();
|
||||
require(rename(replacedPath.c_str(), (replacedPath + ".original").c_str()) == 0, "retain original inode while replacing path");
|
||||
require([sentinel writeToFile:nativePath(replacedPath) atomically:NO], "replace report with unrelated sentinel");
|
||||
bool refused = false;
|
||||
try { reporter.resume(); } catch (...) { refused = true; }
|
||||
require(refused && [[NSData dataWithContentsOfFile:nativePath(replacedPath)] isEqual:sentinel],
|
||||
"revisiting never appends to a replaced report inode");
|
||||
}
|
||||
|
||||
const auto concurrentPath = root + "/concurrent.jsonl";
|
||||
std::atomic<int> ready{0}, successes{0}, failures{0};
|
||||
std::atomic<bool> start{false};
|
||||
|
||||
@@ -3,6 +3,7 @@ import AppKit
|
||||
@main
|
||||
@MainActor
|
||||
struct ResolvedPlaybackCheck {
|
||||
final class Clock { var now = 100.0 }
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
@@ -10,7 +11,7 @@ struct ResolvedPlaybackCheck {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if model.state.status == PLAYER_ERROR || model.commandError != nil {
|
||||
fatalError("FAIL: " + (model.errorText ?? "Playback failed"))
|
||||
fatalError("FAIL: \(message): " + (model.errorText ?? "Playback failed"))
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
@@ -22,6 +23,11 @@ struct ResolvedPlaybackCheck {
|
||||
}
|
||||
|
||||
static func run() async throws {
|
||||
PlayerModel.metadataLifecycleCheck()
|
||||
if CommandLine.arguments[1] == "--metadata-lifecycle" {
|
||||
print("PASS: metadata lifecycle replacement, A-B-A stale results, and actual-duration changes")
|
||||
return
|
||||
}
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let input = CommandLine.arguments[1]
|
||||
@@ -35,10 +41,16 @@ struct ResolvedPlaybackCheck {
|
||||
} else {
|
||||
source = try ResolvedSource.load(from: file)
|
||||
}
|
||||
if CommandLine.arguments.contains("--enqueue-only") {
|
||||
try await enqueueLifecycle(source: source)
|
||||
return
|
||||
}
|
||||
let suite = "terebi.resolved-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil)
|
||||
let clock = Clock()
|
||||
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil,
|
||||
skipClock: { clock.now })
|
||||
defer { model.shutdown() }
|
||||
if input.hasPrefix("https://") { model.openStream(input) }
|
||||
else { model.openResolvedSource(file) }
|
||||
@@ -47,6 +59,19 @@ struct ResolvedPlaybackCheck {
|
||||
}
|
||||
print("PASS: resolved HLS decodes video/audio and advances")
|
||||
fflush(stdout)
|
||||
if input.hasPrefix("https://") {
|
||||
try await until("descriptive title and measured-duration metadata bands", model: model) {
|
||||
model.fileName != source.title && !model.visibleIntervals.isEmpty
|
||||
}
|
||||
let intervals = model.visibleIntervals
|
||||
require(intervals.allSatisfy {
|
||||
$0.start.isFinite && $0.end.isFinite && $0.start >= 0 &&
|
||||
$0.end > $0.start && $0.end <= model.state.duration
|
||||
}, "bands use finite in-bounds actual media duration")
|
||||
require(intervals.filter { $0.kind == .intro }.count <= 1 &&
|
||||
intervals.filter { $0.kind == .outro }.count <= 1, "at most one band of each kind")
|
||||
print("PASS: Miruro title '\(model.fileName!)' and \(intervals.count) measured-duration bands")
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause accepted")
|
||||
try await until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.state.can_seek && model.state.duration > 0, "seekable measured duration")
|
||||
@@ -72,5 +97,337 @@ struct ResolvedPlaybackCheck {
|
||||
model.subtitleText == document.text(at: model.state.position)
|
||||
}
|
||||
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing")
|
||||
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
|
||||
if !input.hasPrefix("https://") {
|
||||
model.shutdown()
|
||||
try await streamQueueLifecycle(source: source)
|
||||
try await enqueueLifecycle(source: source)
|
||||
}
|
||||
}
|
||||
|
||||
static func streamQueueLifecycle(source: ResolvedSource) async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("terebi.queue-check." + UUID().uuidString).resolvingSymlinksInPath()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let youtubeURL = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||
let episodeURL = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
||||
let firstReport = directory.appendingPathComponent("youtube.jsonl")
|
||||
let secondReport = directory.appendingPathComponent("miruro.jsonl")
|
||||
let queue = StreamQueue(entries: [
|
||||
.init(url: youtubeURL, identity: youtubeURL.absoluteString, progressFile: firstReport, label: "First"),
|
||||
.init(url: episodeURL, identity: source.identity, progressFile: secondReport, label: "Second")
|
||||
])
|
||||
let suite = "terebi.queue-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
var youtubeRequests = 0
|
||||
var episodeRequests = 0
|
||||
var stale: CheckedContinuation<YouTubeVideo, Never>?
|
||||
let video = YouTubeVideo(title: "Queue YouTube fixture",
|
||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
||||
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
||||
youtubeRequests += 1
|
||||
if youtubeRequests == 1 {
|
||||
return await withCheckedContinuation { stale = $0 }
|
||||
}
|
||||
return video
|
||||
}, miruroResolver: { _, _ in
|
||||
episodeRequests += 1
|
||||
return source
|
||||
})
|
||||
defer { model.shutdown() }
|
||||
require(model.progressInitializationError == nil, "queue report registration")
|
||||
model.openStreamQueue(queue)
|
||||
try await until("only selected first stream resolves", model: model) { stale != nil }
|
||||
require(episodeRequests == 0 && model.queue.items.map(\.url) == [youtubeURL, episodeURL],
|
||||
"stream queue is lazy and preserves supplied order")
|
||||
let firstID = model.queue.items[0].id
|
||||
let secondID = model.queue.items[1].id
|
||||
model.selectQueueItem(secondID)
|
||||
try await until("manual stream selection decodes native media", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||
}
|
||||
stale!.resume(returning: video)
|
||||
stale = nil
|
||||
// Yield to the canceled resolver; it must not open or redirect its report.
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
require(model.queue.currentID == secondID && model.fileName == source.title &&
|
||||
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
||||
model.advanceQueue(-1)
|
||||
try await until("previous resolves YouTube afresh", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
|
||||
}
|
||||
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
||||
require(model.setPlayback(playing: false), "pause first queued stream")
|
||||
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
|
||||
try await until("end seek acknowledged", model: model) {
|
||||
!model.state.seek_pending && !model.isBusy && abs(model.state.position - max(0, model.duration - 0.3)) < 0.1
|
||||
}
|
||||
require(model.setPlayback(playing: true), "play first stream to natural end")
|
||||
try await until("natural end advances and freshly resolves second stream", model: model) {
|
||||
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
||||
model.fileName == source.title && episodeRequests == 2
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
||||
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
|
||||
try await until("second bookmark acknowledged", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1
|
||||
}
|
||||
model.advanceQueue(-1)
|
||||
try await until("revisit first report session", model: model) {
|
||||
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
model.advanceQueue(1)
|
||||
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
||||
!model.isBusy && model.fileName == source.title && model.state.status == PLAYER_PLAYING &&
|
||||
model.state.position >= 0.7
|
||||
}
|
||||
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
|
||||
model.shutdown()
|
||||
func records(_ file: URL) throws -> [[String: Any]] {
|
||||
try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map {
|
||||
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
||||
}
|
||||
}
|
||||
let youtubeRows = try records(firstReport)
|
||||
let episodeRows = try records(secondReport)
|
||||
require(youtubeRows.count >= 4 && youtubeRows.allSatisfy {
|
||||
$0["version"] as? Int == 1 && $0["media_url"] as? String == youtubeURL.absoluteString
|
||||
}, "YouTube report appends on revisit without another identity")
|
||||
require(youtubeRows.contains { $0["event"] as? String == "ended" }, "natural end retains its acknowledged completion snapshot")
|
||||
require(episodeRows.count >= 6 && episodeRows.allSatisfy {
|
||||
let identity = $0["media_identity"] as? [String: Any]
|
||||
return $0["version"] as? Int == 2 && identity?["media_id"] as? String == "fixture-ID" &&
|
||||
identity?["episode"] as? Int == 1
|
||||
}, "Miruro report is isolated through selection, advance and revisits")
|
||||
print("PASS: ordered lazy mixed stream queue, stale cancellation, native end advance, fresh revisits/resume and report isolation")
|
||||
}
|
||||
|
||||
static func enqueueLifecycle(source: ResolvedSource) async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("terebi.enqueue-check." + UUID().uuidString).resolvingSymlinksInPath()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let first = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||
let second = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
||||
let third = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
|
||||
let fourth = URL(string: "https://www.youtube.com/watch?v=CCCCCCCCCCC")!
|
||||
let originalReport = directory.appendingPathComponent("original.jsonl")
|
||||
let secondReport = directory.appendingPathComponent("second.jsonl")
|
||||
let thirdReport = directory.appendingPathComponent("third.jsonl")
|
||||
let fourthReport = directory.appendingPathComponent("fourth.jsonl")
|
||||
let manifest = directory.appendingPathComponent("queue & #.json")
|
||||
var requests = 0
|
||||
var episodeRequests = 0
|
||||
let suite = "terebi.enqueue-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, progressFile: originalReport.path,
|
||||
youtubeResolver: { url, _ in
|
||||
requests += 1
|
||||
return YouTubeVideo(title: url.absoluteString,
|
||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||
}, miruroResolver: { _, _ in episodeRequests += 1; return source })
|
||||
defer { model.shutdown() }
|
||||
func deliver(_ items: [(URL, URL)]) throws {
|
||||
let document: [String: Any] = ["version": 1, "items": items.map {
|
||||
["url": $0.0.absoluteString, "progress_file": $0.1.path]
|
||||
}]
|
||||
try JSONSerialization.data(withJSONObject: document).write(to: manifest)
|
||||
model.receiveEnqueueURL(StreamQueue.enqueueURL(for: manifest))
|
||||
}
|
||||
model.openStream(first.absoluteString)
|
||||
try await until("standalone stream begins before enqueue", model: model) {
|
||||
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause original single-video playback")
|
||||
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
let selected = model.queue.currentID
|
||||
let position = model.state.position
|
||||
let revision = model.state.revision
|
||||
try deliver([(second, secondReport), (third, thirdReport)])
|
||||
require(model.queue.items.map(\.url) == [first, second, third] &&
|
||||
model.queue.currentID == selected && model.state.status == PLAYER_PAUSED &&
|
||||
model.state.position == position && model.state.revision == revision &&
|
||||
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
||||
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
|
||||
require(model.setPlayback(playing: true), "resume original playback")
|
||||
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
||||
let playingRevision = model.state.revision
|
||||
try deliver([(fourth, fourthReport)])
|
||||
require(model.queue.items.map(\.url) == [first, second, third, fourth] &&
|
||||
model.queue.currentID == selected && model.state.revision == playingRevision &&
|
||||
model.state.status == PLAYER_PLAYING && requests == 1 && episodeRequests == 0,
|
||||
"repeated native delivery appends while playing without reopening current media")
|
||||
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
||||
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
||||
"conflicting identity rejects complete enqueue without changing playback")
|
||||
try deliver([(URL(string: "https://www.youtube.com/watch?v=DDDDDDDDDDD")!, fourthReport)])
|
||||
require(model.commandError != nil && model.queue.items.count == 4,
|
||||
"registered report-path conflicts reject complete enqueue before files exist")
|
||||
try deliver([(URL(string: "https://www.youtube.com/watch?v=DDDDDDDDDDD")!, directory.appendingPathComponent("valid.jsonl")),
|
||||
(URL(string: "file:///not-a-stream")!, directory.appendingPathComponent("invalid.jsonl"))])
|
||||
require(model.commandError != nil && model.queue.items.count == 4,
|
||||
"invalid later manifest entry never partially appends or registers reports")
|
||||
try deliver([(URL(string: "https://www.youtube.com/watch?v=DDDDDDDDDDD")!, directory.appendingPathComponent("valid.jsonl"))])
|
||||
require(model.commandError == nil && model.queue.items.count == 5, "failed batches leave no registrations behind")
|
||||
require(model.setPlayback(playing: false), "pause original before natural-end advance")
|
||||
try await until("original pause before end seek", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
let ending = max(0, model.duration - 0.3)
|
||||
require(model.seekRelative(ending - model.state.position), "seek original near natural end")
|
||||
try await until("original end seek acknowledged", model: model) {
|
||||
!model.state.seek_pending && !model.isBusy && abs(model.state.position - ending) < 0.1
|
||||
}
|
||||
require(model.setPlayback(playing: true), "resume original through natural end")
|
||||
try await until("natural end advances to enqueued Miruro item", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title &&
|
||||
model.queue.currentURL == second
|
||||
}
|
||||
model.advanceQueue(1)
|
||||
try await until("enqueued YouTube item decodes", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
||||
}
|
||||
model.advanceQueue(-1)
|
||||
try await until("enqueued Miruro report revisits", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == source.title
|
||||
}
|
||||
model.advanceQueue(-1)
|
||||
try await until("original single video remains navigable", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == first.absoluteString
|
||||
}
|
||||
var retiredReports: [(URL, Data, URL, URL)] = []
|
||||
for (url, oldReport) in [(first, originalReport), (third, thirdReport)] {
|
||||
let oldID = model.queue.items.first(where: { $0.url == url })!.id
|
||||
model.removeQueueItem(oldID)
|
||||
let currentID = model.queue.currentID
|
||||
let freshReport = directory.appendingPathComponent(oldReport.lastPathComponent + ".fresh")
|
||||
try deliver([(url, freshReport)])
|
||||
require(model.commandError == nil && model.queue.items.count == 5 &&
|
||||
model.queue.currentID == currentID, "removed video re-enqueues without interrupting current playback")
|
||||
let retiredBytes = try Data(contentsOf: oldReport)
|
||||
retiredReports.append((oldReport, retiredBytes, freshReport, url))
|
||||
require(!FileManager.default.fileExists(atPath: freshReport.path), "re-added report remains lazy")
|
||||
model.selectQueueItem(model.queue.items.last!.id)
|
||||
try await until("re-added video decodes with a fresh session", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING &&
|
||||
model.queue.currentURL == url && model.state.position > 0.2
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause re-added video")
|
||||
try await until("re-added pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
}
|
||||
model.shutdown()
|
||||
for (oldReport, retiredBytes, freshReport, url) in retiredReports {
|
||||
let finalBytes = try Data(contentsOf: oldReport)
|
||||
require(finalBytes == retiredBytes, "re-added playback leaves the retired report unchanged")
|
||||
let freshRows = try String(contentsOf: freshReport, encoding: .utf8).split(separator: "\n").map {
|
||||
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
||||
}
|
||||
require(freshRows.contains { $0["event"] as? String == "closed" } &&
|
||||
freshRows.contains { $0["event"] as? String == "progress" } &&
|
||||
freshRows.allSatisfy { $0["media_url"] as? String == url.absoluteString },
|
||||
"re-added video writes its acknowledged progress and close to the fresh report only")
|
||||
}
|
||||
for (file, expected) in [(originalReport, first.absoluteString), (thirdReport, third.absoluteString)] {
|
||||
let rows = try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map {
|
||||
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
||||
}
|
||||
require(!rows.isEmpty && rows.allSatisfy { $0["media_url"] as? String == expected },
|
||||
"original and appended YouTube reports retain independent identities")
|
||||
}
|
||||
let episodeRows = try String(contentsOf: secondReport, encoding: .utf8).split(separator: "\n").map {
|
||||
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
||||
}
|
||||
require(!episodeRows.isEmpty && episodeRows.allSatisfy {
|
||||
let identity = $0["media_identity"] as? [String: Any]
|
||||
return identity?["media_id"] as? String == "fixture-ID" && identity?["episode"] as? Int == 1
|
||||
}, "appended Miruro report remains isolated on revisit")
|
||||
require(!FileManager.default.fileExists(atPath: fourthReport.path), "unselected enqueued item never owns a writer")
|
||||
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
||||
})
|
||||
defer { empty.shutdown() }
|
||||
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
||||
empty.enqueueStreamQueue(StreamQueue(entries: [.init(url: fourth, identity: fourth.absoluteString,
|
||||
progressFile: coldReport, label: "Cold enqueue")]))
|
||||
try await until("empty player starts first enqueued item", model: empty) {
|
||||
!empty.isBusy && empty.state.status == PLAYER_PLAYING && empty.queue.currentURL == fourth
|
||||
}
|
||||
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
|
||||
}
|
||||
|
||||
static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws {
|
||||
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
|
||||
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
||||
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
|
||||
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
|
||||
require(model.seekRelative(0.75 - model.state.position), "enter opening")
|
||||
try await until("opening position acknowledged", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||
}
|
||||
model.supplyMetadataForCheck(metadata, episode: 1)
|
||||
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
|
||||
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
|
||||
clock.now = 105
|
||||
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
|
||||
clock.now = 109.999
|
||||
require(model.segmentToSkip == opening, "opening remains available before ten seconds")
|
||||
clock.now = 110
|
||||
require(model.segmentToSkip == nil && !model.skipSegment(), "offer expires at ten seconds while paused")
|
||||
require(model.skipOfferRemainingFraction == 0, "expired offer has no remaining background")
|
||||
clock.now = 111
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
require(model.segmentToSkip == nil, "polling does not reoffer ignored opening")
|
||||
require(model.seekRelative(2.25 - model.state.position), "enter ending")
|
||||
require(model.segmentToSkip == nil, "pending seek cannot offer stale opening")
|
||||
try await until("ending offer", model: model) {
|
||||
!model.state.seek_pending && model.segmentToSkip == ending
|
||||
}
|
||||
require(model.skipSegment(), "ending skip accepted")
|
||||
require(model.segmentToSkip == nil, "accepted skip immediately removes button")
|
||||
try await until("ending skip reaches exact media end", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
|
||||
}
|
||||
require(!model.effectivePlaying, "paused skip does not start playback")
|
||||
require(model.seekRelative(0.75 - model.state.position), "revisit ignored opening")
|
||||
try await until("opening revisit acknowledged", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||
}
|
||||
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
|
||||
model.closeFile()
|
||||
require(model.segmentToSkip == nil, "close removes offer")
|
||||
model.openResolvedSource(file)
|
||||
try await until("reopened media playing", model: model) {
|
||||
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause reopened media")
|
||||
try await until("reopened media paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.seekRelative(0.75 - model.state.position), "reenter opening after reopen")
|
||||
try await until("reopened opening acknowledged", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||
}
|
||||
model.supplyMetadataForCheck(metadata, episode: 1)
|
||||
try await until("new open resets dismissal after seek settles", model: model) {
|
||||
model.segmentToSkip == opening
|
||||
}
|
||||
require(model.setPlayback(playing: true), "resume inside opening")
|
||||
try await until("playing opening offer", model: model) {
|
||||
model.state.status == PLAYER_PLAYING && !model.state.buffering &&
|
||||
model.state.position > 0.85 && model.segmentToSkip == opening
|
||||
}
|
||||
require(model.skipSegment(), "opening skip accepted")
|
||||
try await until("opening skip reaches segment end", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - opening.end) < 0.1
|
||||
}
|
||||
require(model.segmentToSkip == nil, "segment end is outside opening")
|
||||
require(model.effectivePlaying, "opening skip preserves playing intent")
|
||||
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,12 @@ struct StreamResolverChecks {
|
||||
throw Failure(message: "YouTube URL classification")
|
||||
}
|
||||
try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization")
|
||||
let other = "https://vimeo.com/76979871?quality=original"
|
||||
guard case .youtube(let generic) = try StreamURL.parse(other) else {
|
||||
throw Failure(message: "other HTTP(S) sites route through yt-dlp")
|
||||
}
|
||||
try require(generic.absoluteString == other, "other sites retain their URL and query")
|
||||
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@"),
|
||||
@@ -70,7 +73,7 @@ struct StreamResolverChecks {
|
||||
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"
|
||||
"file:///secret"
|
||||
] {
|
||||
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
|
||||
}
|
||||
|
||||
@@ -70,20 +70,20 @@ struct YouTubeResolverChecks {
|
||||
let url = try YouTubeResolver.validateURL(input)
|
||||
try require(url == page, "supported video URLs identify the same video")
|
||||
}
|
||||
for input in ["https://vimeo.com/76979871", "https://example.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"http://127.0.0.1:8080/video.mp4?token=kept#fragment",
|
||||
"https://www.youtube.com/playlist?list=PLexample"] {
|
||||
try require(try YouTubeResolver.validateURL(input).absoluteString == input,
|
||||
"HTTP(S) URLs outside canonical YouTube video forms reach yt-dlp unchanged")
|
||||
}
|
||||
let hostile = [
|
||||
"", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw",
|
||||
"https://example.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"ftp://example.com/video", "https://", "https://example.com:0/video",
|
||||
"https://example.com:65536/video", "https://example.com/video\ninjected",
|
||||
"https://youtube.com@evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"https://www.youtube.com/playlist?list=PLexample",
|
||||
"https://www.youtube.com/watch?list=PLexample",
|
||||
"https://www.youtube.com/watch?v=short",
|
||||
"https://youtu.be/jNQXAC9IVRwextra", "https://youtu.be/jNQXAC9IVR!",
|
||||
"https://www.youtube.com/shorts/", "https://youtu.be/jNQXAC9IVRw/extra",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVR%0A",
|
||||
"https://www.youtube.com/watch?v=jNQXAC9IVR%C3%A9"
|
||||
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw"
|
||||
]
|
||||
for input in hostile {
|
||||
try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) }
|
||||
@@ -202,6 +202,9 @@ struct YouTubeResolverChecks {
|
||||
}
|
||||
|
||||
static func malformedMetadata() throws {
|
||||
var collection = try fixture(combinedJSON)
|
||||
collection["_type"] = "playlist"
|
||||
try reject("collection result instead of a single video") { _ = try decode(collection) }
|
||||
try reject("non-JSON extractor output") { _ = try decode("not JSON") }
|
||||
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
|
||||
try reject("missing playable stream metadata") { _ = try decode(json) }
|
||||
@@ -210,10 +213,11 @@ struct YouTubeResolverChecks {
|
||||
_ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#)
|
||||
}
|
||||
let combined = try fixture(combinedJSON)
|
||||
var missingURL = combined
|
||||
missingURL.removeValue(forKey: "url")
|
||||
try reject("missing combined stream URL") { _ = try decode(missingURL) }
|
||||
for key in ["url", "vcodec", "acodec"] {
|
||||
var object = combined
|
||||
object.removeValue(forKey: key)
|
||||
try reject("missing required combined \(key)") { _ = try decode(object) }
|
||||
object[key] = 17
|
||||
try reject("wrong type for combined \(key)") { _ = try decode(object) }
|
||||
}
|
||||
@@ -236,11 +240,9 @@ struct YouTubeResolverChecks {
|
||||
var hdr = combined
|
||||
hdr["dynamic_range"] = "HDR10"
|
||||
try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) }
|
||||
for value in ["none", NSNull()] as [Any] {
|
||||
var object = combined
|
||||
object["vcodec"] = value
|
||||
try reject("combined input without video codec") { _ = try decode(object) }
|
||||
}
|
||||
var audioOnly = combined
|
||||
audioOnly["vcodec"] = "none"
|
||||
try reject("explicitly audio-only result") { _ = try decode(audioOnly) }
|
||||
for transport in ["http_dash_segments", "mhtml", "rtmp"] {
|
||||
var object = combined
|
||||
object["protocol"] = transport
|
||||
@@ -444,6 +446,18 @@ struct YouTubeResolverChecks {
|
||||
try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json"))
|
||||
let lower = try await YouTubeResolver.resolve(page, executableURL: extractor.executable, runtimeURL: runtime)
|
||||
try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K")
|
||||
let genericPage = URL(string: "https://video.example/watch?id=123")!
|
||||
let generic: [String: Any] = [
|
||||
"id": "123", "title": "Generic HTML5 video", "webpage_url": genericPage.absoluteString,
|
||||
"extractor": "generic", "extractor_key": "Generic",
|
||||
"formats": [["format_id": "0", "url": "https://media.example/unknown.mp4",
|
||||
"ext": "mp4", "protocol": "https"]]
|
||||
]
|
||||
try JSONSerialization.data(withJSONObject: generic).write(to: extractor.marker("response.json"))
|
||||
let unknown = try await YouTubeResolver.resolve(genericPage, executableURL: extractor.executable, runtimeURL: runtime)
|
||||
try require(unknown.video.url == "https://media.example/unknown.mp4" &&
|
||||
unknown.audio == nil && unknown.height == nil && unknown.webpageURL == genericPage,
|
||||
"generic HTML5 video without codec or height hints remains playable and retains page identity")
|
||||
}
|
||||
|
||||
static func bundledHelpers(root: URL) throws {
|
||||
|
||||
@@ -18,7 +18,10 @@ SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sou
|
||||
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)
|
||||
SWIFT_SOURCES += Sources/App/StreamResolver.swift
|
||||
SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
|
||||
CLI_SOURCES += Sources/App/StreamResolver.swift
|
||||
SWIFT_SOURCES += Sources/App/StreamQueue.swift
|
||||
CLI_SOURCES += Sources/App/StreamQueue.swift
|
||||
CONFIG_BUILD := .build/configuration
|
||||
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
|
||||
CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a
|
||||
@@ -71,7 +74,7 @@ $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
|
||||
$(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/video_decoder.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/network_policy.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/cast_media.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/video_decoder.hpp Sources/Core/network_policy.hpp Sources/Core/network_input.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
@@ -218,6 +221,14 @@ $(BUILD_DIR)/stream-resolver-check: Checks/stream-resolver.swift Sources/App/Str
|
||||
check-stream: $(BUILD_DIR)/stream-resolver-check
|
||||
$(BUILD_DIR)/stream-resolver-check
|
||||
|
||||
$(BUILD_DIR)/miruro-metadata-check: Checks/miruro-metadata.swift Sources/App/MiruroMetadata.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/miruro-metadata.swift Sources/App/MiruroMetadata.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
|
||||
|
||||
.PHONY: check-miruro-metadata
|
||||
check: check-miruro-metadata
|
||||
check-miruro-metadata: $(BUILD_DIR)/miruro-metadata-check
|
||||
$(BUILD_DIR)/miruro-metadata-check
|
||||
|
||||
$(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 $@
|
||||
|
||||
@@ -262,14 +273,14 @@ $(BUILD_DIR)/playback-progress-check: Checks/playback-progress.swift Sources/App
|
||||
check-progress: $(BUILD_DIR)/playback-progress-check
|
||||
$(BUILD_DIR)/playback-progress-check
|
||||
|
||||
$(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/network_policy.hpp Sources/Core/player.h Makefile | $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp -framework Foundation -o $@
|
||||
|
||||
check-reporting: $(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 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 Sources/App/StreamResolver.swift -framework Foundation -o $@
|
||||
$(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 Sources/App/StreamQueue.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 Sources/App/StreamResolver.swift Sources/App/StreamQueue.swift -framework Foundation -o $@
|
||||
|
||||
check-cli: $(BUILD_DIR)/player-command-check
|
||||
$(BUILD_DIR)/player-command-check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Terebi
|
||||
|
||||
A native macOS player for local media, YouTube videos and Miruro episodes, with a local-file queue, saved playback positions, compact mode and Chromecast support.
|
||||
A native macOS player for local media, yt-dlp-supported videos (including YouTube) and Miruro episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support.
|
||||
|
||||
## Build and install
|
||||
|
||||
@@ -22,10 +22,13 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
|
||||
## Usage
|
||||
|
||||
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number; arbitrary streaming sites are not supported.
|
||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
||||
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Only the selected item resolves, including fresh resolution on revisits; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
|
||||
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top.
|
||||
- **Quality:** YouTube defaults to SDR up to 1080p. Use **gear → Quality** for other available resolutions or automatic quality.
|
||||
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
@@ -49,7 +52,7 @@ Open media, select the **Cast** icon or **Playback → Cast to Device**, and cho
|
||||
|
||||
Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback.
|
||||
|
||||
Cast output is limited to 1080p/30 fps; frame stepping and YouTube quality changes are local-only. Use Cast only on a trusted network. AirPlay is not supported.
|
||||
Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. Use Cast only on a trusted network. AirPlay is not supported.
|
||||
|
||||
## Command line
|
||||
|
||||
@@ -57,12 +60,13 @@ Cast output is limited to 1080p/30 fps; frame stepping and YouTube quality chang
|
||||
terebi "/path/to/video.mp4"
|
||||
terebi 'https://youtu.be/jNQXAC9IVRw'
|
||||
terebi --new-instance --no-config "/path/to/video.mp4"
|
||||
terebi --enqueue --queue-file "/absolute/private/session/queue.json"
|
||||
terebi --help
|
||||
```
|
||||
|
||||
The CLI opens one file or supported stream, reusing the running player by default. With no arguments, it opens or activates the player. `--new-instance` starts a separate player; `--no-config` uses built-in settings on a fresh launch. Put these options before the media argument. Before installation, use `.build/terebi` instead of `terebi`.
|
||||
|
||||
For resolved-source playback and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
|
||||
For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -78,7 +82,7 @@ Copy the settings you want to change into the config file; omitted settings keep
|
||||
|
||||
- SDR output only; HDR is not supported.
|
||||
- No embedded or YouTube captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams.
|
||||
- Private, age-restricted, region-blocked or sign-in-required YouTube videos may fail; browser cookies are not used. Rebuild and reinstall for bundled YouTube extractor updates.
|
||||
- yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates.
|
||||
- Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed.
|
||||
- Audio-device changes during playback are not handled.
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
<array><string>public.movie</string><string>public.audio</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array><dict>
|
||||
<key>CFBundleURLName</key><string>Stream queue handoff</string>
|
||||
<key>CFBundleURLSchemes</key><array><string>terebi-enqueue</string></array>
|
||||
</dict></array>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>NSPrincipalClass</key><string>NSApplication</string>
|
||||
<key>NSLocalNetworkUsageDescription</key><string>Discover Cast devices and stream your selected media to a TV on your local network.</string>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
|
||||
struct MiruroInterval: Sendable, Equatable {
|
||||
enum Kind: Sendable, Hashable { case intro, outro }
|
||||
let kind: Kind
|
||||
var start: Double
|
||||
var end: Double
|
||||
let episode: Int
|
||||
var rawKind: String = ""
|
||||
var provider: String? = nil
|
||||
var votes: Double? = nil
|
||||
var referenceLength: Double? = nil
|
||||
}
|
||||
|
||||
struct MiruroMetadata: Sendable {
|
||||
let title: String?
|
||||
let intervals: [MiruroInterval]
|
||||
|
||||
func visibleIntervals(episode: Int, duration: Double) -> [MiruroInterval] {
|
||||
guard duration.isFinite, duration > 0 else { return [] }
|
||||
var intro: MiruroInterval?, outro: MiruroInterval?
|
||||
for var row in intervals {
|
||||
guard row.episode == episode, row.start.isFinite, row.end.isFinite,
|
||||
row.end > row.start,
|
||||
!(row.provider?.range(of: "crunchyroll", options: .caseInsensitive) != nil),
|
||||
(row.votes ?? 0).isFinite, (row.votes ?? 0) >= 0 else { continue }
|
||||
if let length = row.referenceLength, length.isFinite, length > 0,
|
||||
abs(length - duration) / duration > 0.05 { continue }
|
||||
row.start = max(0, row.start)
|
||||
row.end = min(duration, row.end)
|
||||
guard row.end > row.start else { continue }
|
||||
// Strictly greater preserves input order on ties, equivalent to stable descending votes.
|
||||
switch row.kind {
|
||||
case .intro:
|
||||
if intro == nil || (row.votes ?? 0) > (intro?.votes ?? 0) { intro = row }
|
||||
case .outro:
|
||||
if outro == nil || (row.votes ?? 0) > (outro?.votes ?? 0) { outro = row }
|
||||
}
|
||||
}
|
||||
var selected: [MiruroInterval] = []
|
||||
if let intro { selected.append(intro) }
|
||||
if let outro { selected.append(outro) }
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiruroMetadataError: LocalizedError {
|
||||
var errorDescription: String? { "Miruro optional metadata was unavailable or invalid." }
|
||||
}
|
||||
|
||||
enum MiruroMetadataResolver {
|
||||
static let bodyLimit = 2 * 1024 * 1024
|
||||
static let redirectLimit = 4
|
||||
|
||||
// Both HTTP redirects and SvelteKit's HTTP-200 redirect envelopes use this policy.
|
||||
struct Route {
|
||||
let mediaID: String
|
||||
let episode: Int
|
||||
private(set) var url: URL
|
||||
private var visited: Set<URL>
|
||||
private var redirects = 0
|
||||
|
||||
init(mediaID: String, episode: Int) throws {
|
||||
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw MiruroMetadataError() }
|
||||
self.mediaID = mediaID
|
||||
self.episode = episode
|
||||
url = URL(string: "https://barelystarted.miruro.tv/watch/\(mediaID)/episode/__data.json?ep=\(episode)")!
|
||||
visited = [url]
|
||||
}
|
||||
|
||||
mutating func follow(_ location: String) throws {
|
||||
guard redirects < MiruroMetadataResolver.redirectLimit,
|
||||
!location.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
|
||||
}), !location.contains("\\"),
|
||||
let destination = URL(string: location, relativeTo: url)?.absoluteURL,
|
||||
var parts = URLComponents(url: destination, resolvingAgainstBaseURL: true),
|
||||
parts.scheme?.lowercased() == "https", parts.host?.lowercased() == "barelystarted.miruro.tv",
|
||||
parts.port == nil || parts.port == 443, parts.user == nil, parts.password == nil,
|
||||
parts.fragment == nil, parts.percentEncodedPath == parts.path,
|
||||
parts.percentEncodedQuery == "ep=\(episode)" else { throw MiruroMetadataError() }
|
||||
let path = parts.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard (path.count == 4 || path.count == 5), path[0].isEmpty, path[1] == "watch",
|
||||
path[2] == Substring(mediaID), !path[3].isEmpty, path[3].utf8.count <= 512,
|
||||
path[3].utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) ||
|
||||
(48...57).contains($0) || $0 == 45 || $0 == 95 }),
|
||||
path.count == 4 || path[4] == "__data.json" else { throw MiruroMetadataError() }
|
||||
parts.scheme = "https"
|
||||
parts.host = "barelystarted.miruro.tv"
|
||||
parts.port = nil
|
||||
parts.path = "/watch/\(mediaID)/\(path[3])/__data.json"
|
||||
guard let next = parts.url, visited.insert(next).inserted else { throw MiruroMetadataError() }
|
||||
redirects += 1
|
||||
url = next
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> MiruroMetadata {
|
||||
try Task.checkCancellation()
|
||||
var route = try Route(mediaID: mediaID, episode: episode)
|
||||
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: MetadataRedirectPolicy(), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
var request = URLRequest(url: route.url)
|
||||
request.timeoutInterval = 25
|
||||
request.setValue("application/json, text/sveltekit-data", forHTTPHeaderField: "Accept")
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw MiruroMetadataError() }
|
||||
if [301, 302, 303, 307, 308].contains(http.statusCode) {
|
||||
guard let location = http.value(forHTTPHeaderField: "Location") else { throw MiruroMetadataError() }
|
||||
try route.follow(location)
|
||||
continue
|
||||
}
|
||||
try validateResponse(response)
|
||||
var body = Data()
|
||||
if response.expectedContentLength > 0 { body.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard body.count < bodyLimit else { throw MiruroMetadataError() }
|
||||
body.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
switch try decodeRoute(body, mediaID: mediaID, episode: episode) {
|
||||
case .redirect(let location): try route.follow(location)
|
||||
case .metadata(let metadata): return metadata
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
}
|
||||
|
||||
static func validateResponse(_ response: URLResponse) throws {
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||
["application/json", "text/sveltekit-data"].contains(http.mimeType?.lowercased() ?? ""),
|
||||
http.expectedContentLength <= Int64(bodyLimit) else { throw MiruroMetadataError() }
|
||||
}
|
||||
|
||||
enum RouteData { case redirect(String), metadata(MiruroMetadata) }
|
||||
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroMetadata {
|
||||
guard case .metadata(let metadata) = try decodeRoute(data, mediaID: mediaID, episode: episode) else {
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
static func decodeRoute(_ data: Data, mediaID: String, episode: Int) throws -> RouteData {
|
||||
guard data.count <= bodyLimit, ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
// Bound JSON nesting before Foundation allocates containers. Devalue traversal below is schema-guided,
|
||||
// so unrelated source payloads and unsupported tagged types are never recursively expanded.
|
||||
var depth = 0, quoted = false, escaped = false
|
||||
for byte in data {
|
||||
if quoted {
|
||||
if escaped { escaped = false }
|
||||
else if byte == 92 { escaped = true }
|
||||
else if byte == 34 { quoted = false }
|
||||
} else if byte == 34 { quoted = true }
|
||||
else if byte == 91 || byte == 123 {
|
||||
depth += 1
|
||||
guard depth <= 64 else { throw MiruroMetadataError() }
|
||||
} else if byte == 93 || byte == 125 {
|
||||
depth -= 1
|
||||
guard depth >= 0 else { throw MiruroMetadataError() }
|
||||
}
|
||||
}
|
||||
let lines = data.split(separator: 10).filter { !$0.allSatisfy({ $0 == 13 || $0 == 32 || $0 == 9 }) }
|
||||
guard !lines.isEmpty, lines.count <= 128 else { throw MiruroMetadataError() }
|
||||
let envelopes = try lines.map { line -> [String: Any] in
|
||||
guard let envelope = try JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] else {
|
||||
throw MiruroMetadataError()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
// Chunks may arrive before the initial envelope and each has its own independent table.
|
||||
let initials = envelopes.filter { ($0["type"] as? String) == "data" || ($0["type"] as? String) == "redirect" }
|
||||
guard initials.count == 1, let initial = initials.first else { throw MiruroMetadataError() }
|
||||
if initial["type"] as? String == "redirect" {
|
||||
guard envelopes.count == 1, let location = initial["location"] as? String else { throw MiruroMetadataError() }
|
||||
return .redirect(location)
|
||||
}
|
||||
guard let nodes = initial["nodes"] as? [Any], nodes.count > 1,
|
||||
let node = nodes[1] as? [String: Any], node["type"] as? String == "data",
|
||||
let table = node["data"] as? [Any], !table.isEmpty else { throw MiruroMetadataError() }
|
||||
let reader = MetadataReader(envelopes: envelopes)
|
||||
let root = MetadataReader.Cursor(table: table, index: 0, depth: 0)
|
||||
let media = try? reader.field(root, "media")
|
||||
if let id = media.flatMap({ try? reader.string(reader.field($0, "id")) }), id != mediaID { throw MiruroMetadataError() }
|
||||
if let number = try? reader.integer(reader.field(root, "episodeNumber")), number != episode { throw MiruroMetadataError() }
|
||||
var series: String?
|
||||
if let media, let title = try? reader.field(media, "title") {
|
||||
series = cleanTitle(try? reader.string(title))
|
||||
if series == nil {
|
||||
for key in ["english", "romaji", "native"] {
|
||||
if let value = cleanTitle(try? reader.string(reader.field(title, key))) { series = value; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
let episodes = try? reader.field(root, "episodesData")
|
||||
var episodeTitle: String?
|
||||
if let episodes {
|
||||
if let current = try? reader.field(episodes, "currentEpisode"),
|
||||
(try? reader.integer(reader.field(current, "number"))) == episode {
|
||||
episodeTitle = cleanTitle(try? reader.string(reader.field(current, "title")))
|
||||
}
|
||||
if episodeTitle == nil, let rows = try? reader.array(reader.field(episodes, "episodes")) {
|
||||
for row in rows where (try? reader.integer(reader.field(row, "number"))) == episode {
|
||||
episodeTitle = cleanTitle(try? reader.string(reader.field(row, "title")))
|
||||
if episodeTitle != nil { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
var intervals: [MiruroInterval] = []
|
||||
if let episodes, let rows = try? reader.array(reader.field(episodes, "aniskip")) {
|
||||
for row in rows {
|
||||
guard let rawKind = try? reader.string(reader.field(row, "type")),
|
||||
let number = try? reader.integer(reader.field(row, "episode")),
|
||||
let start = try? reader.number(reader.field(row, "start")),
|
||||
let end = try? reader.number(reader.field(row, "end")) else { continue }
|
||||
let kind: MiruroInterval.Kind
|
||||
switch rawKind {
|
||||
case "op", "mixed-op": kind = .intro
|
||||
case "ed", "mixed-ed": kind = .outro
|
||||
default: continue
|
||||
}
|
||||
intervals.append(MiruroInterval(kind: kind, start: start, end: end, episode: number,
|
||||
rawKind: rawKind, provider: try? reader.string(reader.field(row, "provider")),
|
||||
votes: try? reader.number(reader.field(row, "votes")),
|
||||
referenceLength: try? reader.number(reader.field(row, "episode_length"))))
|
||||
}
|
||||
}
|
||||
let title: String?
|
||||
if let series { title = series + " — Episode \(episode)" + (episodeTitle.map { ": " + $0 } ?? "") }
|
||||
else { title = episodeTitle.map { "Episode \(episode): " + $0 } }
|
||||
return .metadata(MiruroMetadata(title: title, intervals: intervals))
|
||||
}
|
||||
|
||||
private static func cleanTitle(_ value: String?) -> String? {
|
||||
guard let value, value.utf8.count <= 2048,
|
||||
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { return nil }
|
||||
let title = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return title.isEmpty ? nil : title
|
||||
}
|
||||
}
|
||||
|
||||
// Only traverse fields needed for metadata. The budget counts repeated references as well as strings,
|
||||
// bounding amplification without copying/expanding whole devalue objects or sourcesData.
|
||||
private final class MetadataReader {
|
||||
struct Cursor { let table: [Any]; let index: Int; let depth: Int }
|
||||
private let envelopes: [[String: Any]]
|
||||
private var budget = MiruroMetadataResolver.bodyLimit
|
||||
|
||||
init(envelopes: [[String: Any]]) { self.envelopes = envelopes }
|
||||
|
||||
private func charge(_ cost: Int = 32) throws {
|
||||
guard cost <= budget else { throw MiruroMetadataError() }
|
||||
budget -= cost
|
||||
}
|
||||
|
||||
private func value(_ cursor: Cursor) throws -> Any {
|
||||
let cursor = try resolved(cursor)
|
||||
try charge()
|
||||
let value = cursor.table[cursor.index]
|
||||
if let tag = value as? [Any], tag.first is String { throw MiruroMetadataError() }
|
||||
return value
|
||||
}
|
||||
|
||||
private func reference(_ value: Any, from cursor: Cursor) throws -> Cursor {
|
||||
guard let index = Self.integer(value), index >= 0 else { throw MiruroMetadataError() }
|
||||
return Cursor(table: cursor.table, index: index, depth: cursor.depth + 1)
|
||||
}
|
||||
|
||||
func field(_ cursor: Cursor, _ name: String) throws -> Cursor {
|
||||
// A promise's resolved object must retain its own table for subsequent references.
|
||||
let cursor = try resolved(cursor)
|
||||
guard let object = try value(cursor) as? [String: Any], let reference = object[name] else { throw MiruroMetadataError() }
|
||||
return try self.reference(reference, from: cursor)
|
||||
}
|
||||
|
||||
private func resolved(_ cursor: Cursor) throws -> Cursor {
|
||||
try charge()
|
||||
guard cursor.depth < 32, cursor.table.indices.contains(cursor.index) else { throw MiruroMetadataError() }
|
||||
guard let tag = cursor.table[cursor.index] as? [Any], tag.first as? String == "Promise" else { return cursor }
|
||||
guard tag.count == 2 else { throw MiruroMetadataError() }
|
||||
let id = try integer(reference(tag[1], from: cursor))
|
||||
let matches = envelopes.filter { ($0["type"] as? String) == "chunk" && Self.integer($0["id"]) == id }
|
||||
guard matches.count == 1, let chunk = matches.first, chunk["error"] == nil,
|
||||
let table = chunk["data"] as? [Any] else { throw MiruroMetadataError() }
|
||||
return try resolved(Cursor(table: table, index: 0, depth: cursor.depth + 1))
|
||||
}
|
||||
|
||||
func array(_ cursor: Cursor) throws -> [Cursor] {
|
||||
let cursor = try resolved(cursor)
|
||||
guard let rows = try value(cursor) as? [Any] else { throw MiruroMetadataError() }
|
||||
try charge(rows.count * 32)
|
||||
// Bad individual references do not invalidate their siblings.
|
||||
return rows.compactMap { try? reference($0, from: cursor) }
|
||||
}
|
||||
|
||||
func string(_ cursor: Cursor) throws -> String {
|
||||
guard let string = try value(cursor) as? String else { throw MiruroMetadataError() }
|
||||
try charge(string.utf8.count)
|
||||
return string
|
||||
}
|
||||
|
||||
func number(_ cursor: Cursor) throws -> Double {
|
||||
guard let number = try value(cursor) as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { throw MiruroMetadataError() }
|
||||
return number.doubleValue
|
||||
}
|
||||
|
||||
func integer(_ cursor: Cursor) throws -> Int {
|
||||
guard let integer = Self.integer(try value(cursor)) else { throw MiruroMetadataError() }
|
||||
return integer
|
||||
}
|
||||
|
||||
private static func integer(_ value: Any?) -> Int? {
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||
let value = number.doubleValue
|
||||
guard value.isFinite, value.rounded(.towardZero) == value, value >= 0, value < Double(Int.max) else { return nil }
|
||||
return Int(value)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MetadataRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ struct PlaybackQueue {
|
||||
struct Item: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
var label: String? = nil
|
||||
var displayName: String { label ?? url.lastPathComponent }
|
||||
}
|
||||
|
||||
private(set) var items: [Item] = []
|
||||
private(set) var currentID: UUID?
|
||||
var currentURL: URL? { items.first { $0.id == currentID }?.url }
|
||||
var currentItem: Item? { items.first { $0.id == currentID } }
|
||||
var currentURL: URL? { currentItem?.url }
|
||||
private var currentIndex: Int? { items.firstIndex { $0.id == currentID } }
|
||||
var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false }
|
||||
var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false }
|
||||
@@ -18,6 +21,21 @@ struct PlaybackQueue {
|
||||
currentID = items.first?.id
|
||||
}
|
||||
|
||||
// Unlike file batches, stream entries already have an intentional playback order.
|
||||
mutating func replaceStreams(_ entries: [(url: URL, label: String)]) {
|
||||
items = entries.map { Item(url: $0.url, label: $0.label) }
|
||||
currentID = items.first?.id
|
||||
}
|
||||
|
||||
mutating func appendStreams(_ entries: [(url: URL, label: String)]) {
|
||||
items.append(contentsOf: entries.map { Item(url: $0.url, label: $0.label) })
|
||||
}
|
||||
|
||||
mutating func updateCurrentLabel(_ label: String) {
|
||||
guard let index = currentIndex else { return }
|
||||
items[index].label = label
|
||||
}
|
||||
|
||||
mutating func append(_ urls: [URL]) {
|
||||
items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
struct StreamQueue: Sendable {
|
||||
struct Entry: Sendable {
|
||||
let url: URL
|
||||
let identity: String
|
||||
let progressFile: URL
|
||||
let label: String
|
||||
}
|
||||
let entries: [Entry]
|
||||
|
||||
private struct Document: Decodable {
|
||||
struct Item: Decodable { let url: String; let progress_file: String }
|
||||
let version: Int
|
||||
let items: [Item]
|
||||
}
|
||||
private struct InvalidQueue: LocalizedError {
|
||||
var errorDescription: String? { "Invalid stream queue: expected version 1, 1–4096 unique supported videos and new absolute report files in private owned directories." }
|
||||
}
|
||||
// Explicit-app URL delivery reaches both cold launches and already running players.
|
||||
static func enqueueURL(for file: URL) -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = "terebi-enqueue"
|
||||
components.host = "queue"
|
||||
components.queryItems = [URLQueryItem(name: "file", value: file.path)]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
static func enqueueFile(from url: URL) throws -> URL {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.scheme == "terebi-enqueue", components.host == "queue",
|
||||
components.path.isEmpty, components.user == nil, components.password == nil,
|
||||
components.port == nil, components.fragment == nil,
|
||||
let query = components.queryItems, query.count == 1, query[0].name == "file",
|
||||
let path = query[0].value, path.hasPrefix("/"), !path.contains("\0") else {
|
||||
throw InvalidQueue()
|
||||
}
|
||||
return URL(fileURLWithPath: path).standardizedFileURL
|
||||
}
|
||||
|
||||
static func load(from file: URL) throws -> StreamQueue {
|
||||
do {
|
||||
guard file.isFileURL, file.path.hasPrefix("/") else { throw InvalidQueue() }
|
||||
let descriptor = Darwin.open(file.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC)
|
||||
guard descriptor >= 0 else { throw InvalidQueue() }
|
||||
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
||||
defer { try? handle.close() }
|
||||
var info = stat()
|
||||
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { throw InvalidQueue() }
|
||||
let data = try handle.read(upToCount: 8 * 1024 * 1024 + 1) ?? Data()
|
||||
guard data.count <= 8 * 1024 * 1024 else { throw InvalidQueue() }
|
||||
let document = try JSONDecoder().decode(Document.self, from: data)
|
||||
guard document.version == 1, (1...4096).contains(document.items.count) else { throw InvalidQueue() }
|
||||
var identities = Set<String>()
|
||||
var reports = Set<String>()
|
||||
let entries = try document.items.map { item -> Entry in
|
||||
guard item.url == item.url.trimmingCharacters(in: .whitespacesAndNewlines) else { throw InvalidQueue() }
|
||||
let stream = try StreamURL.parse(item.url)
|
||||
let url: URL
|
||||
let identity: String
|
||||
let label: String
|
||||
switch stream {
|
||||
case .youtube(let canonical):
|
||||
url = canonical
|
||||
identity = canonical.absoluteString
|
||||
label = canonical.absoluteString
|
||||
case .miruro(let mediaID, let episode):
|
||||
url = URL(string: item.url)!
|
||||
identity = "miruro:\(mediaID):\(episode)"
|
||||
label = "\(url.lastPathComponent.replacingOccurrences(of: "-", with: " ")) · Episode \(episode)"
|
||||
}
|
||||
guard identities.insert(identity).inserted,
|
||||
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
|
||||
let report = URL(fileURLWithPath: item.progress_file).standardizedFileURL
|
||||
let parent = report.deletingLastPathComponent()
|
||||
var directoryInfo = stat()
|
||||
guard lstat(parent.path, &directoryInfo) == 0,
|
||||
(directoryInfo.st_mode & S_IFMT) == S_IFDIR,
|
||||
directoryInfo.st_uid == geteuid(), (directoryInfo.st_mode & 0o7777) == 0o700,
|
||||
let resolvedParent = realpath(parent.path, nil) else { throw InvalidQueue() }
|
||||
defer { free(resolvedParent) }
|
||||
let canonicalReport = URL(fileURLWithPath: String(cString: resolvedParent), isDirectory: true)
|
||||
.appendingPathComponent(report.lastPathComponent)
|
||||
guard reports.insert(canonicalReport.path).inserted else { throw InvalidQueue() }
|
||||
var existing = stat()
|
||||
guard lstat(canonicalReport.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() }
|
||||
return Entry(url: url, identity: identity, progressFile: canonicalReport, label: label)
|
||||
}
|
||||
return StreamQueue(entries: entries)
|
||||
} catch { throw InvalidQueue() }
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ enum StreamURL: Sendable {
|
||||
|
||||
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.")
|
||||
let url = try YouTubeResolver.validateURL(text)
|
||||
if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
|
||||
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
|
||||
guard !text.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
|
||||
}), let components = URLComponents(string: text),
|
||||
|
||||
+494
-49
@@ -25,7 +25,9 @@ func compactProgressText(position: Double, duration: Double, format: CompactTitl
|
||||
final class PlayerModel: ObservableObject {
|
||||
static var startupConfiguration = PlayerConfiguration()
|
||||
static var startupProgressFile: String?
|
||||
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile)
|
||||
static var startupStreamQueue: StreamQueue?
|
||||
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile,
|
||||
streamQueue: startupStreamQueue)
|
||||
|
||||
@Published private(set) var state = PlayerState()
|
||||
@Published private(set) var fileName: String?
|
||||
@@ -48,6 +50,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var queuePlaybackRevision: UInt64?
|
||||
private var queueCompletionHandled = false
|
||||
private var queueCastDevice: CastDevice?
|
||||
private var resolvedQueueSource: (id: UUID, source: ResolvedSource)?
|
||||
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
|
||||
var canNextQueueItem: Bool { canOpen && queue.canNext }
|
||||
private var transition = PlaybackTransition()
|
||||
@@ -57,6 +60,49 @@ final class PlayerModel: ObservableObject {
|
||||
var castConnecting: Bool { casting.connecting }
|
||||
private var currentMedia: MediaSource?
|
||||
private var shouldRecordRecentMedia = false
|
||||
private var metadataLifecycle = MetadataLifecycle()
|
||||
private var metadataTask: Task<Void, Never>?
|
||||
var visibleIntervals: [MiruroInterval] {
|
||||
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
||||
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
||||
return metadataLifecycle.visibleIntervals(duration: state.duration)
|
||||
}
|
||||
@Published private var skipOffer: (interval: MiruroInterval, deadline: Double)?
|
||||
private var offeredSegments: Set<MiruroInterval.Kind> = []
|
||||
private let skipClock: () -> Double
|
||||
private var currentSkipInterval: MiruroInterval? {
|
||||
guard canSeek, state.has_video, !isSeeking, !transition.seekPending, !state.seek_pending,
|
||||
state.status == PLAYER_PLAYING || state.status == PLAYER_PAUSED else { return nil }
|
||||
return visibleIntervals.first { state.position >= $0.start && state.position < $0.end }
|
||||
}
|
||||
var segmentToSkip: MiruroInterval? {
|
||||
guard let skipOffer, skipClock() < skipOffer.deadline,
|
||||
currentSkipInterval == skipOffer.interval else { return nil }
|
||||
return skipOffer.interval
|
||||
}
|
||||
var skipOfferRemainingFraction: Double {
|
||||
guard segmentToSkip != nil, let skipOffer else { return 0 }
|
||||
return min(1, max(0, (skipOffer.deadline - skipClock()) / 10))
|
||||
}
|
||||
|
||||
private func updateSegmentSkip() {
|
||||
guard let interval = currentSkipInterval else {
|
||||
if skipOffer != nil { skipOffer = nil }
|
||||
return
|
||||
}
|
||||
if offeredSegments.insert(interval.kind).inserted {
|
||||
skipOffer = (interval, skipClock() + 10)
|
||||
} else if let skipOffer, skipClock() >= skipOffer.deadline || skipOffer.interval != interval {
|
||||
self.skipOffer = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func skipSegment() -> Bool {
|
||||
guard let interval = segmentToSkip, seekRelative(interval.end - state.position) else { return false }
|
||||
skipOffer = nil
|
||||
return true
|
||||
}
|
||||
var pendingRevision: UInt64? { transition.pendingRevision }
|
||||
var resolvingStream: Bool { transition.isResolving }
|
||||
var isLiveStream: Bool { transition.isLive }
|
||||
@@ -131,14 +177,22 @@ final class PlayerModel: ObservableObject {
|
||||
private var reportingEnabled = false
|
||||
private var reportingErrorShown = false
|
||||
private var reportingErrorBuffer = [CChar]()
|
||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
|
||||
private var reportingErrorMessage: String {
|
||||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||||
progressFile: String? = nil,
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
self.skipClock = skipClock
|
||||
self.youtubeResolver = youtubeResolver
|
||||
self.miruroResolver = miruroResolver
|
||||
keyBindings = configuration.keys
|
||||
compactTitleProgress = configuration.compactTitleProgress
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
@@ -165,6 +219,9 @@ final class PlayerModel: ObservableObject {
|
||||
progressInitializationError = "Could not create the media player to initialize progress reporting."
|
||||
}
|
||||
}
|
||||
if let streamQueue, !registerQueueReports(streamQueue) {
|
||||
progressInitializationError = reportingErrorMessage
|
||||
}
|
||||
compactWindow.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
if !self.compactWindow.isTransitioning {
|
||||
@@ -266,6 +323,7 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
}
|
||||
systemPlayback?.update()
|
||||
updateSegmentSkip()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -307,9 +365,93 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
showsYouTubeInput = false
|
||||
clearQueue()
|
||||
let url: URL
|
||||
switch stream {
|
||||
case .youtube(let url): resolveYouTube(url, height: nil)
|
||||
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode)
|
||||
case .youtube(let canonical): url = canonical
|
||||
case .miruro: url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
|
||||
}
|
||||
queue.replaceStreams([(url: url, label: url.absoluteString)])
|
||||
resolveStream(stream, change: .open)
|
||||
}
|
||||
|
||||
func openStreamQueue(_ streamQueue: StreamQueue) {
|
||||
guard canOpen, progressInitializationError == nil else { return }
|
||||
queueCastDevice = nil
|
||||
queue.replaceStreams(streamQueue.entries.map { (url: $0.url, label: $0.label) })
|
||||
if fileName == nil { configureStartWindow(showing: false) }
|
||||
showsQueue = queue.items.count > 1
|
||||
showsYouTubeInput = false
|
||||
openQueueSelection(keepingReceiver: false)
|
||||
}
|
||||
|
||||
private func registerQueueReports(_ streamQueue: StreamQueue) -> Bool {
|
||||
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
|
||||
guard let handle else { return false }
|
||||
let identities = streamQueue.entries.map { strdup($0.identity) }
|
||||
let paths = streamQueue.entries.map { strdup($0.progressFile.path) }
|
||||
defer {
|
||||
for value in identities { free(value) }
|
||||
for value in paths { free(value) }
|
||||
}
|
||||
let identityPointers = identities.map { $0.map { UnsafePointer($0) } }
|
||||
let pathPointers = paths.map { $0.map { UnsafePointer($0) } }
|
||||
let accepted = player_register_progress_files(handle, identityPointers, pathPointers,
|
||||
Int32(streamQueue.entries.count), &reportingErrorBuffer)
|
||||
if accepted { reportingEnabled = true }
|
||||
return accepted
|
||||
}
|
||||
|
||||
func enqueueStreamQueue(_ streamQueue: StreamQueue) {
|
||||
guard canOpen else {
|
||||
commandError = "Could not create the media player to enqueue streams."
|
||||
return
|
||||
}
|
||||
let existing = Set(queue.items.compactMap { item -> String? in
|
||||
guard !item.url.isFileURL else { return nil }
|
||||
if let stream = try? StreamURL.parse(item.url.absoluteString) {
|
||||
switch stream {
|
||||
case .youtube(let url): return url.absoluteString
|
||||
case .miruro(let id, let episode): return "miruro:\(id):\(episode)"
|
||||
}
|
||||
}
|
||||
return item.url.absoluteString
|
||||
})
|
||||
guard !streamQueue.entries.contains(where: { existing.contains($0.identity) }) else {
|
||||
commandError = "Cannot enqueue a video already present in this queue."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
guard registerQueueReports(streamQueue) else {
|
||||
commandError = reportingErrorMessage
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
let needsStart = queue.currentID == nil && currentMedia == nil && !resolvingStream
|
||||
queue.appendStreams(streamQueue.entries.map { (url: $0.url, label: $0.label) })
|
||||
commandError = nil
|
||||
showsQueue = queue.items.count > 1
|
||||
if needsStart, let first = queue.items.first {
|
||||
queue.select(first.id)
|
||||
if fileName == nil { configureStartWindow(showing: false) }
|
||||
openQueueSelection(keepingReceiver: false)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveEnqueueURL(_ url: URL) {
|
||||
do {
|
||||
let file = try StreamQueue.enqueueFile(from: url)
|
||||
enqueueStreamQueue(try StreamQueue.load(from: file))
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveStream(_ stream: StreamURL, change: MediaChange) {
|
||||
replaceMetadata(for: nil)
|
||||
switch stream {
|
||||
case .youtube(let url): resolveYouTube(url, height: nil, change: change)
|
||||
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +459,8 @@ final class PlayerModel: ObservableObject {
|
||||
do {
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
clearQueue()
|
||||
queue.replaceStreams([(url: URL(string: source.identity)!, label: source.title)])
|
||||
resolvedQueueSource = (queue.currentID!, source)
|
||||
cancelStreamResolution()
|
||||
openMedia(.resolved(source))
|
||||
} catch {
|
||||
@@ -385,7 +529,7 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: height, change: change)
|
||||
}
|
||||
|
||||
private func resolveMiruro(mediaID: String, episode: Int) {
|
||||
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
cancelStreamResolution()
|
||||
@@ -396,13 +540,17 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
objectWillChange.send()
|
||||
let request = transition.beginResolution(.open)
|
||||
let request = transition.beginResolution(change)
|
||||
systemPlayback?.update()
|
||||
commandError = nil
|
||||
revealControls()
|
||||
let resolver = miruroResolver
|
||||
let task = Task { [weak self] in
|
||||
do {
|
||||
let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
|
||||
let source = try await resolver(mediaID, episode)
|
||||
guard source.identity == "miruro:\(mediaID):\(episode)" else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.objectWillChange.send()
|
||||
guard let change = self.transition.finishResolution(request) else { return }
|
||||
@@ -424,7 +572,7 @@ final class PlayerModel: ObservableObject {
|
||||
cancelStreamResolution()
|
||||
if !change.automatic { automaticQuality = AutomaticQuality() }
|
||||
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 the stream. Please try again."
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
@@ -433,9 +581,11 @@ final class PlayerModel: ObservableObject {
|
||||
systemPlayback?.update()
|
||||
commandError = nil
|
||||
revealControls()
|
||||
let resolver = youtubeResolver
|
||||
let task = Task { [weak self] in
|
||||
do {
|
||||
let video = try await YouTubeResolver.resolve(url, height: height)
|
||||
let video = try await resolver(url, height)
|
||||
guard video.webpageURL == url else { throw CocoaError(.fileReadCorruptFile) }
|
||||
guard !Task.isCancelled, let self, let handle = self.handle else { return }
|
||||
self.objectWillChange.send()
|
||||
guard var change = self.transition.finishResolution(request) else { return }
|
||||
@@ -470,14 +620,45 @@ final class PlayerModel: ObservableObject {
|
||||
automaticQuality.extractionFailed(at: qualityNow)
|
||||
return
|
||||
}
|
||||
commandError = error?.localizedDescription ?? "Could not queue the YouTube video to open. Please try again."
|
||||
commandError = error?.localizedDescription ?? "Could not queue the video to open. Please try again."
|
||||
if change.isQuality && change.playing, let handle, !player_play(handle) {
|
||||
commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ??
|
||||
"Could not queue the YouTube video or resume the previous playback. Please try again."
|
||||
"Could not queue the video or resume the previous playback. Please try again."
|
||||
}
|
||||
revealControls()
|
||||
}
|
||||
|
||||
private func replaceMetadata(for source: MediaSource?) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
if source?.url.absoluteString != metadataLifecycle.identity {
|
||||
offeredSegments.removeAll()
|
||||
skipOffer = nil
|
||||
}
|
||||
let parts = source?.url.absoluteString.split(separator: ":") ?? []
|
||||
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
|
||||
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
|
||||
guard let source, let episode else { return }
|
||||
let identity = source.url.absoluteString
|
||||
let mediaID = String(parts[1])
|
||||
metadataTask = Task { [weak self] in
|
||||
guard let metadata = try? await MiruroMetadataResolver.resolve(mediaID: mediaID, episode: episode),
|
||||
!Task.isCancelled, let self,
|
||||
self.currentMedia?.url.absoluteString == identity,
|
||||
self.metadataLifecycle.accept(metadata, identity: identity, generation: generation) else { return }
|
||||
self.objectWillChange.send()
|
||||
self.updateSegmentSkip()
|
||||
if let title = metadata.title {
|
||||
self.fileName = title
|
||||
self.window?.title = title
|
||||
self.casting.updateTitle(title)
|
||||
if self.queue.currentID != nil { self.queue.updateCurrentLabel(title) }
|
||||
self.systemPlayback?.update()
|
||||
}
|
||||
self.metadataTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
@@ -563,6 +744,7 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
}
|
||||
if let fileName, !source.url.isFileURL { queue.updateCurrentLabel(fileName) }
|
||||
selectSubtitle(nil)
|
||||
if case .resolved(let resolved) = source {
|
||||
subtitleTracks = resolved.subtitles
|
||||
@@ -578,6 +760,7 @@ final class PlayerModel: ObservableObject {
|
||||
window?.representedURL = source.url
|
||||
window?.title = fileName ?? "Terebi"
|
||||
revealControls()
|
||||
replaceMetadata(for: source)
|
||||
systemPlayback?.update()
|
||||
}
|
||||
|
||||
@@ -633,13 +816,22 @@ final class PlayerModel: ObservableObject {
|
||||
queuePlaybackRevision = nil
|
||||
queueCompletionHandled = true
|
||||
cancelStreamResolution()
|
||||
openMedia(.file(url), change: .queue(playing: receiver == nil))
|
||||
let change = MediaChange.queue(playing: receiver == nil)
|
||||
if let original = resolvedQueueSource, queue.currentID == original.id {
|
||||
openMedia(.resolved(original.source), change: change)
|
||||
} else if url.isFileURL {
|
||||
openMedia(.file(url), change: change)
|
||||
} else {
|
||||
do { resolveStream(try StreamURL.parse(url.absoluteString), change: change) }
|
||||
catch { commandError = error.localizedDescription }
|
||||
}
|
||||
queueCastDevice = receiver
|
||||
}
|
||||
|
||||
private func clearQueue() {
|
||||
queue.replace([])
|
||||
queuePlaybackRevision = nil
|
||||
resolvedQueueSource = nil
|
||||
queueCompletionHandled = false
|
||||
queueCastDevice = nil
|
||||
}
|
||||
@@ -718,6 +910,7 @@ final class PlayerModel: ObservableObject {
|
||||
queueCompletionHandled = true
|
||||
queueCastDevice = nil
|
||||
currentMedia = nil
|
||||
replaceMetadata(for: nil)
|
||||
shouldRecordRecentMedia = false
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
@@ -787,7 +980,7 @@ final class PlayerModel: ObservableObject {
|
||||
queueAnimation = nil
|
||||
queueAnimationCompletion = nil
|
||||
let targetWidth: CGFloat = showsQueue ? 260 : 0
|
||||
guard let window, !isCompact, !compactWindow.isTransitioning else {
|
||||
guard let window, fileName != nil, !isCompact, !compactWindow.isTransitioning else {
|
||||
queueWidth = targetWidth
|
||||
queueWindowVideoWidth = nil
|
||||
return
|
||||
@@ -1131,12 +1324,17 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func poll() {
|
||||
guard let handle else { return }
|
||||
if window?.isVisible == true, let streamQueue = Self.startupStreamQueue {
|
||||
Self.startupStreamQueue = nil
|
||||
openStreamQueue(streamQueue)
|
||||
}
|
||||
if reportingEnabled && !reportingErrorShown {
|
||||
if player_get_progress_error(handle, &reportingErrorBuffer) {
|
||||
showReportingError(reportingErrorMessage)
|
||||
}
|
||||
}
|
||||
if !casting.active { applyState(player_get_state(handle)) }
|
||||
else { updateSegmentSkip() }
|
||||
}
|
||||
|
||||
func applyState(_ next: PlayerState) {
|
||||
@@ -1148,6 +1346,7 @@ final class PlayerModel: ObservableObject {
|
||||
handle: handle, history: playbackProgress)
|
||||
guard observation.ready else { return }
|
||||
defer { systemPlayback?.update() }
|
||||
defer { updateSegmentSkip() }
|
||||
state = next
|
||||
updateSubtitle()
|
||||
if shouldRecordRecentMedia, !isBusy, next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED,
|
||||
@@ -1223,6 +1422,7 @@ final class PlayerModel: ObservableObject {
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
castDiscovery.stop()
|
||||
cancelStreamResolution()
|
||||
replaceMetadata(for: nil)
|
||||
selectSubtitle(nil)
|
||||
systemPlayback?.shutdown()
|
||||
systemPlayback = nil
|
||||
@@ -1281,7 +1481,7 @@ final class PlayerModel: ObservableObject {
|
||||
private final class Operation {
|
||||
let device: CastDevice
|
||||
let source: MediaSource
|
||||
let title: String
|
||||
var title: String
|
||||
let subtitle: Int?
|
||||
let handle: OpaquePointer
|
||||
let snapshot: PlayerState
|
||||
@@ -1324,6 +1524,11 @@ final class PlayerModel: ObservableObject {
|
||||
return (operation.source, projection.state)
|
||||
}
|
||||
|
||||
func updateTitle(_ title: String) {
|
||||
// The existing Cast interface accepts titles at LOAD; do not reload active playback.
|
||||
operation?.title = title
|
||||
}
|
||||
|
||||
func start(to device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
|
||||
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
|
||||
volume: Double, audibleVolume: Double) {
|
||||
@@ -1454,7 +1659,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard var projection = operation.projection else { return }
|
||||
projection.state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED)
|
||||
projection.state.position = status.position
|
||||
if status.duration > 0 { projection.state.duration = status.duration }
|
||||
projection.state.duration = status.duration.isFinite && status.duration > 0 ? status.duration : 0
|
||||
projection.state.buffering = status.buffering
|
||||
projection.state.can_seek = status.seekable && !operation.source.live
|
||||
projection.state.seek_pending = status.seekPending
|
||||
@@ -1535,6 +1740,78 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetadataLifecycle {
|
||||
private(set) var identity: String?
|
||||
private(set) var metadata: MiruroMetadata?
|
||||
private var episode: Int?
|
||||
private var generation: UInt64 = 0
|
||||
|
||||
mutating func replace(identity: String?, episode: Int?) -> UInt64 {
|
||||
generation &+= 1
|
||||
self.identity = identity
|
||||
self.episode = episode
|
||||
metadata = nil
|
||||
return generation
|
||||
}
|
||||
|
||||
mutating func accept(_ metadata: MiruroMetadata, identity: String, generation: UInt64) -> Bool {
|
||||
guard self.identity == identity, self.generation == generation else { return false }
|
||||
self.metadata = metadata
|
||||
return true
|
||||
}
|
||||
|
||||
func visibleIntervals(duration: Double) -> [MiruroInterval] {
|
||||
guard let episode, let metadata else { return [] }
|
||||
return metadata.visibleIntervals(episode: episode, duration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
#if PLAYER_CHECK
|
||||
func supplyMetadataForCheck(_ metadata: MiruroMetadata, episode: Int) {
|
||||
metadataTask?.cancel()
|
||||
metadataTask = nil
|
||||
let identity = currentMedia!.url.absoluteString
|
||||
let generation = metadataLifecycle.replace(identity: identity, episode: episode)
|
||||
precondition(metadataLifecycle.accept(metadata, identity: identity, generation: generation))
|
||||
updateSegmentSkip()
|
||||
}
|
||||
|
||||
static func metadataLifecycleCheck() {
|
||||
var lifecycle = MetadataLifecycle()
|
||||
let a = "miruro:A:1"
|
||||
let b = "miruro:B:2"
|
||||
let metadata = MiruroMetadata(title: "Series — Episode 1", intervals: [
|
||||
MiruroInterval(kind: .intro, start: 2.5, end: 10, episode: 1),
|
||||
MiruroInterval(kind: .outro, start: 90, end: 100, episode: 1),
|
||||
])
|
||||
let first = lifecycle.replace(identity: a, episode: 1)
|
||||
precondition(lifecycle.accept(metadata, identity: a, generation: first), "Current metadata is accepted")
|
||||
precondition(lifecycle.visibleIntervals(duration: 0).isEmpty, "Unknown duration hides bands")
|
||||
precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "Actual duration permits exact-end bands")
|
||||
precondition(lifecycle.visibleIntervals(duration: 95) == [
|
||||
metadata.intervals[0], MiruroInterval(kind: .outro, start: 90, end: 95, episode: 1)
|
||||
], "Duration shrink clips raw bands")
|
||||
precondition(lifecycle.visibleIntervals(duration: 100) == metadata.intervals, "Duration growth restores raw bands")
|
||||
_ = lifecycle.replace(identity: b, episode: 2)
|
||||
precondition(lifecycle.metadata == nil, "Replacement clears title and intervals")
|
||||
precondition(!lifecycle.accept(metadata, identity: a, generation: first), "Different-identity result is stale")
|
||||
let second = lifecycle.replace(identity: a, episode: 1)
|
||||
precondition(!lifecycle.accept(metadata, identity: a, generation: first), "A-B-A result is stale")
|
||||
precondition(lifecycle.accept(metadata, identity: a, generation: second), "Fresh same-identity result is accepted")
|
||||
precondition(lifecycle.accept(MiruroMetadata(title: nil, intervals: []), identity: a, generation: second),
|
||||
"Empty optional metadata is accepted")
|
||||
precondition(lifecycle.metadata?.title == nil && lifecycle.visibleIntervals(duration: 100).isEmpty,
|
||||
"Absent title and intervals do not retain enrichment")
|
||||
_ = lifecycle.replace(identity: "file:///replacement.mp4", episode: nil)
|
||||
precondition(lifecycle.metadata == nil && lifecycle.visibleIntervals(duration: 100).isEmpty,
|
||||
"Local replacement has no title enrichment or bands")
|
||||
_ = lifecycle.replace(identity: "https://www.youtube.com/watch?v=fixture", episode: nil)
|
||||
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
|
||||
_ = lifecycle.replace(identity: nil, episode: nil)
|
||||
precondition(lifecycle.metadata == nil, "Close clears metadata")
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct PlaybackTransition {
|
||||
enum PlaybackSubmission {
|
||||
case unchanged
|
||||
@@ -1829,6 +2106,7 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
var volume = false
|
||||
var enabled = true
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
var intervals: [MiruroInterval] = []
|
||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
@@ -1851,8 +2129,10 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
slider.setModelValue(value)
|
||||
slider.isEnabled = enabled
|
||||
slider.editingChanged = onEditingChanged
|
||||
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
|
||||
if let cell = slider.cell as? Cell,
|
||||
cell.bufferedRange != bufferedRange || cell.intervals != intervals {
|
||||
cell.bufferedRange = bufferedRange
|
||||
cell.intervals = intervals
|
||||
slider.needsDisplay = true
|
||||
}
|
||||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||
@@ -1891,18 +2171,71 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
guard isEnabled else { return }
|
||||
tracking = true
|
||||
editingChanged(true)
|
||||
super.mouseDown(with: event)
|
||||
tracking = false
|
||||
editingChanged(false)
|
||||
defer {
|
||||
tracking = false
|
||||
editingChanged(false)
|
||||
}
|
||||
guard let cell = cell as? Cell, !cell.volume else {
|
||||
super.mouseDown(with: event)
|
||||
return
|
||||
}
|
||||
let bar = cell.barRect(flipped: isFlipped)
|
||||
let start = bar.minX + cell.knobThickness / 2
|
||||
let width = bar.width - cell.knobThickness
|
||||
guard width > 0, maxValue > minValue else { return }
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
func seek(_ x: CGFloat) {
|
||||
let fraction = min(1, max(0, (x - start) / width))
|
||||
let value = minValue + fraction * (maxValue - minValue)
|
||||
guard value != doubleValue else { return }
|
||||
doubleValue = value
|
||||
if let action { sendAction(action, to: target) }
|
||||
}
|
||||
seek(point.x)
|
||||
while let next = window?.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) {
|
||||
let nextPoint = convert(next.locationInWindow, from: nil)
|
||||
if next.type == .leftMouseDragged || nextPoint.x != point.x { seek(nextPoint.x) }
|
||||
if next.type == .leftMouseUp { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class Cell: NSSliderCell {
|
||||
var volume = false
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
var intervals: [MiruroInterval] = []
|
||||
#if PLAYER_CHECK
|
||||
var renderedKnob = NSRect.zero
|
||||
#endif
|
||||
func visibleKnobRect(_ rect: NSRect) -> NSRect {
|
||||
let width: CGFloat = volume ? 16 : 3
|
||||
let height: CGFloat = volume ? 12 : 15
|
||||
return NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2,
|
||||
width: width, height: height)
|
||||
}
|
||||
override func drawBar(inside rect: NSRect, flipped: Bool) {
|
||||
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
|
||||
width: max(0, rect.width - knobThickness), height: 3)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
if !volume, maxValue > minValue, bar.width > 0 {
|
||||
let scale = bar.width / (maxValue - minValue)
|
||||
let boundaries = Array(Set([minValue, maxValue] + intervals.flatMap {
|
||||
[$0.start, $0.end]
|
||||
}.filter { $0 > minValue && $0 < maxValue })).sorted()
|
||||
let track = NSBezierPath()
|
||||
for index in 0..<(boundaries.count - 1) {
|
||||
let start = bar.minX + (boundaries[index] - minValue) * scale
|
||||
let end = bar.minX + (boundaries[index + 1] - minValue) * scale
|
||||
let inset = min(1.5, (end - start) / 4)
|
||||
let left = index == 0 ? start : start + inset
|
||||
let right = index == boundaries.count - 2 ? end : end - inset
|
||||
track.append(NSBezierPath(roundedRect:
|
||||
NSRect(x: left, y: bar.minY, width: right - left, height: bar.height),
|
||||
xRadius: 1.5, yRadius: 1.5))
|
||||
}
|
||||
track.addClip()
|
||||
}
|
||||
SunsetPalette.track.setFill()
|
||||
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
if volume {
|
||||
@@ -1921,9 +2254,10 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
override func drawKnob(_ rect: NSRect) {
|
||||
let width: CGFloat = volume ? 16 : 3
|
||||
let height: CGFloat = volume ? 12 : 15
|
||||
let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height)
|
||||
let knob = visibleKnobRect(rect)
|
||||
#if PLAYER_CHECK
|
||||
renderedKnob = knob
|
||||
#endif
|
||||
let color = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText)
|
||||
: SunsetPalette.disabledThumb
|
||||
color.setFill()
|
||||
@@ -1935,6 +2269,53 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
#if PLAYER_CHECK
|
||||
@MainActor
|
||||
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
|
||||
@MainActor
|
||||
func playbackSliderHitCheck() -> Bool {
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 80),
|
||||
styleMask: [.borderless], backing: .buffered, defer: false)
|
||||
let slider = PlaybackSlider.Slider(frame: NSRect(x: 20, y: 20, width: 280, height: 14))
|
||||
slider.cell = PlaybackSlider.Cell()
|
||||
slider.minValue = 0
|
||||
slider.maxValue = 100
|
||||
slider.isContinuous = true
|
||||
window.contentView!.addSubview(slider)
|
||||
window.display()
|
||||
let cell = slider.cell as! PlaybackSlider.Cell
|
||||
slider.doubleValue = slider.minValue
|
||||
let left = cell.knobRect(flipped: slider.isFlipped).midX
|
||||
slider.doubleValue = slider.maxValue
|
||||
let right = cell.knobRect(flipped: slider.isFlipped).midX
|
||||
for (initial, offset, movement): (Double, CGFloat, CGFloat) in [
|
||||
(50, -4, 0), (50, -2, 0), (50, -1, 0), (50, 0, 0), (50, 1, 0), (50, 2, 0), (50, 4, 0),
|
||||
(20, -1, -20), (80, 1, 20), (20, -4, -20), (80, 4, 20),
|
||||
(20, 0, -500), (80, 0, 500)
|
||||
] {
|
||||
slider.doubleValue = initial
|
||||
let knob = cell.knobRect(flipped: slider.isFlipped)
|
||||
let point = slider.convert(NSPoint(x: knob.midX + offset, y: knob.midY), to: nil)
|
||||
let end = NSPoint(x: point.x + movement, y: point.y)
|
||||
let up = NSEvent.mouseEvent(with: .leftMouseUp, location: end, modifierFlags: [],
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
eventNumber: 1, clickCount: 1, pressure: 0)!
|
||||
let down = NSEvent.mouseEvent(with: .leftMouseDown, location: point, modifierFlags: [],
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
eventNumber: 0, clickCount: 1, pressure: 1)!
|
||||
NSApp.postEvent(up, atStart: true)
|
||||
if movement != 0 {
|
||||
let drag = NSEvent.mouseEvent(with: .leftMouseDragged, location: end, modifierFlags: [],
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
eventNumber: 1, clickCount: 1, pressure: 1)!
|
||||
NSApp.postEvent(drag, atStart: true)
|
||||
}
|
||||
slider.mouseDown(with: down)
|
||||
slider.display()
|
||||
let movedKnob = cell.renderedKnob
|
||||
let expectedMovement = movement + offset
|
||||
let expectedX = min(right, max(left, knob.midX + expectedMovement))
|
||||
guard abs(movedKnob.midX - expectedX) < 0.5 else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
@@ -2475,6 +2856,12 @@ private struct PlayerContent: View {
|
||||
|
||||
private var playbackControls: some View {
|
||||
let buffered = model.bufferedRange
|
||||
let intervals = model.visibleIntervals
|
||||
let intervalStatus = intervals.map {
|
||||
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
|
||||
}.joined(separator: ", ")
|
||||
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus].compactMap { $0 }
|
||||
.joined(separator: ", ")
|
||||
return VStack(spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
@@ -2522,13 +2909,13 @@ private struct PlayerContent: View {
|
||||
HStack(spacing: 10) {
|
||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||||
enabled: model.canSeek, bufferedRange: buffered, onEditingChanged: { editing in
|
||||
enabled: model.canSeek, bufferedRange: buffered, intervals: intervals, onEditingChanged: { editing in
|
||||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||
})
|
||||
.frame(height: 14)
|
||||
.accessibilityLabel("Playback Position")
|
||||
.accessibilityValue(time(model.seekPosition) + (bufferedStatus.map { ", " + $0 } ?? ""))
|
||||
.help(bufferedStatus ?? "Playback Position")
|
||||
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
|
||||
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)
|
||||
Button {
|
||||
showsRemainingTime.toggle()
|
||||
model.revealControls()
|
||||
@@ -2641,15 +3028,15 @@ private struct PlayerContent: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Remove from Queue")
|
||||
.accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue")
|
||||
.accessibilityLabel("Remove \(item.displayName) from Queue")
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.url.deletingPathExtension().lastPathComponent)
|
||||
Text(item.url.isFileURL ? item.url.deletingPathExtension().lastPathComponent : item.displayName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.lineLimit(2)
|
||||
.lineSpacing(2)
|
||||
.truncationMode(.middle)
|
||||
if !item.url.pathExtension.isEmpty {
|
||||
if item.url.isFileURL && !item.url.pathExtension.isEmpty {
|
||||
Text(item.url.pathExtension.uppercased())
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.tracking(0.6)
|
||||
@@ -2669,7 +3056,7 @@ private struct PlayerContent: View {
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { model.selectQueueItem(item.id) }
|
||||
.help(item.url.path + "\nClick to play; drag to reorder.")
|
||||
.help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.")
|
||||
.contextMenu {
|
||||
Button("Play") { model.selectQueueItem(item.id) }
|
||||
Button("Remove from Queue") { model.removeQueueItem(item.id) }
|
||||
@@ -2795,7 +3182,7 @@ private struct PlayerContent: View {
|
||||
|
||||
var body: some View {
|
||||
let titleControlsVisible = model.showsQueue || model.isQueueTransitioning || model.controlsVisible
|
||||
let startScreen = model.fileName == nil && !model.isCompact && !model.compactWindow.isTransitioning
|
||||
let startScreen = model.fileName == nil && model.queue.items.isEmpty && !model.isCompact && !model.compactWindow.isTransitioning
|
||||
HStack(spacing: 0) {
|
||||
videoContent
|
||||
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
|
||||
@@ -2875,8 +3262,8 @@ private struct PlayerContent: View {
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(PlayerGlass().opacity(0.6)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||||
.frame(maxWidth: geometry.size.width * 0.8)
|
||||
@@ -3012,6 +3399,44 @@ private struct PlayerContent: View {
|
||||
.allowsHitTesting(!model.isCompact && model.controlsVisible)
|
||||
.accessibilityHidden(model.isCompact || !model.controlsVisible)
|
||||
}
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if let interval = model.segmentToSkip {
|
||||
Button {
|
||||
model.skipSegment()
|
||||
} label: {
|
||||
Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending",
|
||||
systemImage: "forward.end.fill")
|
||||
.font(.system(size: model.isCompact ? 11 : 13, weight: .semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color(SunsetPalette.text))
|
||||
.background {
|
||||
ZStack(alignment: .leading) {
|
||||
PlayerGlass(cornerRadius: 8)
|
||||
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in
|
||||
GeometryReader { geometry in
|
||||
let elapsed = 1 - model.skipOfferRemainingFraction
|
||||
Color(SunsetPalette.text).opacity(0.25)
|
||||
.frame(width: geometry.size.width *
|
||||
(reduceMotion ? floor(elapsed * 10) / 10 : elapsed))
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.transition(reduceMotion ? .identity : .opacity)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, model.isCompact ? 8 : 16)
|
||||
.padding(.top, 44)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.segmentToSkip?.kind)
|
||||
.allowsHitTesting(model.segmentToSkip != nil)
|
||||
if dropTargeted {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
|
||||
@@ -3025,7 +3450,7 @@ private struct PlayerContent: View {
|
||||
model.openFiles(urls)
|
||||
} else {
|
||||
guard urls.count == 1, let url = urls.first,
|
||||
(try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { return false }
|
||||
(try? StreamURL.parse(url.absoluteString)) != nil else { return false }
|
||||
model.open(url)
|
||||
}
|
||||
return true
|
||||
@@ -3159,11 +3584,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let events = NSAppleEventManager.shared()
|
||||
events.setEventHandler(self, andSelector: #selector(openInternetURL(_:withReplyEvent:)),
|
||||
forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
|
||||
// Pre-main model/AppKit initialization suppresses SwiftUI's initial player window.
|
||||
if let error = PlayerModel.shared.progressInitializationError {
|
||||
PlayerModel.shared.shutdown()
|
||||
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate()
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Could not initialize playback progress reporting"
|
||||
alert.informativeText = error
|
||||
alert.runModal()
|
||||
exit(73)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
|
||||
PlayerModel.shared.openStream(text)
|
||||
guard let url = URL(string: text) else { return }
|
||||
if url.scheme == "terebi-enqueue" { PlayerModel.shared.receiveEnqueueURL(url) }
|
||||
else { PlayerModel.shared.openStream(text) }
|
||||
}
|
||||
|
||||
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
@@ -3189,7 +3629,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
|
||||
if urls.contains(where: { $0.scheme == "terebi-enqueue" }) {
|
||||
guard urls.count == 1, let url = urls.first else { return }
|
||||
PlayerModel.shared.receiveEnqueueURL(url)
|
||||
} else if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
|
||||
else if let url = urls.first { PlayerModel.shared.open(url) }
|
||||
}
|
||||
|
||||
@@ -3239,6 +3682,21 @@ public func terebiAppMain() {
|
||||
exit(78)
|
||||
}
|
||||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||
let queueArguments = arguments.filter { $0.hasPrefix("--queue-file=") }
|
||||
if let argument = queueArguments.first {
|
||||
do {
|
||||
let path = String(argument.dropFirst("--queue-file=".count))
|
||||
guard path.hasPrefix("/"), queueArguments.count == 1,
|
||||
!arguments.contains("--progress-file"), !arguments.contains("--source-file"),
|
||||
!arguments.contains("--open-media") else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
PlayerModel.startupStreamQueue = try StreamQueue.load(from: URL(fileURLWithPath: path))
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
|
||||
exit(73)
|
||||
}
|
||||
}
|
||||
if let index = arguments.firstIndex(of: "--progress-file") {
|
||||
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
|
||||
arguments.filter({ $0 == "--progress-file" }).count == 1 else {
|
||||
@@ -3250,19 +3708,6 @@ public func terebiAppMain() {
|
||||
exit(73)
|
||||
}
|
||||
PlayerModel.startupProgressFile = arguments[index + 1]
|
||||
_ = NSApplication.shared
|
||||
if let error = PlayerModel.shared.progressInitializationError {
|
||||
PlayerModel.shared.shutdown()
|
||||
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate()
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Could not initialize playback progress reporting"
|
||||
alert.informativeText = error
|
||||
alert.runModal()
|
||||
exit(73)
|
||||
}
|
||||
}
|
||||
TerebiApp.main()
|
||||
}
|
||||
|
||||
@@ -30,12 +30,13 @@ enum YouTubeResolver {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
var components = URLComponents(string: text),
|
||||
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == (scheme == "https" ? 443 : 80),
|
||||
let host = components.host?.lowercased() else {
|
||||
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.")
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let host = components.host?.lowercased(), !host.isEmpty,
|
||||
let url = components.url, url.absoluteString.utf8.count <= 65536 else {
|
||||
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS video URL without embedded credentials.")
|
||||
}
|
||||
let id: String?
|
||||
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
@@ -51,11 +52,12 @@ enum YouTubeResolver {
|
||||
id = nil
|
||||
}
|
||||
} else {
|
||||
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.")
|
||||
id = nil
|
||||
}
|
||||
guard let id, id.utf8.count == 11,
|
||||
id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else {
|
||||
throw YouTubeResolutionError(message: "The YouTube URL must identify one video, not a playlist or channel.")
|
||||
components.scheme = scheme
|
||||
return components.url!
|
||||
}
|
||||
return URL(string: "https://www.youtube.com/watch?v=\(id)")!
|
||||
}
|
||||
@@ -112,7 +114,7 @@ enum YouTubeResolver {
|
||||
throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.")
|
||||
}
|
||||
} else if let selectedHeight = videoFormat.height, selectedHeight > 1080 {
|
||||
throw YouTubeResolutionError(message: "YouTube did not provide a supported stream at or below 1080p.")
|
||||
throw YouTubeResolutionError(message: "yt-dlp did not provide a supported stream at or below 1080p.")
|
||||
}
|
||||
let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? [])
|
||||
let hasSeparateAudio = formats.contains {
|
||||
@@ -129,7 +131,7 @@ enum YouTubeResolver {
|
||||
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
||||
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return YouTubeVideo(
|
||||
title: title?.isEmpty == false ? title! : "YouTube video",
|
||||
title: title?.isEmpty == false ? title! : "Video",
|
||||
video: video, audio: audio, duration: duration,
|
||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
||||
webpageURL: try validateURL(webpageURL.absoluteString),
|
||||
@@ -151,7 +153,7 @@ enum YouTubeResolver {
|
||||
?? bundledExecutable("yt-dlp")
|
||||
?? findExecutable("yt-dlp", directories: directories)
|
||||
guard let executable else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
|
||||
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
|
||||
}
|
||||
let runtime: URL?
|
||||
if let runtimeURL {
|
||||
@@ -162,12 +164,12 @@ enum YouTubeResolver {
|
||||
}
|
||||
guard let runtime,
|
||||
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.")
|
||||
throw YouTubeResolutionError(message: "Stream playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.")
|
||||
}
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = directories.joined(separator: ":")
|
||||
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=1080]"
|
||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=?1080]"
|
||||
let arguments = [
|
||||
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
||||
@@ -257,7 +259,7 @@ enum YouTubeResolver {
|
||||
return YouTubeResolutionError(message: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.")
|
||||
}
|
||||
if text.contains("no such option") || text.contains("unrecognized arguments") {
|
||||
return YouTubeResolutionError(message: "The bundled yt-dlp is too old for YouTube streaming. Upgrade terebi or rebuild its bundled helpers.")
|
||||
return YouTubeResolutionError(message: "The bundled yt-dlp is too old for streaming. Upgrade terebi or rebuild its bundled helpers.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -267,16 +269,16 @@ enum YouTubeResolver {
|
||||
if let error = dependencyError(text) { return error }
|
||||
// Never include extractor output: it can contain signed URLs or credentials.
|
||||
if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") {
|
||||
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
|
||||
return YouTubeResolutionError(message: "This site requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
|
||||
}
|
||||
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
|
||||
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.")
|
||||
return YouTubeResolutionError(message: "yt-dlp did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.")
|
||||
}
|
||||
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
|
||||
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.")
|
||||
return YouTubeResolutionError(message: "This video is unavailable or restricted in your region.")
|
||||
}
|
||||
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
|
||||
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; upgrade terebi if the problem persists.")
|
||||
return YouTubeResolutionError(message: "Could not reach the video site. Check your network connection and try again; upgrade terebi if the problem persists.")
|
||||
}
|
||||
return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, then try again.")
|
||||
}
|
||||
@@ -298,8 +300,9 @@ private struct YouTubeMetadata: Decodable {
|
||||
let http_headers: [String: String]?
|
||||
let requested_formats: [YouTubeMetadata]?
|
||||
|
||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
|
||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
||||
}
|
||||
|
||||
private struct YouTubeExtractionOutput: Sendable {
|
||||
|
||||
@@ -5,24 +5,31 @@ enum PlayerCommand {
|
||||
case printDefaultConfig
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
||||
case enqueue(queueFile: URL, noConfig: Bool)
|
||||
|
||||
static let usage = """
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
|
||||
terebi [--new-instance] [--no-config] --url STREAM_URL
|
||||
terebi --new-instance --url STREAM_URL --progress-file PATH
|
||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||
terebi --new-instance [--no-config] --queue-file PATH
|
||||
terebi --enqueue [--no-config] --queue-file PATH
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
terebi --default-config
|
||||
|
||||
Open one local file, YouTube video, Miruro episode or resolved source and start playback,
|
||||
Open one local file, yt-dlp-supported video, Miruro episode or resolved source and start playback,
|
||||
resuming saved video progress. With no arguments, open or activate the player.
|
||||
Quote paths and URLs containing spaces or shell metacharacters.
|
||||
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Open a YouTube video or Miruro episode URL.
|
||||
--url URL Resolve an HTTP(S) video URL with yt-dlp, or open a Miruro episode.
|
||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
||||
--enqueue Append a queue to the running player without interrupting playback;
|
||||
launch and start its first item if no player is running.
|
||||
--progress-file PATH Write JSONL playback facts to a new private file; requires
|
||||
--new-instance. The GUI owns reporting after this CLI exits.
|
||||
-- Treat the following argument as a filename, even with a leading '-'.
|
||||
@@ -55,16 +62,23 @@ enum PlayerCommand {
|
||||
}
|
||||
var newInstance = false
|
||||
var noConfig = false
|
||||
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
|
||||
var enqueue = false
|
||||
while let option = inputs.first, ["--new-instance", "--no-config", "--enqueue"].contains(option) {
|
||||
if option == "--new-instance" {
|
||||
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
|
||||
newInstance = true
|
||||
} else if option == "--enqueue" {
|
||||
guard !enqueue else { throw ArgumentError("--enqueue may only be supplied once.") }
|
||||
enqueue = true
|
||||
} else {
|
||||
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
|
||||
noConfig = true
|
||||
}
|
||||
inputs.removeFirst()
|
||||
}
|
||||
guard !enqueue || (!newInstance && progressFile == nil && inputs.first == "--queue-file") else {
|
||||
throw ArgumentError("--enqueue requires --queue-file and cannot combine with --new-instance, --progress-file or other media.")
|
||||
}
|
||||
func launch(_ url: URL?) throws -> PlayerCommand {
|
||||
guard progressFile == nil || newInstance else {
|
||||
throw ArgumentError("--progress-file requires --new-instance.")
|
||||
@@ -90,6 +104,16 @@ enum PlayerCommand {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
|
||||
return .printDefaultConfig
|
||||
}
|
||||
if inputs.first == "--queue-file" {
|
||||
guard newInstance || enqueue, progressFile == nil, inputs.count == 2,
|
||||
inputs[1].hasPrefix("/"), !inputs[1].contains("\0") else {
|
||||
throw ArgumentError("--queue-file requires --new-instance or --enqueue and one absolute manifest path; it cannot be combined with --progress-file.")
|
||||
}
|
||||
let queueFile = URL(fileURLWithPath: inputs[1]).standardizedFileURL
|
||||
_ = try StreamQueue.load(from: queueFile)
|
||||
if enqueue { return .enqueue(queueFile: queueFile, noConfig: noConfig) }
|
||||
return .launchQueue(queueFile: queueFile, newInstance: newInstance, noConfig: noConfig)
|
||||
}
|
||||
if inputs.first == "--source-file" {
|
||||
guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") }
|
||||
guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"),
|
||||
|
||||
@@ -21,10 +21,17 @@ enum TerebiCLI {
|
||||
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)
|
||||
case .launchQueue(let queueFile, let newInstance, let noConfig):
|
||||
await launch(url: nil, sourceFile: nil, newInstance: newInstance, noConfig: noConfig,
|
||||
progressFile: nil, queueFile: queueFile)
|
||||
case .enqueue(let queueFile, let noConfig):
|
||||
await launch(url: StreamQueue.enqueueURL(for: queueFile), sourceFile: nil,
|
||||
newInstance: false, noConfig: noConfig, progressFile: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) async {
|
||||
private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool,
|
||||
progressFile: URL?, queueFile: URL? = nil) async {
|
||||
if let progressFile {
|
||||
var existing = stat()
|
||||
guard lstat(progressFile.path, &existing) != 0 else {
|
||||
@@ -69,6 +76,9 @@ enum TerebiCLI {
|
||||
if let progressFile {
|
||||
configuration.arguments += ["--progress-file", progressFile.path]
|
||||
}
|
||||
if let queueFile {
|
||||
configuration.arguments += ["--queue-file=" + queueFile.path]
|
||||
}
|
||||
if let sourceFile {
|
||||
configuration.arguments += ["--source-file", sourceFile.path]
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
#include <arpa/inet.h>
|
||||
|
||||
namespace playback {
|
||||
inline bool validNetworkURL(const char *text) {
|
||||
if (!text) return false;
|
||||
const std::string_view url(text);
|
||||
inline bool validNetworkURL(std::string_view url) {
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
@@ -52,6 +50,9 @@ inline bool validNetworkURL(const char *text) {
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
inline bool validNetworkURL(const char *text) {
|
||||
return text && validNetworkURL(std::string_view(text));
|
||||
}
|
||||
inline bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
|
||||
+23
-4
@@ -197,7 +197,7 @@ struct Player {
|
||||
PlayerStatus status = PLAYER_EMPTY;
|
||||
bool previewShown = false;
|
||||
std::array<char, 512> error{};
|
||||
std::unique_ptr<playback::ProgressReporter> reporter;
|
||||
const std::unique_ptr<playback::ProgressReports> reporter = std::make_unique<playback::ProgressReports>();
|
||||
bool remoteReporting = false;
|
||||
std::thread worker;
|
||||
|
||||
@@ -766,7 +766,9 @@ struct Player {
|
||||
for (auto &input : inputs) {
|
||||
if (!input) continue;
|
||||
input->discardAudio(false);
|
||||
if (input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target))
|
||||
// EOF is a timeline position, not a demux seek target (HLS rejects it).
|
||||
if ((duration > 0 && target >= duration) ||
|
||||
(input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target)))
|
||||
input->finish();
|
||||
else
|
||||
input->seek(-1, timestamp);
|
||||
@@ -1427,10 +1429,10 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
try {
|
||||
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
|
||||
std::lock_guard<std::mutex> lock(player->mutex);
|
||||
if (player->reporter || player->stopping.load(std::memory_order_relaxed) ||
|
||||
if (player->stopping.load(std::memory_order_relaxed) ||
|
||||
player->state.status != PLAYER_EMPTY || !player->commands.empty())
|
||||
throw std::runtime_error("Progress reporting must be initialized once, before opening media");
|
||||
player->reporter = std::make_unique<playback::ProgressReporter>(path);
|
||||
player->reporter->setSingleFile(path);
|
||||
return true;
|
||||
} catch (const std::exception &failure) {
|
||||
if (error) copyError(error, failure.what());
|
||||
@@ -1439,6 +1441,23 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool player_register_progress_files(Player *player, const char *const *identities,
|
||||
const char *const *paths, int count, char *error) {
|
||||
if (error) error[0] = '\0';
|
||||
try {
|
||||
if (!player) throw std::runtime_error("A player is required for queue progress registration");
|
||||
std::lock_guard<std::mutex> lock(player->mutex);
|
||||
if (player->stopping.load(std::memory_order_relaxed))
|
||||
throw std::runtime_error("Cannot register queue reports while the player is stopping");
|
||||
player->reporter->registerFiles(identities, paths, count);
|
||||
return true;
|
||||
} catch (const std::exception &failure) {
|
||||
if (error) copyError(error, failure.what());
|
||||
} catch (...) {
|
||||
if (error) copyError(error, "Could not register queue progress reporting");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool player_get_progress_error(Player *player, char *error) {
|
||||
if (error) error[0] = '\0';
|
||||
if (!player || !player->reporter) return false;
|
||||
|
||||
@@ -40,6 +40,12 @@ Player *player_create(void);
|
||||
// Opt-in reporting must be initialized before opening media. Creates a new 0600
|
||||
// file exclusively; errors never change playback state. Error buffers hold 512 bytes.
|
||||
bool player_set_progress_file(Player *player, const char *path, char *error);
|
||||
// Register queue destinations without changing playback. Each path is new and stays reserved.
|
||||
// A fresh path replaces an identity's previous session after draining its writer; callers
|
||||
// reject identities still queued. Revisits append only to the registered session's owned inode.
|
||||
// Atomic batch: all registrations succeed or none do. Strings are copied before returning.
|
||||
bool player_register_progress_files(Player *player, const char *const *identities,
|
||||
const char *const *paths, int count, char *error);
|
||||
bool player_get_progress_error(Player *player, char *error);
|
||||
// Stops the worker and drains final reporting, allowing errors to be shown before
|
||||
// destroying the handle. Idempotent; do not call from an audio callback.
|
||||
@@ -53,8 +59,8 @@ typedef struct PlayerNetworkSource {
|
||||
const char *audio_headers;
|
||||
double duration;
|
||||
bool is_live;
|
||||
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode>
|
||||
// identity, never an extracted stream/CDN URL.
|
||||
// Original HTTP(S) video URL (canonical for YouTube), or stable
|
||||
// miruro:<base64url media_id>:<positive episode> identity, never an extracted CDN URL.
|
||||
// NULL/empty means no reporting for this source.
|
||||
const char *media_url;
|
||||
// Initial restored position, applied before publishing ready state. Zero
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "progress_reporter.hpp"
|
||||
#include "network_policy.hpp"
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
@@ -28,7 +29,7 @@ void appendNumber(std::string &record, double value) {
|
||||
}
|
||||
|
||||
ProgressReporter::ProgressReporter(const std::string &path, std::function<double()> clock)
|
||||
: clock(clock ? std::move(clock) : monotonicSeconds) {
|
||||
: path(path), clock(clock ? std::move(clock) : monotonicSeconds) {
|
||||
if (path.empty() || path.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("The progress file path must be nonempty and contain no NUL bytes");
|
||||
descriptor = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||
@@ -49,6 +50,8 @@ ProgressReporter::ProgressReporter(const std::string &path, std::function<double
|
||||
throw std::runtime_error(std::string("Could not verify progress file permissions: ") + std::strerror(errno));
|
||||
if ((info.st_mode & 07777) != 0600)
|
||||
throw std::runtime_error("The progress file must have exactly owner read/write permissions (0600)");
|
||||
device = info.st_dev;
|
||||
inode = info.st_ino;
|
||||
writer = std::thread([this] { writeRecords(); });
|
||||
} catch (...) {
|
||||
::close(descriptor);
|
||||
@@ -59,14 +62,33 @@ ProgressReporter::ProgressReporter(const std::string &path, std::function<double
|
||||
|
||||
ProgressReporter::~ProgressReporter() { finish(); }
|
||||
|
||||
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
||||
constexpr std::string_view prefix = "https://www.youtube.com/watch?v=";
|
||||
if (url.size() != prefix.size() + 11 || url.substr(0, prefix.size()) != prefix) return false;
|
||||
for (char character : url.substr(prefix.size())) {
|
||||
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') || character == '_' || character == '-')) return false;
|
||||
void ProgressReporter::resume() {
|
||||
if (!finished) return;
|
||||
if (disabled.load(std::memory_order_acquire))
|
||||
throw std::runtime_error("Cannot resume a failed progress report");
|
||||
descriptor = ::open(path.c_str(), O_WRONLY | O_APPEND | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (descriptor < 0) throw std::runtime_error("Could not reopen the owned progress report");
|
||||
struct stat info{};
|
||||
if (::fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode) ||
|
||||
info.st_dev != device || info.st_ino != inode || info.st_uid != ::geteuid() ||
|
||||
info.st_nlink != 1 || (info.st_mode & 07777) != 0600) {
|
||||
::close(descriptor);
|
||||
descriptor = -1;
|
||||
throw std::runtime_error("The owned progress report was replaced or is no longer private");
|
||||
}
|
||||
return true;
|
||||
try {
|
||||
stopping = false;
|
||||
writer = std::thread([this] { writeRecords(); });
|
||||
finished = false;
|
||||
} catch (...) {
|
||||
::close(descriptor);
|
||||
descriptor = -1;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
||||
return validNetworkURL(url);
|
||||
}
|
||||
|
||||
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
||||
@@ -176,7 +198,10 @@ bool ProgressReporter::emit(const char *event) noexcept {
|
||||
record += "},\"position_seconds\":";
|
||||
} else {
|
||||
record += "\",\"media_url\":\"";
|
||||
record += mediaURL;
|
||||
for (char character : mediaURL) {
|
||||
if (character == '"' || character == '\\') record += '\\';
|
||||
record += character;
|
||||
}
|
||||
record += "\",\"position_seconds\":";
|
||||
}
|
||||
appendNumber(record, snapshot.position);
|
||||
@@ -261,4 +286,113 @@ void ProgressReporter::finish() noexcept {
|
||||
wake.notify_all();
|
||||
if (writer.joinable()) writer.join();
|
||||
}
|
||||
|
||||
ProgressReports::ProgressReports(const std::string &path) { setSingleFile(path); }
|
||||
|
||||
void ProgressReports::setSingleFile(const std::string &path) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (single || !entries.empty()) throw std::runtime_error("Progress reporting is already initialized");
|
||||
single = std::make_unique<ProgressReporter>(path);
|
||||
singlePath = path;
|
||||
active = single.get();
|
||||
}
|
||||
|
||||
void ProgressReports::registerFile(const std::string &identity, const std::string &path) {
|
||||
const char *identities[] = {identity.c_str()}, *destinations[] = {path.c_str()};
|
||||
registerFiles(identities, destinations, 1);
|
||||
}
|
||||
|
||||
void ProgressReports::registerFiles(const char *const *identities, const char *const *destinations, int count) {
|
||||
std::lock_guard lock(mutex);
|
||||
if (!identities || !destinations || count < 1 || count > 4096 ||
|
||||
paths.size() + count > 4096)
|
||||
throw std::runtime_error("Queue reports exceed the 4096 retained registration limit");
|
||||
std::map<std::string, Entry> additions;
|
||||
std::set<std::string> newPaths;
|
||||
for (int index = 0; index < count; ++index) {
|
||||
const auto *identity = identities[index], *path = destinations[index];
|
||||
if (!identity || !path || !ProgressReporter::validMediaIdentity(identity) ||
|
||||
!*path || *path != '/' || additions.contains(identity))
|
||||
throw std::runtime_error("Invalid or conflicting queue progress identity");
|
||||
if (singlePath == path || paths.contains(path) || !newPaths.insert(path).second)
|
||||
throw std::runtime_error("Conflicting queue progress report path");
|
||||
additions.emplace(identity, Entry{path, nullptr});
|
||||
}
|
||||
// Validate and allocate the whole batch before retiring any previous sessions.
|
||||
auto retire = [this](ProgressReporter *reporter) {
|
||||
if (!reporter) return;
|
||||
reporter->finish();
|
||||
if (active == reporter) active = nullptr;
|
||||
try { if (failure.empty()) failure = reporter->error(); } catch (...) {}
|
||||
};
|
||||
for (auto &[identity, entry] : additions) {
|
||||
if (singleIdentity == identity) retire(single.get());
|
||||
if (auto found = entries.find(identity); found != entries.end()) {
|
||||
retire(found->second.reporter.get());
|
||||
std::swap(found->second, entry);
|
||||
}
|
||||
}
|
||||
// Node merges allocate nothing; replaced nodes remain in additions for destruction.
|
||||
entries.merge(additions);
|
||||
paths.merge(newPaths);
|
||||
}
|
||||
|
||||
void ProgressReports::observe(const PlayerState &state) noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (active) active->observe(state);
|
||||
}
|
||||
|
||||
void ProgressReports::closeMedia() noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (active) active->closeMedia();
|
||||
}
|
||||
|
||||
void ProgressReports::begin(const std::string &identity, bool live) noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto found = entries.find(identity);
|
||||
const bool useSingle = found == entries.end() && single &&
|
||||
(entries.empty() || singleIdentity.empty() || singleIdentity == identity);
|
||||
auto *next = found == entries.end() ? (useSingle ? single.get() : nullptr) : found->second.reporter.get();
|
||||
if (active != next && active) active->finish();
|
||||
active = nullptr; // A failed or unregistered identity must never reuse the old report.
|
||||
if (found == entries.end() && !useSingle) return;
|
||||
try {
|
||||
if (found == entries.end()) {
|
||||
singleIdentity = identity;
|
||||
single->resume();
|
||||
active = single.get();
|
||||
} else {
|
||||
auto &entry = found->second;
|
||||
if (!entry.reporter) entry.reporter = std::make_unique<ProgressReporter>(entry.path);
|
||||
else entry.reporter->resume();
|
||||
active = entry.reporter.get();
|
||||
}
|
||||
active->begin(identity, live);
|
||||
} catch (const std::exception &exception) {
|
||||
try { if (failure.empty()) failure = exception.what(); } catch (...) {}
|
||||
} catch (...) {
|
||||
try { if (failure.empty()) failure = "Could not select queue progress report"; } catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
void ProgressReports::finish() noexcept {
|
||||
std::lock_guard lock(mutex);
|
||||
if (active) active->finish();
|
||||
}
|
||||
|
||||
std::string ProgressReports::error() const {
|
||||
std::lock_guard lock(mutex);
|
||||
if (!failure.empty()) return failure;
|
||||
if (single) {
|
||||
const auto message = single->error();
|
||||
if (!message.empty()) return message;
|
||||
}
|
||||
for (const auto &[identity, entry] : entries) {
|
||||
if (entry.reporter) {
|
||||
auto message = entry.reporter->error();
|
||||
if (!message.empty()) return message;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <sys/types.h>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
|
||||
@@ -26,6 +30,8 @@ public:
|
||||
void closeMedia() noexcept;
|
||||
// Call after the decoder joins; drains queued records and joins the writer.
|
||||
void finish() noexcept;
|
||||
// Reopen only this session's original private inode, never an arbitrary existing file.
|
||||
void resume();
|
||||
std::string error() const;
|
||||
|
||||
private:
|
||||
@@ -36,6 +42,9 @@ private:
|
||||
bool eligible = false;
|
||||
} snapshot;
|
||||
std::string mediaURL;
|
||||
std::string path;
|
||||
dev_t device = 0;
|
||||
ino_t inode = 0;
|
||||
std::function<double()> clock;
|
||||
bool live = false;
|
||||
bool hasSnapshot = false;
|
||||
@@ -58,4 +67,29 @@ private:
|
||||
void disable(const char *message, int osError = 0) noexcept;
|
||||
void writeRecords() noexcept;
|
||||
};
|
||||
|
||||
// Routes decoder-owned snapshots by immutable media identity; only the active writer runs.
|
||||
class ProgressReports {
|
||||
public:
|
||||
explicit ProgressReports(const std::string &path);
|
||||
ProgressReports() = default;
|
||||
void setSingleFile(const std::string &path);
|
||||
void registerFile(const std::string &identity, const std::string &path);
|
||||
void registerFiles(const char *const *identities, const char *const *paths, int count);
|
||||
void begin(const std::string &identity, bool live = false) noexcept;
|
||||
void observe(const PlayerState &state) noexcept;
|
||||
void closeMedia() noexcept;
|
||||
void finish() noexcept;
|
||||
std::string error() const;
|
||||
private:
|
||||
struct Entry { std::string path; std::unique_ptr<ProgressReporter> reporter; };
|
||||
std::unique_ptr<ProgressReporter> single;
|
||||
std::string singlePath;
|
||||
std::string singleIdentity;
|
||||
std::map<std::string, Entry> entries;
|
||||
std::set<std::string> paths;
|
||||
ProgressReporter *active = nullptr;
|
||||
mutable std::mutex mutex;
|
||||
std::string failure;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
- [x] improve chrome cast subtitle rendering
|
||||
- native WebVTT style: white sans-serif, black outline, transparent background; physical-TV font-scale tuning remains unverified
|
||||
- [x] currently, computer falls asleep even if video is playing. this should not be the case.
|
||||
- native playback activity prevents idle system sleep; local video keeps the display awake, casting allows screensaver/display sleep; native assertion checks and loopback Cast transitions passed, physical idle-TV verification remains unverified
|
||||
- [x] add session-only local-file playlist support
|
||||
- multi-file Open/Finder/drop, natural filename order, editable queue sidebar, explicit append, previous/next and automatic advance; Cast navigation retains the receiver; UI/drag-drop and physical-Cast verification remain unverified
|
||||
- [x] make sure the `make install` command installs the application (i can't find it in spotlight)
|
||||
- installs to ~/Applications, migrates the old hidden app, registers with Launch Services and requests Spotlight indexing; actual install, CLI, code signature, registration and Spotlight query passed; Spotlight UI remains unverified
|
||||
- [x] takes an awfully long time seek in local videos which i find weird, since seeking in streamed youtube videos is pretty much instant
|
||||
- local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification
|
||||
- [x] make the sidebar expand/collapse with a nice transition
|
||||
- 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.
|
||||
- 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
|
||||
- [x] on the start screen:
|
||||
- [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
|
||||
- [x] change the youtube button to general streaming
|
||||
- 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
|
||||
- [x] 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
|
||||
- audited SDK 27.0 / Swift 6.4 with the macOS 15 minimum retained; migrated Cast discovery to NWBrowser and production activation to activate(). Real Bonjour identity/name/update/order/deduplication/removal/restart and routable-endpoint checks, loopback Cast transitions, focused startup-error alerts and a cooperating-launcher production smoke for cold start/CLI reuse/document handoff/keyboard playback passed. The existing full CLI snapshot-harness timeout remains; macOS 15 runtime and physical-Cast verification remain unverified. Observation, glass and the existing native bridges were left unchanged.
|
||||
- [x] fix the readme. it's currently bloated with irrelevant implementation detail and other such nonsense. it should do the job of a readme only, nothing more.
|
||||
- [ ] in the playlist sidebar, pressing the `+` button by the queue indicator should it expand it to double it's size (with a nice transition) split down the middle with the left side being a file icon and the right side being a link icon.
|
||||
- pressing the file icon should open the file picker for local file selection (current behavior)
|
||||
- pressing the link icon should present an input field that opens upwards with a nice transition where the user can paste a link; pressing return will fetch and add the video.
|
||||
- we already have this ui effect in the start screen when pressing the "open url" button, which we should be able to reuse much of.
|
||||
- [ ] in compact mode, "side"bar should be expandable downwards (but hidden by default).
|
||||
- let's also add a keybinding that toggles sidebar visibility in both normal and compact mode.
|
||||
@@ -4,25 +4,70 @@
|
||||
|
||||
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'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.
|
||||
Tubular launches native episode URLs. Terebi resolves fresh direct sources and independently acquires optional descriptive titles and opening/ending metadata. Metadata never gates source playback: missing or invalid enrichment retains a generic title and no bands.
|
||||
|
||||
## Ownership
|
||||
|
||||
- **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.
|
||||
- **Tubular:** catalog/detail browsing, actual returned episode selection, collections, private reporting sessions, cleanup and watched-state decisions.
|
||||
- **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting.
|
||||
|
||||
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.
|
||||
Native source resolution selects direct **`ssub` / `sun`**. Other providers, iframe embedding, cookies, impersonation and protection-bypass machinery are not supported.
|
||||
|
||||
## Launch contract
|
||||
|
||||
```sh
|
||||
terebi --new-instance --source-file '/private/session/source.json' \
|
||||
terebi --new-instance \
|
||||
--url 'https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=1' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
`--source-file` requires `--new-instance`; `--progress-file` is optional and retains its existing behavior. Relative paths resolve against the invoking shell's directory. Each launch creates an independent player; the CLI passes absolute paths to the GUI, not media URLs/header values in arguments.
|
||||
The slug `episode` is deliberately non-identifying. Use the returned episode number, not the absolute episode, array index or an external catalog ID. Slug/title changes never change resume or progress identity.
|
||||
|
||||
Create a unique session directory with mode `0700`, write the source document with mode `0600`, and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report with mode `0600`; it rejects existing destinations and symlinks. Keep the source file unchanged and present for the player session. CLI success means macOS accepted the launch, not that media opened or playback completed. Tubular owns session-file cleanup.
|
||||
Create a private reporting-session directory (`0700`) and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report (`0600`), rejecting existing destinations and symlinks. CLI success means macOS accepted the launch, not that playback succeeded. Tubular consumes reporting independently of launcher lifetime; launcher exit, a final record or Tubular shutdown does not establish that the player stopped. Preserve retained sessions and restart/partial-line recovery.
|
||||
|
||||
### Ordered stream queues
|
||||
|
||||
```sh
|
||||
terebi --new-instance --queue-file '/absolute/private/session/queue.json'
|
||||
# Append to the running player, or launch and play if none is running:
|
||||
terebi --enqueue --queue-file '/absolute/private/session/queue.json'
|
||||
```
|
||||
|
||||
The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback order:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"items": [
|
||||
{"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw", "progress_file": "/absolute/private/youtube-session/progress.jsonl"},
|
||||
{"url": "https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=1", "progress_file": "/absolute/private/episode-session/progress.jsonl"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires either `--new-instance` (replace in a separate player) or `--enqueue` (append to the running player), accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`. `--enqueue` and `--new-instance` are mutually exclusive.
|
||||
|
||||
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Sources resolve only when selected, resolve afresh on revisits, and canceled/stale results cannot replace a newer selection. Labels start with descriptive page information and update from resolved titles/optional metadata.
|
||||
|
||||
Enqueue uses native macOS URL-open delivery to the explicit application, not launch arguments: repeated requests reach the existing process. Both one-item and multi-item requests use the same manifest. The current selection, playback/pause state, timeline, pending resolution and metadata stay intact; an empty player selects and starts the first entry. Original single-file, single-stream and resolved-source playback remain navigable when items are appended. Enqueue never silently deduplicates: any identity still in the queue, or any report-path collision, rejects the complete batch with an error in the player. Removed videos can be re-added with fresh report destinations, including the original standalone video. Old report paths remain reserved for the process lifetime; at most 4096 queue-report destinations may be registered, counting retired sessions. Existing files are never adopted as reporting sessions. Keep manifests and private sessions available until delivery; CLI success acknowledges macOS delivery, not GUI acceptance or playback.
|
||||
|
||||
Each report is immutably bound to its stable identity in the native decoder. Registration is atomic for a complete batch and can occur during playback without redirecting unrelated current reporting. Re-registering a removed identity drains/closes its old writer before replacing the session; the old report is never overwritten or appended to afterward, and the fresh report is created only when the re-added video begins. Switching entries drains/closes the previous writer before beginning the next; revisits within a session append only after verifying the original private report inode, owner, permissions and link count. An unrelated or replaced file is never reopened as an owned session, and an unregistered identity is never written to a previously selected queue report. Only the active item owns a running writer/open report descriptor. Retiring a failed writer preserves its reporting error. Reporting failures remain separate from acknowledged playback/completion semantics. An original single-stream `--progress-file` keeps its destination when navigating back from appended queue entries, unless its row was removed and re-added with a fresh destination.
|
||||
|
||||
Runnable headless queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness drives a real native `PlayerModel` through mixed YouTube/Miruro queues with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. It also delivers repeated enqueue URLs while paused and playing, checks original standalone selection/timeline preservation, lazy append ordering, atomic invalid/conflicting requests, independent single/queue report revisits, removal/re-addition with fresh reports and unchanged retired files, and empty-player startup. These checks require no browser or live streaming service. Actual LaunchServices/UI delivery remains a separate user-owned verification step; no GUI smoke result is claimed for enqueue.
|
||||
|
||||
Removal/re-addition verification: the new native registration regression failed before the fix and passed afterward. `make check-reporting check-queue .build/resolved-playback-check all` passed. A focused `.build/resolved-playback-check /absolute/source.json --enqueue-only` smoke using local video-only H.264 HLS passed paused/playing append, natural-end advancement, atomic conflicts/rollback, removal and re-addition of the current standalone video and an inactive queued video, fresh-session playback, byte-for-byte retired-report preservation, and empty-player startup. The source document requires `headers` and `subtitles` (empty values are valid). No full A/V-suite or packaged-GUI/LaunchServices pass is claimed; the app was built, not installed. Actual UI confirmation remains user-owned; temporary fixture/server files were removed.
|
||||
|
||||
### Optional native metadata
|
||||
|
||||
The native resolver requests watch-route data and safely follows bounded same-origin, same-ID, same-episode canonicalization envelopes, including HTTP-200 redirects. It accepts JSON and newline-delimited `text/sveltekit-data`, decoding only the needed response-local tables and episode promise. Bodies and traversal are bounded; unrelated source promises are not used for playback.
|
||||
|
||||
Titles come from returned series/selected-episode metadata. Only `op`/`mixed-op` and `ed`/`mixed-ed` become opening/ending bands. Bands retain fractional seconds, require finite positive player-reported duration, and reject nonfinite timestamps, originally reversed/zero-length intervals and wrong-episode timings. Display intervals are clipped to `[0, duration]`; intervals with no positive-length overlap are hidden. Raw timestamps remain unchanged, and duration changes recompute clipping without shifting or scaling. Optional provenance/votes/reference-length checks remain in force; a finite positive reference length differing from actual duration by more than 5% rejects the interval, and missing fields are not invented. Highest-vote eligible opening and ending win, with response order breaking ties. Overlaps are not merged.
|
||||
|
||||
The controls mark interval boundaries with small rounded gaps in the slider track, without visible labels or boxes. Buffer rendering shares the segmented track; accessibility descriptions retain opening/ending names and times. While acknowledged, seekable video playback is inside a band, a **Skip Opening** or **Skip Ending** button offers a seek to its end for up to ten seconds of monotonic time, including while paused, or until playback leaves the band. Each kind is offered once until close or media-identity replacement; pointer movement and seeking back do not reoffer ignored segments. Pending seeks and slider previews cannot expose a stale offer. The same action routes to local or Cast playback and retains playback intent. No automatic skipping is performed. Bounds do not prove synchronization across cuts. Metadata tasks are bound to identity and lifecycle generation; replacement clears intervals and rejects stale results, including A → B → A.
|
||||
|
||||
### External resolved-source compatibility
|
||||
|
||||
`--source-file PATH --new-instance` remains available for callers supplying already-resolved sources; it is not Tubular's current launch path and does not acquire URL metadata. Keep private source files unchanged and present for their player session. Their version-1 contract below is unchanged.
|
||||
|
||||
### Source document, version 1
|
||||
|
||||
@@ -60,21 +105,11 @@ All shown fields except `title` are required. `headers: {}` and `subtitles: []`
|
||||
|
||||
Limits: source document 1 MiB; 32 subtitle tracks; 64 headers per resource; Miruro media ID up to 512 ASCII letters/digits/`-`/`_`; episode integer 1–2147483647. Invalid URLs, HTTP header names, case-insensitive duplicate header names and control characters are rejected with sanitized errors. Header values are not trimmed or rewritten.
|
||||
|
||||
### Projection from Tubular's Miruro response
|
||||
### External source projection
|
||||
|
||||
Use the selected direct Sun server/provider from `src/miruro.rs`:
|
||||
External source-document producers must preserve selected direct stream URLs and complete server headers, use Miruro's opaque catalog ID and returned episode number for identity, and carry explicit per-subtitle headers. Subtitle headers **do not inherit** stream headers. The verified Sun path supplies server headers to its VTT resources; do not generalize this to other providers.
|
||||
|
||||
| Terebi field | Tubular resolved source |
|
||||
| --- | --- |
|
||||
| `url` | Selected `server.streams[].url`, unchanged |
|
||||
| `headers` | Selected server's header map, including Origin and Referer |
|
||||
| `identity.media_id` | Miruro opaque catalog ID, not AniList/MAL ID or slug |
|
||||
| `identity.episode` | Selected returned episode number used for source resolution, not absolute episode number or array index |
|
||||
| `subtitles[].url` | Selected provider subtitle's `file` |
|
||||
| `format`, `language`, `label`, `default` | Selected subtitle metadata; restrict this launch to VTT and at most one default |
|
||||
| `subtitles[].headers` | Explicit headers appropriate to that subtitle resource |
|
||||
|
||||
Subtitle headers **do not inherit** stream headers. The verified Sun smoke explicitly supplied its server headers to the English subtitle resource; do not assume this applies to other providers or resources. `identity.provider` is the catalog namespace `miruro`, not the playback provider `sun`.
|
||||
`identity.provider` is the catalog namespace `miruro`, not the stream provider `sun`. Titles, slugs and expiring CDN URLs are not identity. Tubular no longer projects source documents.
|
||||
|
||||
## Propagation and subtitle behavior
|
||||
|
||||
@@ -145,22 +180,34 @@ For only the actual Miruro launch scenario:
|
||||
TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup
|
||||
```
|
||||
|
||||
Default checks use local fixtures and do not contact Miruro. For an **explicit live smoke**, resolve fresh in Tubular, project the private source document above, then run:
|
||||
Default checks use local fixtures and do not contact Miruro. Optional metadata protocol/security/normalization checks:
|
||||
|
||||
```sh
|
||||
make check-miruro-metadata
|
||||
make .build/resolved-playback-check
|
||||
.build/resolved-playback-check '/absolute/private/session/source.json'
|
||||
.build/resolved-playback-check --metadata-lifecycle
|
||||
```
|
||||
|
||||
This checks native decode and acknowledged subtitle/seek state, not visual appearance or perceived A/V synchronization. Use the production launch command for that human check. Tubular's existing `cargo run --locked --example miruro_probe` verifies source resolution/master/subtitle retrieval, not Terebi playback.
|
||||
Explicit live native URL smoke (contacts Miruro and media/subtitle resources):
|
||||
|
||||
## Remaining work and limits
|
||||
```sh
|
||||
.build/miruro-metadata-check --live 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1'
|
||||
.build/resolved-playback-check 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1'
|
||||
```
|
||||
|
||||
1. Tubular must create the source document, launch Terebi with it and implement v2 progress consumption; no Tubular implementation files were changed by this work.
|
||||
2. Confirm this identity/reporting contract on the Tubular side; external adoption is not yet agreement.
|
||||
3. For end-to-end release verification, check English subtitle appearance and synchronization in the visible app before/after seeking, using Tubular's actual launch path.
|
||||
4. Sources requiring media redirects or cross-origin HLS resources will fail explicitly under the current conservative policy. Expired sources must be freshly resolved by Tubular; Terebi does not refresh automatically.
|
||||
URL smoke expects a fixture episode with available title, bands and default subtitles. It checks native decode and acknowledged subtitle/seek state, not visual appearance or perceived A/V synchronization. Tubular's catalog probe does not run Terebi or retrieve sources.
|
||||
|
||||
Other providers' proxies/HLS rewrites, iframe providers, downloads, thumbnails, intro/outro controls, embedded captions and additional external-subtitle formats remain out of scope.
|
||||
Metadata implementation verification (2026-10-22): protocol/security/bounds checks, lifecycle replacement/A-B-A stale results/duration changes, stream resolver and CLI checks passed. The live placeholder resolved the descriptive title “The Apothecary Diaries Season 3 — Episode 1: Locusts,” opening 63.313–153.237 seconds and ending 1335.252–1425.384 seconds; native video/audio advanced, measured-duration bands appeared in model state, and default VTT plus forward/backward acknowledged cue seeks passed. FFmpeg emitted redacted diagnostics during this successful run; no visual synchronization or visible-band sign-off is claimed.
|
||||
|
||||
Controls alignment correction: removed the obsolete label-height reservation after switching to gapped segments. A native offscreen SwiftUI/AppKit layout smoke reproduced a 9-point top-row shift when intervals appeared; after restoring the standard 14-point slider frame, volume and timeline geometry match exactly with and without intervals. Production build passed; visible confirmation remains a human check.
|
||||
|
||||
Segment-skip verification (2026-10-22): local HLS/VTT playback exercised late metadata, the controlled-clock three-second boundary while paused, persistent dismissal, pending-seek suppression, opening/ending seeks, exact media-end seeking, close/reopen reset and playing/paused intent. Exact-end HLS seeks initially failed with `Operation not permitted`; the shared native seek path now finishes network inputs at EOF instead of asking the demuxer to seek beyond its final packet. Network playback regression checks, metadata checks, production build and TLS loopback Cast segment skipping/restoration passed. Rapid play-then-seek probes encountered Core Audio startup errors on the active Bluetooth output; the settled-playback smoke waits for actual advancing playback and passed. Visible button placement, rapid audio transitions and physical-TV behavior remain unverified.
|
||||
|
||||
## Remaining limits
|
||||
|
||||
- Confirm title readability and gapped slider segments in normal/compact controls, buffer/playhead visibility and subtitle alignment in the visible app using Tubular's real launch path. Offscreen native rasterization at compact/normal widths confirmed all four interval-boundary gaps remain clear even with full buffer coverage.
|
||||
- Tubular collection launches, retained-report recovery and watched decisions remain its acceptance responsibility; this implementation changes no collection or progress schema.
|
||||
- Media redirects/cross-origin HLS resources remain unsupported. Native URL launches resolve fresh; externally supplied source documents must be refreshed by their producer. Automatic expired-source refresh is not implemented.
|
||||
- Other providers' proxies/HLS rewrites, iframe providers, downloads, thumbnails, automatic skipping, embedded captions and additional external-subtitle formats remain out of scope.
|
||||
|
||||
The implementation and this handoff are local; no remote publication or Tubular issue/comment update was performed.
|
||||
|
||||
Reference in New Issue
Block a user