feat: enrich Miruro URL playback metadata and timeline

This commit is contained in:
2026-10-22 11:39:56 +02:00
parent b9c7d7f73a
commit a1c8314391
7 changed files with 798 additions and 37 deletions
+341
View File
@@ -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<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)
}
}