Files
terebi/Sources/App/StreamResolver.swift
T

300 lines
15 KiB
Swift

import Foundation
private struct StreamResolutionError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
enum StreamURL: Sendable {
case youtube(URL)
case miruro(mediaID: String, episode: Int)
static func parse(_ text: String) throws -> StreamURL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
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),
components.scheme?.lowercased() == "https",
components.host?.lowercased() == "barelystarted.miruro.tv",
components.user == nil, components.password == nil,
components.port == nil || components.port == 443,
components.fragment == nil,
components.percentEncodedPath == components.path,
components.url != nil else { throw invalid }
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
guard path.count == 4, path[0].isEmpty, path[1] == "watch",
!path[3].isEmpty, path[3].utf8.count <= 512,
path[3].utf8.allSatisfy({ isSegmentByte($0) }),
let query = components.queryItems, query.count == 1, query[0].name == "ep",
let number = query[0].value, let episode = Int(number),
components.percentEncodedQuery == "ep=\(episode)",
ResolvedSource.validIdentity(mediaID: String(path[2]), episode: episode) else { throw invalid }
return .miruro(mediaID: String(path[2]), episode: episode)
}
private static func isSegmentByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95
}
}
struct MiruroSelection: Sendable {
let source: ResolvedSource
let videoProvider: String
let captionProvider: String?
}
struct MiruroSources: Sendable {
fileprivate struct Candidate: Sendable {
let provider: String
let source: ResolvedSource
}
fileprivate let videos: [Candidate]
fileprivate let captions: [Candidate]
let videoProviders: [String]
let captionProviders: [String]
fileprivate init(videos: [Candidate], captions: [Candidate]) {
self.videos = videos
self.captions = captions
videoProviders = videos.map(\.provider)
captionProviders = captions.map(\.provider)
}
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
guard let video = videos.first(where: { $0.provider == videoProvider }) ??
videos.first(where: { $0.provider == "sun" }) ?? videos.first else {
throw StreamResolutionError(message: "Direct Miruro Japanese source is unavailable.")
}
let caption = captions.first(where: { $0.provider == captionProvider }) ??
captions.first(where: { $0.provider == video.provider }) ??
captions.first(where: { $0.provider == "sun" }) ?? captions.first
let source = ResolvedSource(url: video.source.url, headers: video.source.headers,
identity: video.source.identity, title: video.source.title, subtitles: caption?.source.subtitles ?? [])
return MiruroSelection(source: source, videoProvider: video.provider, captionProvider: caption?.provider)
}
}
enum MiruroResolver {
static let bodyLimit = 2 * 1024 * 1024
static func resolve(mediaID: String, episode: Int) async throws -> MiruroSources {
try Task.checkCancellation()
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
}
var components = URLComponents(string: "https://barelystarted.miruro.tv/api/sources")!
components.queryItems = [URLQueryItem(name: "id", value: mediaID), URLQueryItem(name: "n", value: String(episode))]
var request = URLRequest(url: components.url!)
request.timeoutInterval = 25
request.setValue("application/json", forHTTPHeaderField: "Accept")
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.timeoutIntervalForResource = 25
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: MiruroRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
do {
let (bytes, response) = try await session.bytes(for: request)
try validateResponse(response)
var data = Data()
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < bodyLimit else {
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
}
data.append(byte)
}
try Task.checkCancellation()
return try decode(data, mediaID: mediaID, episode: episode)
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro request failed or timed out. No retry was attempted.")
}
}
static func validateResponse(_ response: URLResponse) throws {
guard let response = response as? HTTPURLResponse else {
throw StreamResolutionError(message: "Miruro returned an invalid HTTP response.")
}
guard response.statusCode == 200 else {
throw StreamResolutionError(message: "Miruro HTTP \(response.statusCode). No retry was attempted.")
}
guard response.mimeType?.lowercased() == "application/json" else {
throw StreamResolutionError(message: "Miruro returned an unexpected content type; protection pages are not supported.")
}
guard response.expectedContentLength <= Int64(bodyLimit) else {
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
}
}
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroSources {
do {
guard data.count <= bodyLimit,
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
let response = try JSONDecoder().decode(Sources.self, from: data)
guard response.episodeNumber == episode else { throw InvalidProjection() }
var videos: [MiruroSources.Candidate] = []
var captions: [MiruroSources.Candidate] = []
var videoNames = Set<String>()
var captionNames = Set<String>()
// Japanese variants only, preferring soft subtitles independently for each provider.
for variant in ["ssub", "sub"] {
for track in response.tracks.lazy.compactMap(\.value) where track.track == variant {
for provider in track.providers.lazy.compactMap(\.value) {
let name = provider.provider
guard validProviderName(name), let config = response.config[name]?.value,
config.player == "native", !config.proxy,
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { continue }
let needsVideo = !videoNames.contains(name)
let needsCaptions = !captionNames.contains(name)
guard needsVideo || needsCaptions else { continue }
for server in provider.servers.lazy.compactMap(\.value) where !server.hasEmbed {
let headers = server.headers ?? [:]
var found = false
for stream in server.streams.lazy.compactMap(\.value) {
guard !stream.hasEmbed,
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
let video = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: [])) else { continue }
if videoNames.insert(name).inserted {
videos.append(.init(provider: name, source: video))
}
// Caption requests retain this provider/server's headers, never the selected video's.
let subtitles = provider.subtitles.lazy.compactMap(\.value).filter { $0.format == "vtt" }.map {
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
label: $0.label, isDefault: $0.default, headers: headers)
}
if needsCaptions, !subtitles.isEmpty,
let caption = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: Array(subtitles))) {
captions.append(.init(provider: name, source: caption))
captionNames.insert(name)
}
found = true
break
}
if found { break }
}
}
}
}
// API order is advisory; retain response order for names omitted from it.
let order = response.providerOrder ?? []
func ordered(_ candidates: [MiruroSources.Candidate]) -> [MiruroSources.Candidate] {
var remaining = Dictionary(uniqueKeysWithValues: candidates.map { ($0.provider, $0) })
var result: [MiruroSources.Candidate] = []
for name in order {
if let candidate = remaining.removeValue(forKey: name) { result.append(candidate) }
}
result.append(contentsOf: candidates.filter { remaining[$0.provider] != nil })
return result
}
let sources = MiruroSources(videos: ordered(videos), captions: ordered(captions))
_ = try sources.select()
return sources
} catch {
if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
}
}
private static func validProviderName(_ name: String) -> Bool {
!name.isEmpty && name.utf8.count <= 128 && name.utf8.allSatisfy {
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
}
}
private struct InvalidProjection: Error {}
// A malformed entry must not discard valid neighboring providers, servers or streams.
private struct Entry<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
}
private struct Sources: Decodable {
let episodeNumber: Int
let tracks: [Entry<Track>]
let config: [String: Entry<ProviderConfig>]
let providerOrder: [String]?
}
private struct ProviderConfig: Decodable {
let player: String
// Objects describe proxy work this native player cannot perform.
let proxy: Bool
let hls: HLS?
}
private struct HLS: Decodable {
struct Rule: Decodable { let enabled: Bool? }
let segments: Rule?
let query: Rule?
}
private struct Track: Decodable {
let track: String
let providers: [Entry<Provider>]
}
private struct Provider: Decodable {
let provider: String
let servers: [Entry<Server>]
let subtitles: [Entry<Subtitle>]
}
private struct Server: Decodable {
let headers: [String: String]?
let streams: [Entry<Stream>]
let hasEmbed: Bool
enum CodingKeys: String, CodingKey { case headers, streams, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
headers = try values.decodeIfPresent([String: String].self, forKey: .headers)
streams = try values.decode([Entry<Stream>].self, forKey: .streams)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
}
}
private struct Stream: Decodable {
let url: String
let format: String
let hasEmbed: Bool
enum CodingKeys: String, CodingKey { case url, format, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
url = try values.decode(String.self, forKey: .url)
format = try values.decode(String.self, forKey: .format)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
}
}
private struct Subtitle: Decodable {
let file: String
let format: String
let language: String
let label: String
let `default`: Bool
}
}
private final class MiruroRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}