Files
jellyfin-plugin-floatplane/doc/research/reference-sources/wf_VideoViewModel.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

355 lines
13 KiB
Swift

import Foundation
import CoreData
import Logging
import FloatplaneAPIClient
import NIO
import AVKit
import SwiftUI
class VideoViewModel: BaseViewModel, ObservableObject {
enum VideoError: Error, CustomStringConvertible {
case badResponse
case noQualityLevelsFound
var description: String {
switch self {
case .badResponse:
return "There was an error retrieving the livestream information. Please try again."
case .noQualityLevelsFound:
return "Unable to find any playable content from this video for your device."
}
}
}
@Published var state: ViewModelState<(CdnDeliveryV3Response, ContentVideoV3Response)> = .idle
private let fpApiService: FPAPIService
let videoAttachment: VideoAttachmentModel
let contentPost: ContentPostV3Response
let description: AttributedString
let supportedSpeeds = [AVPlaybackSpeed(rate: 0.5, localizedName: "x0.5"),
AVPlaybackSpeed(rate: 0.75, localizedName: "x0.75"),
AVPlaybackSpeed(rate: 1.0, localizedName: "x1.0"),
AVPlaybackSpeed(rate: 1.25, localizedName: "x1.25"),
AVPlaybackSpeed(rate: 1.5, localizedName: "x1.5"),
AVPlaybackSpeed(rate: 1.75, localizedName: "x1.75"),
AVPlaybackSpeed(rate: 2.0, localizedName: "x2.0")]
private var resourceLoaderDelegate: HLSResourceLoaderDelegate?
var avMetadataItems: [AVMetadataItem] {
let desc = String(description.characters)
var releaseDate = videoAttachment.releaseDate?.formatted(.dateTime) ?? ""
if releaseDate == "" {
releaseDate = contentPost.releaseDate.formatted(.dateTime)
}
return [
metadataItem(identifier: .commonIdentifierTitle, value: videoAttachment.title),
metadataItem(identifier: .commonIdentifierDescription, value: desc),
metadataItem(identifier: .iTunesMetadataTrackSubTitle, value: releaseDate),
// metadataItem(identifier: .commonIdentifierArtwork, value: videoAttachment.thumbnail.path),
]
}
private(set) var qualityLevels: [String: (URL, CdnDeliveryV3Variant)] = [:]
init(fpApiService: FPAPIService, videoAttachment: VideoAttachmentModel, contentPost: ContentPostV3Response, description: AttributedString, bearerToken: String) {
self.fpApiService = fpApiService
self.videoAttachment = videoAttachment
self.contentPost = contentPost
self.description = description
super.init()
self.resourceLoaderDelegate = HLSResourceLoaderDelegate(logger: self.logger, bearerToken: bearerToken)
}
func load() {
Task { @MainActor in
self.state = .loading
let type: CDNV2API.ModelType_getDeliveryInfo = .vod
self.logger.info("Loading video information.", metadata: [
"type": "\(type)",
"id": "\(videoAttachment.guid)",
])
let deliveryInfo: CdnDeliveryV3Response
let videoContent: ContentVideoV3Response
do {
deliveryInfo = try await self.fpApiService.getDeliveryInfo(scenario: .ondemand, entityId: videoAttachment.guid, outputKind: nil)
videoContent = try await self.fpApiService.getVideoContent(id: videoAttachment.id)
} catch {
self.state = .failed(error)
return
}
self.logger.notice("Received video information.", metadata: [
"origins": "\(deliveryInfo.groups.flatMap({ $0.origins ?? [] }).map(\.url).joined(separator: ", "))",
])
let screenNativeBounds = UIScreen.main.nativeBounds
// Only use the first group, for now.
let group = deliveryInfo.groups.first
let variants = group?.variants
let filteredVariants = variants?.filter({ variant in
let enabled = variant.enabled ?? false
let hidden = variant.hidden ?? false
// Filter out resolutions larger than the device's screen resolution to save
// on bandwidth and downscaling performance.
// Use height for these comparisons. If using width, we run into funny issues with
// LTT videos which use 2:1 aspect ratios (1080p is 2160x1080 instead of 1920x1080,
// and 4K is 4320x2160 instead of 3480x2160) which makes screen size comparisons
// difficult to do correctly.
// Just in case some creators have funny heights, allow for a 15% tolerance.
var videoSizeOkay: Bool = false
if let video = variant.meta?.video {
videoSizeOkay = CGFloat(video.height ?? 0) <= (screenNativeBounds.height * 1.15)
if !videoSizeOkay {
self.logger.warning("Ignoring quality level \(String(describing: variant.name)) (\(video.width ?? 0) x \(video.height ?? 0)) due to larger-than-screen height of \(screenNativeBounds.height).")
}
}
return enabled && !hidden && videoSizeOkay
})
let qualityLevelsAndUrls = filteredVariants?.compactMap({ variant -> (String, (URL, CdnDeliveryV3Variant))? in
// Map the quality level to the correct URL
if let url = DeliveryHelper.getBestUrl(variant: variant, group: group) {
return (variant.name, (url, variant))
}
return nil
}) ?? []
self.qualityLevels = Dictionary(uniqueKeysWithValues: qualityLevelsAndUrls)
if self.qualityLevels.isEmpty {
self.logger.warning("No quality levels were able to be parsed from the video response. Showing an error to the user.", metadata: [
"id": "\(self.videoAttachment.guid)",
"qualityLevelNames": "\(group?.variants.map(\.name).joined(separator: ", ") ?? "<nil>")",
])
self.state = .failed(VideoError.noQualityLevelsFound)
}
self.state = .loaded((deliveryInfo, videoContent))
}
}
private func metadataItem(identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.value = value as? NSCopying & NSObjectProtocol
item.identifier = identifier
item.extendedLanguageTag = "und" // undefined (wildcard) language
return item
}
func createAVPlayerItem(desiredQuality: String) -> AVPlayerItem {
self.logger.notice("Creating new AVPlayerItem for video playback.", metadata: [
"id": "\(videoAttachment.guid)",
"desiredQuality": "\(desiredQuality)",
])
var url = qualityLevels[desiredQuality]?.0
if url == nil {
let sortedUrls = qualityLevels
.sorted(by: { $0.value.1.meta?.video?.height ?? 0 < $1.value.1.meta?.video?.height ?? 0 })
// Just get the highest available. It's difficult to be smart about next highest from the selection, because the label isn't
// sortable, so let's just guess that if a desired quality isn't available, it's because it was too high and this video
// doesn't have it, but the user wants higher quality. It's also an Apple TV instead of a mobiel device, so higher quality
// is expected. The expected use case is 4K is desired, but only up to 1080p is available, so default to 1080p.
url = sortedUrls
.last?
.value.0
}
guard let url else {
self.logger.critical("No playable URL was found for the video. Invalid state. Closing the application.")
fatalError()
}
self.logger.debug("Setting up AVPlayerItem with matched content url.", metadata: [
"url": "\(url)",
])
// let testURL = URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8")!
let asset = AVURLAsset(url: url)
let templateItem = AVPlayerItem(asset: asset)
templateItem.externalMetadata = avMetadataItems
let queue = DispatchQueue(label: "com.wasserflug.resourceloader")
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: queue)
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: templateItem,
queue: .main
) { notification in
self.logger.debug("Notification: \(notification)")
if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
self.logger.debug("Playback failed: \(error)")
self.logger.debug("Error domain: \(error as NSError).domain")
self.logger.debug("Error code: \(error as NSError).code")
self.logger.debug("User info: \(error as NSError).userInfo")
}
}
// Also observe status changes
templateItem.observe(\.status) { item, _ in
switch item.status {
case .readyToPlay:
self.logger.debug("Player ready")
case .failed:
self.logger.debug("Player failed: \(String(describing: item.error))")
case .unknown:
self.logger.debug("Player status unknown")
@unknown default:
break
}
}
templateItem.observe(\.error) { item, _ in
if let error = item.error {
self.logger.debug("Player item error: \(error)")
}
}
return templateItem
}
func updateProgress(progressSeconds: Int, managedObjectContext: NSManagedObjectContext) {
Task {
guard case let .loaded((_, videoModel)) = self.state else {
return
}
logger.notice("Attempting to record watch progress for a video.", metadata: [
"blogPostId": "\(videoModel.primaryBlogPost)",
"videoId": "\(videoModel.id)",
"progressSeconds": "\(progressSeconds)",
])
// First save to local storage
Self.updateLocalProgress(logger: logger, blogPostId: contentPost.id, videoId: videoAttachment.id, videoDuration: videoAttachment.duration, progressSeconds: progressSeconds, managedObjectContext: managedObjectContext)
// Next save to FP API
do {
try await self.fpApiService.updateProgress(id: videoModel.id, contentType: .video, progress: progressSeconds)
} catch {
logger.warning("Could not update progress. Not showing an error as this is not critical.", metadata: [
"id": "\(videoModel.id)",
"progress": "\(progressSeconds)",
])
}
}
}
static func updateLocalProgress(logger: Logger, blogPostId: String, videoId: String, videoDuration: Double, progressSeconds: Int, managedObjectContext: NSManagedObjectContext) {
let fetchRequest = WatchProgress.fetchRequest()
let progress: Double = Double(progressSeconds) / videoDuration
do {
fetchRequest.predicate = NSPredicate(format: "blogPostId = %@ and videoId = %@", blogPostId, videoId)
if let fetchResult = (try? managedObjectContext.fetch(fetchRequest))?.first {
logger.info("Did find previous watchProgress. Will mutate with new progress.", metadata: [
"previous": "\(String(reflecting: fetchResult))",
])
fetchResult.progress = progress
} else {
logger.info("No previous watchProgress found. Will create new WatchProgress entity.")
let newWatchProgress = WatchProgress(context: managedObjectContext)
newWatchProgress.blogPostId = blogPostId
newWatchProgress.videoId = videoId
newWatchProgress.progress = progress
}
try managedObjectContext.save()
logger.info("Successfully saved watch progress for \(blogPostId) \(videoId)")
} catch {
logger.error("Error saving watch progress for for \(blogPostId) \(videoId): \(String(reflecting: error))")
// Otherwise, this has minimal impact on the user of this application.
// No need to further handle the error.
}
}
}
class HLSResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
private let logger: Logger
private let bearerToken: String
init(logger: Logger, bearerToken: String) {
self.logger = logger
self.bearerToken = bearerToken
super.init()
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
guard let url = loadingRequest.request.url else {
loadingRequest.finishLoading(with: NSError(
domain: "HLSResourceLoader",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]
))
return false
}
logger.debug("Intercepted request for: \(url)")
// Handle the actual request
Task {
await handleLoadingRequest(loadingRequest, url: url)
}
return true
}
private func handleLoadingRequest(
_ loadingRequest: AVAssetResourceLoadingRequest,
url: URL
) async {
do {
// Create request with bearer token
var request = URLRequest(url: url)
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(
domain: "HLSResourceLoader",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "Invalid response type"]
)
}
logger.debug("Key request status: \(httpResponse.statusCode)")
guard httpResponse.statusCode == 200 else {
throw NSError(
domain: "HLSResourceLoader",
code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
)
}
// Provide the response
if let contentType = httpResponse.mimeType {
loadingRequest.contentInformationRequest?.contentType = contentType
}
loadingRequest.contentInformationRequest?.contentLength = Int64(data.count)
loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = false
// Provide the data
loadingRequest.dataRequest?.respond(with: data)
// Finish loading
loadingRequest.finishLoading()
} catch {
logger.debug("Key request failed: \(error)")
loadingRequest.finishLoading(with: error)
}
}
}