From a1c8314391b7b7a5c235a3bf3c8ee7d1da6f5368 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 11:39:56 +0200 Subject: [PATCH] feat: enrich Miruro URL playback metadata and timeline --- Checks/miruro-metadata.swift | 244 ++++++++++++++++++++++ Checks/resolved-playback.swift | 18 ++ Makefile | 9 + README.md | 3 +- Sources/App/MiruroMetadata.swift | 341 +++++++++++++++++++++++++++++++ Sources/App/TerebiApp.swift | 151 +++++++++++++- docs/miruro-playback-handoff.md | 69 ++++--- 7 files changed, 798 insertions(+), 37 deletions(-) create mode 100644 Checks/miruro-metadata.swift create mode 100644 Sources/App/MiruroMetadata.swift diff --git a/Checks/miruro-metadata.swift b/Checks/miruro-metadata.swift new file mode 100644 index 0000000..018ae87 --- /dev/null +++ b/Checks/miruro-metadata.swift @@ -0,0 +1,244 @@ +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, -1, 10), interval(.intro, 10, 10), interval(.intro, 20, 10), + interval(.intro, .nan, 10), interval(.intro, 0, .infinity), interval(.intro, 0, 101), + 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).isEmpty, "duration shrink revalidates without trimming") + try require(metadata.visibleIntervals(episode: 1, duration: 120) == [valid], "duration growth revalidates without scaling") + 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.. [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) + } + } +} diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift index 48e9308..25fd5aa 100644 --- a/Checks/resolved-playback.swift +++ b/Checks/resolved-playback.swift @@ -22,6 +22,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] @@ -47,6 +52,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") diff --git a/Makefile b/Makefile index 029c9f5..62d2453 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ 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 CONFIG_BUILD := .build/configuration CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path) @@ -218,6 +219,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 $@ diff --git a/README.md b/README.md index c5252c2..0b7a908 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self - **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//?ep=` with an explicit positive episode number; arbitrary streaming sites are not supported. +- **Miruro metadata:** titles and labeled Opening/Ending bands load independently of playback when available. Bands use measured media duration; missing/invalid metadata leaves playback usable. Timings may differ between cuts. No automatic skipping is performed. - **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. @@ -62,7 +63,7 @@ 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, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md). ## Configuration diff --git a/Sources/App/MiruroMetadata.swift b/Sources/App/MiruroMetadata.swift new file mode 100644 index 0000000..f6b82f4 --- /dev/null +++ b/Sources/App/MiruroMetadata.swift @@ -0,0 +1,341 @@ +import Foundation +import CoreFoundation + +struct MiruroInterval: Sendable, Equatable { + enum Kind: Sendable { case intro, outro } + let kind: Kind + let start: Double + let 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 row in intervals { + guard row.episode == episode, row.start.isFinite, row.end.isFinite, + row.start >= 0, row.end > row.start, row.end <= duration, + !(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 } + // 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 + 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) + } +} diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 3893969..3656262 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -57,6 +57,13 @@ 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? + 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) + } var pendingRevision: UInt64? { transition.pendingRevision } var resolvingStream: Bool { transition.isResolving } var isLiveStream: Bool { transition.isLive } @@ -478,6 +485,31 @@ final class PlayerModel: ObservableObject { revealControls() } + private func replaceMetadata(for source: MediaSource?) { + metadataTask?.cancel() + metadataTask = 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() + if let title = metadata.title { + self.fileName = title + self.window?.title = title + self.casting.updateTitle(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) } @@ -578,6 +610,7 @@ final class PlayerModel: ObservableObject { window?.representedURL = source.url window?.title = fileName ?? "Terebi" revealControls() + replaceMetadata(for: source) systemPlayback?.update() } @@ -718,6 +751,7 @@ final class PlayerModel: ObservableObject { queueCompletionHandled = true queueCastDevice = nil currentMedia = nil + replaceMetadata(for: nil) shouldRecordRecentMedia = false selectSubtitle(nil) subtitleTracks = [] @@ -1223,6 +1257,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 +1316,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 +1359,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 +1494,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 +1575,67 @@ 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 + 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).count == 1, "Duration shrink revalidates raw bands") + precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "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 +1930,7 @@ private struct PlaybackSlider: NSViewRepresentable { var volume = false var enabled = true var bufferedRange: ClosedRange? + var intervals: [MiruroInterval] = [] var onEditingChanged: (Bool) -> Void = { _ in } func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -1851,8 +1953,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") @@ -1900,9 +2004,36 @@ private struct PlaybackSlider: NSViewRepresentable { final class Cell: NSSliderCell { var volume = false var bufferedRange: ClosedRange? + var intervals: [MiruroInterval] = [] + private static let labelAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9, weight: .semibold), + .foregroundColor: SunsetPalette.text, + ] 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) + if !volume, maxValue > minValue { + let scale = bar.width / (maxValue - minValue) + for interval in intervals { + let band = NSRect(x: bar.minX + (interval.start - minValue) * scale, + y: bar.minY - 2, width: (interval.end - interval.start) * scale, height: 7) + let color = interval.kind == .intro ? SunsetPalette.accent : SunsetPalette.ember + color.withAlphaComponent(0.3).setFill() + NSBezierPath(rect: band).fill() + SunsetPalette.secondaryText.withAlphaComponent(0.8).setStroke() + let outline = NSBezierPath(rect: band) + outline.lineWidth = 0.5 + outline.stroke() + let full = (interval.kind == .intro ? "Opening" : "Ending") as NSString + let label = full.size(withAttributes: Self.labelAttributes).width <= band.width ? full : + (interval.kind == .intro ? "OP" : "ED") as NSString + let size = label.size(withAttributes: Self.labelAttributes) + let x = min(max(bar.minX, band.midX - size.width / 2), bar.maxX - size.width) + let above = interval.kind == .intro + let y = above != flipped ? bar.maxY + 3 : bar.minY - size.height - 3 + label.draw(at: NSPoint(x: x, y: y), withAttributes: Self.labelAttributes) + } + } SunsetPalette.track.setFill() NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill() if volume { @@ -2475,6 +2606,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 +2659,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) + .frame(height: intervals.isEmpty ? 14 : 32) .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() diff --git a/docs/miruro-playback-handoff.md b/docs/miruro-playback-handoff.md index 0f35cb9..46198de 100644 --- a/docs/miruro-playback-handoff.md +++ b/docs/miruro-playback-handoff.md @@ -4,25 +4,38 @@ 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. + +### 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 positive player-reported duration, and reject nonfinite, negative, reversed, wrong-episode or out-of-bounds timings; an end equal to duration is valid. Duration changes revalidate raw intervals without trimming, shifting or scaling. Optional provenance/votes/reference-length checks follow the documented frontend evidence; missing fields are not invented. Highest-vote eligible opening and ending win, with response order breaking ties. Overlaps are not merged. + +The controls label the bands **Opening** and **Ending**, with accessibility descriptions; neither automatic seeking nor skip buttons are added. 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 +73,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 +148,30 @@ 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. + +## Remaining limits + +- Confirm title readability and labeled bands in normal/compact controls, buffer/playhead visibility and subtitle alignment in the visible app using Tubular's real launch path. +- 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.