Files
jellyfin-plugin-floatplane/doc/research/reference-sources/fn_ios_VideoResourceLoader.swift
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

189 lines
7.4 KiB
Swift

//
// VideoResourceLoader.swift
// FloatNative
//
// Intercepts AVPlayer's HLS subresource requests over a custom scheme so
// we can attach DPoP / rewrite EXT-X-KEY URIs. The synthetic-HLS-master
// caption path that lived here previously was abandoned (AVPlayer's HLS
// parser kept rejecting it); captions are now rendered as a SwiftUI
// overlay driven by the parsed cues — see CaptionsOverlay + AVPlayerManager.
//
import AVFoundation
import Foundation
import os
class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
private let session: URLSession
private let customScheme = "floatnative"
private let log = Logger(subsystem: "ca.maplespace.FloatNative", category: "VideoResourceLoader")
override init() {
let config = URLSessionConfiguration.default
self.session = URLSession(configuration: config)
super.init()
}
// MARK: - Delegate Methods
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
guard let url = loadingRequest.request.url else { return false }
if url.scheme == customScheme {
handleCustomSchemeRequest(loadingRequest)
return true
}
return false
}
// MARK: - Handlers
private func handleCustomSchemeRequest(_ loadingRequest: AVAssetResourceLoadingRequest) {
guard let url = loadingRequest.request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
loadingRequest.finishLoading(with: URLError(.badURL))
return
}
// Switch scheme back to https
components.scheme = "https"
guard let realURL = components.url else {
loadingRequest.finishLoading(with: URLError(.badURL))
return
}
Task {
do {
if realURL.pathExtension.caseInsensitiveCompare("m3u8") == .orderedSame {
try await handleManifestRequest(loadingRequest, realURL: realURL)
}
else if realURL.absoluteString.range(of: "key", options: .caseInsensitive) != nil
|| realURL.pathExtension == "key" {
try await handleKeyRequest(loadingRequest, realURL: realURL)
}
else {
try await handleGenericRequest(loadingRequest, realURL: realURL)
}
} catch {
loadingRequest.finishLoading(with: error)
}
}
}
// MARK: - Manifest Handling
private func handleManifestRequest(_ loadingRequest: AVAssetResourceLoadingRequest, realURL: URL) async throws {
let data = try await fetchWithDPoP(url: realURL)
guard let manifestString = String(data: data, encoding: .utf8) else {
throw URLError(.cannotDecodeContentData)
}
// Rewrite the manifest so AES-128 keys come back through us (we add
// DPoP), but segments are fetched directly via https — there's no
// need to proxy thousands of chunks.
let baseURL = realURL.deletingLastPathComponent()
var newLines: [String] = []
manifestString.enumerateLines { line, _ in
var processedLine = line
if line.contains("#EXT-X-KEY") {
if let range = line.range(of: "URI=\"") {
let rest = line[range.upperBound...]
if let endQuote = rest.firstIndex(of: "\"") {
let keyUriString = String(rest[..<endQuote])
if let keyURL = URL(string: keyUriString, relativeTo: baseURL) {
var keyComponents = URLComponents(url: keyURL, resolvingAgainstBaseURL: true)
keyComponents?.scheme = self.customScheme
if let newKeyUri = keyComponents?.string {
processedLine = line.replacingOccurrences(of: keyUriString, with: newKeyUri)
}
}
}
}
}
else if !line.hasPrefix("#") && !line.isEmpty {
// Rewrite relative segment URIs to absolute https so AVPlayer
// fetches them directly (bypassing this loader).
if let segmentURL = URL(string: line, relativeTo: baseURL) {
if segmentURL.scheme == "http" || segmentURL.scheme == "https" {
processedLine = segmentURL.absoluteString
}
}
}
newLines.append(processedLine)
}
let modifiedManifest = newLines.joined(separator: "\n")
guard let modifiedData = modifiedManifest.data(using: .utf8) else {
throw URLError(.cannotDecodeContentData)
}
respond(loadingRequest, data: modifiedData, contentType: "public.m3u-playlist")
}
private func handleKeyRequest(_ loadingRequest: AVAssetResourceLoadingRequest, realURL: URL) async throws {
let data = try await fetchWithDPoP(url: realURL)
respond(loadingRequest, data: data, contentType: "application/octet-stream")
}
private func handleGenericRequest(_ loadingRequest: AVAssetResourceLoadingRequest, realURL: URL) async throws {
let data = try await fetchWithDPoP(url: realURL)
respond(loadingRequest, data: data, contentType: "application/octet-stream")
}
/// Fill both contentInformationRequest and dataRequest from an in-memory
/// buffer. AVAssetResourceLoader requires contentType on the first
/// content-information request or it rejects the asset.
private func respond(_ loadingRequest: AVAssetResourceLoadingRequest, data: Data, contentType: String) {
if let info = loadingRequest.contentInformationRequest {
info.contentType = contentType
info.contentLength = Int64(data.count)
info.isByteRangeAccessSupported = true
}
if let dataRequest = loadingRequest.dataRequest {
let offset = Int(dataRequest.requestedOffset)
let length = dataRequest.requestedLength
let end = min(offset &+ length, data.count)
if offset < data.count {
dataRequest.respond(with: data.subdata(in: offset..<max(end, offset)))
}
}
loadingRequest.finishLoading()
}
// MARK: - Network
private func fetchWithDPoP(url: URL) async throws -> Data {
let accessToken = await FloatplaneAPI.shared.accessToken
var request = URLRequest(url: url)
request.httpMethod = "GET"
if let token = accessToken {
if let dpopProof = try? DPoPManager.shared.generateProof(
httpMethod: "GET",
httpUrl: url.absoluteString,
accessToken: token
) {
request.setValue(dpopProof, forHTTPHeaderField: "DPoP")
request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization")
} else {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
}
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
throw URLError(.badServerResponse)
}
return data
}
}