397 lines
20 KiB
Swift
397 lines
20 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 MiruroProxy: Decodable, Sendable {
|
|
let rotate: Bool
|
|
let segments: Bool
|
|
|
|
enum CodingKeys: String, CodingKey { case rotate, segments }
|
|
init(rotate: Bool, segments: Bool) {
|
|
self.rotate = rotate
|
|
self.segments = segments
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
rotate = try values.decodeIfPresent(Bool.self, forKey: .rotate) ?? false
|
|
segments = try values.decodeIfPresent(Bool.self, forKey: .segments) ?? true
|
|
}
|
|
}
|
|
|
|
struct MiruroPlaybackRequirements: Sendable {
|
|
var proxy: MiruroProxy? = nil
|
|
var segmentOrigin: String? = nil
|
|
var playlistQuery: [String: String] = [:]
|
|
var embed = false
|
|
}
|
|
|
|
struct MiruroSelection: Sendable {
|
|
let source: ResolvedSource
|
|
let videoProvider: String
|
|
let captionProvider: String?
|
|
}
|
|
|
|
struct MiruroSources: Sendable {
|
|
fileprivate struct Candidate: Sendable {
|
|
let provider: String
|
|
let track: String
|
|
let source: ResolvedSource
|
|
var id: String { "\(provider):\(track)" }
|
|
}
|
|
fileprivate struct Captions: Sendable {
|
|
let provider: String
|
|
let track: String
|
|
let subtitles: [ResolvedSubtitle]
|
|
var id: String { "\(provider):\(track)" }
|
|
}
|
|
fileprivate let videos: [Candidate]
|
|
fileprivate let captions: [Captions]
|
|
|
|
let videoProviders: [String]
|
|
let captionProviders: [String]
|
|
|
|
fileprivate init(videos: [Candidate], captions: [Captions]) {
|
|
self.videos = videos
|
|
self.captions = captions
|
|
videoProviders = videos.map(\.id)
|
|
captionProviders = captions.map(\.id)
|
|
}
|
|
|
|
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
|
|
func requested(_ preference: String?, provider: String, track: String) -> Bool {
|
|
preference == "\(provider):\(track)" ||
|
|
(preference == provider && track != "dub")
|
|
}
|
|
let japanese = videos.lazy.filter { $0.track != "dub" }
|
|
guard let video = videos.first(where: { requested(videoProvider, provider: $0.provider, track: $0.track) }) ??
|
|
japanese.first(where: { $0.provider == "sun" }) ?? japanese.first else {
|
|
throw StreamResolutionError(message: "Miruro Japanese source is unavailable. Select an available provider and track explicitly.")
|
|
}
|
|
let compatibleCaptions = captions.lazy.filter { $0.track == video.track || (video.track != "dub" && $0.track != "dub") }
|
|
let caption = captions.first(where: { requested(captionProvider, provider: $0.provider, track: $0.track) }) ??
|
|
captions.first(where: { $0.id == video.id }) ??
|
|
compatibleCaptions.first(where: { $0.provider == video.provider }) ??
|
|
compatibleCaptions.first(where: { $0.provider == "sun" }) ?? compatibleCaptions.first
|
|
let source = video.source.replacingSubtitles(caption?.subtitles ?? [])
|
|
return MiruroSelection(source: source, videoProvider: video.id, captionProvider: caption?.id)
|
|
}
|
|
}
|
|
|
|
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.Captions] = []
|
|
var videoIDs = Set<String>()
|
|
var captionIDs = Set<String>()
|
|
for track in response.tracks.lazy.compactMap(\.value) where ["ssub", "sub", "dub"].contains(track.track) {
|
|
for provider in track.providers.lazy.compactMap(\.value) {
|
|
let name = provider.provider
|
|
guard validProviderName(name) else { continue }
|
|
let id = "\(name):\(track.track)"
|
|
let config = response.config[name]?.value
|
|
let servers = provider.servers.compactMap(\.value)
|
|
// Prefer supplied server headers, but do not require a playable video to use them.
|
|
let captionServer = servers.first { !($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:]) } ??
|
|
servers.first { ResolvedSource.validHeaders($0.headers ?? [:]) }
|
|
if !captionIDs.contains(id), captionServer != nil || servers.isEmpty {
|
|
var headers = captionServer?.headers ?? [:]
|
|
var proxy = config?.proxy
|
|
let returned = provider.subtitles.compactMap(\.value)
|
|
// Iframe captions may be exact copies of a parent's resources.
|
|
// Reuse only that verified caption transport, never its video rules.
|
|
if headers.isEmpty, config?.player == "iframe", let parentName = config?.parent,
|
|
let parent = track.providers.compactMap(\.value).first(where: { $0.provider == parentName }),
|
|
parent.subtitles.compactMap(\.value) == returned,
|
|
let parentServer = parent.servers.compactMap(\.value).first(where: {
|
|
!($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:])
|
|
}) {
|
|
headers = parentServer.headers ?? [:]
|
|
proxy = response.config[parentName]?.value?.proxy
|
|
}
|
|
let subtitles = returned.lazy
|
|
.filter { $0.format == "vtt" }.map {
|
|
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
|
|
label: $0.label, isDefault: $0.default, headers: headers, proxy: proxy)
|
|
}
|
|
let values = Array(subtitles)
|
|
if !values.isEmpty, ResolvedSource.validSubtitles(values) {
|
|
captions.append(.init(provider: name, track: track.track, subtitles: values))
|
|
captionIDs.insert(id)
|
|
}
|
|
}
|
|
for server in servers {
|
|
let headers = server.headers ?? [:]
|
|
guard ResolvedSource.validHeaders(headers) else { continue }
|
|
guard !videoIDs.contains(id), let config,
|
|
var requirements = try? config.requirements() else { continue }
|
|
var locations: [(String, Bool)] = []
|
|
if config.player == "native", server.embed == nil {
|
|
locations = server.streams.compactMap(\.value).compactMap { stream in
|
|
guard stream.embed == nil,
|
|
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format) else { return nil }
|
|
return (stream.url, false)
|
|
}
|
|
} else if config.player == "iframe" {
|
|
if let embed = server.embed { locations.append((embed.url, true)) }
|
|
locations.append(contentsOf: server.streams.lazy.compactMap(\.value).compactMap { stream in
|
|
stream.embed.map { ($0.url, true) }
|
|
})
|
|
}
|
|
for (url, embed) in locations {
|
|
guard var source = try? ResolvedSource.validated(.init(version: 1, url: url,
|
|
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
|
title: nil, subtitles: [])) else { continue }
|
|
requirements.embed = embed
|
|
source.requirements = requirements
|
|
videos.append(.init(provider: name, track: track.track, source: source))
|
|
videoIDs.insert(id)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Provider order is advisory and deduplicated; each provider retains all usable tracks.
|
|
var seen = Set<String>()
|
|
let providerOrder = ((response.providerOrder ?? []) + videos.map(\.provider) + captions.map(\.provider))
|
|
.filter { seen.insert($0).inserted }
|
|
func variants(_ name: String) -> [String] {
|
|
var seen = Set<String>()
|
|
return ((response.config[name]?.value?.variantOrder ?? []) + ["ssub", "sub", "dub"])
|
|
.filter { seen.insert($0).inserted }
|
|
}
|
|
let orderedVideos = providerOrder.flatMap { name in
|
|
variants(name).compactMap { variant in videos.first { $0.provider == name && $0.track == variant } }
|
|
}
|
|
let orderedCaptions = providerOrder.flatMap { name in
|
|
variants(name).compactMap { variant in captions.first { $0.provider == name && $0.track == variant } }
|
|
}
|
|
guard !orderedVideos.isEmpty || !orderedCaptions.isEmpty else { throw InvalidProjection() }
|
|
return MiruroSources(videos: orderedVideos, captions: orderedCaptions)
|
|
} 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]?
|
|
enum CodingKeys: String, CodingKey { case episodeNumber, tracks, config, providerOrder }
|
|
init(from decoder: Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
episodeNumber = try values.decode(Int.self, forKey: .episodeNumber)
|
|
tracks = try values.decode([Entry<Track>].self, forKey: .tracks)
|
|
config = try values.decodeIfPresent([String: Entry<ProviderConfig>].self, forKey: .config) ?? [:]
|
|
providerOrder = try values.decodeIfPresent([String].self, forKey: .providerOrder)
|
|
}
|
|
}
|
|
private struct ProviderConfig: Decodable {
|
|
let player: String
|
|
let proxy: MiruroProxy?
|
|
let hls: HLS?
|
|
let variantOrder: [String]?
|
|
let parent: String?
|
|
enum CodingKeys: String, CodingKey { case player, proxy, hls, variantOrder, parent }
|
|
init(from decoder: Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
player = try values.decode(String.self, forKey: .player)
|
|
if let flag = try? values.decode(Bool.self, forKey: .proxy) {
|
|
guard !flag else { throw InvalidProjection() }
|
|
proxy = nil
|
|
} else {
|
|
proxy = try values.decode(MiruroProxy.self, forKey: .proxy)
|
|
}
|
|
hls = try values.decodeIfPresent(HLS.self, forKey: .hls)
|
|
variantOrder = try values.decodeIfPresent([String].self, forKey: .variantOrder)
|
|
parent = try values.decodeIfPresent(String.self, forKey: .parent)
|
|
}
|
|
|
|
func requirements() throws -> MiruroPlaybackRequirements {
|
|
guard player == "native" || player == "iframe" else { throw InvalidProjection() }
|
|
var result = MiruroPlaybackRequirements(proxy: proxy)
|
|
if let segments = hls?.segments, segments.enabled == true {
|
|
guard let origin = segments.origin, ResolvedSource.validURL(origin) else { throw InvalidProjection() }
|
|
result.segmentOrigin = origin
|
|
}
|
|
if let query = hls?.query, query.enabled == true {
|
|
guard let params = query.params, params.count <= 64,
|
|
params.allSatisfy({ !$0.key.isEmpty && ResolvedSource.validText($0.key, limit: 256) &&
|
|
ResolvedSource.validText($0.value, limit: 16384) }) else { throw InvalidProjection() }
|
|
result.playlistQuery = params
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
private struct HLS: Decodable {
|
|
struct Segments: Decodable { let enabled: Bool?; let origin: String? }
|
|
struct Query: Decodable { let enabled: Bool?; let params: [String: String]? }
|
|
let segments: Segments?
|
|
let query: Query?
|
|
}
|
|
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>]
|
|
enum CodingKeys: String, CodingKey { case provider, servers, subtitles }
|
|
init(from decoder: Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
provider = try values.decode(String.self, forKey: .provider)
|
|
servers = try values.decodeIfPresent([Entry<Server>].self, forKey: .servers) ?? []
|
|
subtitles = try values.decodeIfPresent([Entry<Subtitle>].self, forKey: .subtitles) ?? []
|
|
}
|
|
}
|
|
private struct Embed: Decodable { let url: String }
|
|
private struct Server: Decodable {
|
|
let headers: [String: String]?
|
|
let streams: [Entry<Stream>]
|
|
let embed: Embed?
|
|
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.decodeIfPresent([Entry<Stream>].self, forKey: .streams) ?? []
|
|
embed = try values.decodeIfPresent(Embed.self, forKey: .embed)
|
|
}
|
|
}
|
|
private struct Stream: Decodable {
|
|
let url: String
|
|
let format: String
|
|
let embed: Embed?
|
|
}
|
|
private struct Subtitle: Decodable, Equatable {
|
|
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)
|
|
}
|
|
}
|