feat: select Miruro playback and caption providers
This commit is contained in:
@@ -41,10 +41,48 @@ enum StreamURL: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
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 -> ResolvedSource {
|
||||
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.")
|
||||
@@ -100,51 +138,100 @@ enum MiruroResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||
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() }
|
||||
let config = response.config.sun
|
||||
guard config.player == "native", !config.proxy,
|
||||
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
|
||||
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let provider = response.tracks.first(where: { $0.track == "ssub" })?
|
||||
.providers.first(where: { $0.provider == "sun" }),
|
||||
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
|
||||
let stream = server.streams.first else {
|
||||
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
|
||||
// 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
|
||||
}
|
||||
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
|
||||
!stream.hasEmbed else { throw InvalidProjection() }
|
||||
let headers = server.headers ?? [:]
|
||||
var subtitles: [ResolvedSubtitle] = []
|
||||
for subtitle in provider.subtitles where subtitle.format == "vtt" {
|
||||
guard subtitles.count < 32 else { throw InvalidProjection() }
|
||||
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
|
||||
label: subtitle.label, isDefault: subtitle.default, headers: headers))
|
||||
}
|
||||
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers,
|
||||
identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: subtitles))
|
||||
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: [Track]
|
||||
let config: Config
|
||||
let tracks: [Entry<Track>]
|
||||
let config: [String: Entry<ProviderConfig>]
|
||||
let providerOrder: [String]?
|
||||
}
|
||||
private struct Config: Decodable { let sun: ProviderConfig }
|
||||
private struct ProviderConfig: Decodable {
|
||||
let player: String
|
||||
// Objects describe proxy work this native player cannot perform.
|
||||
let proxy: Bool
|
||||
let hls: HLS?
|
||||
}
|
||||
@@ -155,16 +242,28 @@ enum MiruroResolver {
|
||||
}
|
||||
private struct Track: Decodable {
|
||||
let track: String
|
||||
let providers: [Provider]
|
||||
let providers: [Entry<Provider>]
|
||||
}
|
||||
private struct Provider: Decodable {
|
||||
let provider: String
|
||||
let servers: [Server]
|
||||
let subtitles: [Subtitle]
|
||||
let servers: [Entry<Server>]
|
||||
let subtitles: [Entry<Subtitle>]
|
||||
}
|
||||
private struct Server: Decodable {
|
||||
let headers: [String: String]?
|
||||
let streams: [Stream]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user