feat: support Miruro provider tracks, proxies, and native embeds

This commit is contained in:
2026-10-22 19:04:30 +02:00
parent 80d9e705e5
commit 8df500da83
15 changed files with 1201 additions and 203 deletions
+189 -92
View File
@@ -41,6 +41,29 @@ enum StreamURL: Sendable {
}
}
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
@@ -50,32 +73,46 @@ struct MiruroSelection: Sendable {
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: [Candidate]
fileprivate let captions: [Captions]
let videoProviders: [String]
let captionProviders: [String]
fileprivate init(videos: [Candidate], captions: [Candidate]) {
fileprivate init(videos: [Candidate], captions: [Captions]) {
self.videos = videos
self.captions = captions
videoProviders = videos.map(\.provider)
captionProviders = captions.map(\.provider)
videoProviders = videos.map(\.id)
captionProviders = captions.map(\.id)
}
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.")
func requested(_ preference: String?, provider: String, track: String) -> Bool {
preference == "\(provider):\(track)" ||
(preference == provider && track != "dub")
}
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)
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)
}
}
@@ -145,66 +182,93 @@ enum MiruroResolver {
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
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)
}
if found { break }
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
}
}
}
}
// 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
// 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 sources = MiruroSources(videos: ordered(videos), captions: ordered(captions))
_ = try sources.select()
return sources
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.")
@@ -228,17 +292,57 @@ enum MiruroResolver {
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
// Objects describe proxy work this native player cannot perform.
let proxy: Bool
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 Rule: Decodable { let enabled: Bool? }
let segments: Rule?
let query: Rule?
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
@@ -248,40 +352,33 @@ enum MiruroResolver {
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 hasEmbed: Bool
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.decode([Entry<Stream>].self, forKey: .streams)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
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 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
}
}
let embed: Embed?
}
private struct Subtitle: Decodable {
private struct Subtitle: Decodable, Equatable {
let file: String
let format: String
let language: String