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

191 lines
5.9 KiB
Swift

import Foundation
import FloatplaneAPIClient
import NIO
import AVKit
import SwiftUI
import Vapor
import zlib
class LivestreamViewModel: BaseViewModel, ObservableObject {
enum LivestreamError: Error, CustomStringConvertible {
case badResponse
case missingCreator
case badUrl
var description: String {
switch self {
case .badResponse:
return "There was an error retrieving the livestream information. Please try again."
case .missingCreator:
return "There was an error retrieving the creator information. Please try again."
case .badUrl:
return "There was an error preparing the livestream. Please try again."
}
}
}
@Published var state: ViewModelState<(CreatorModelV2, CdnDeliveryV3Response, URL)> = .idle
@Published var isLive: Bool = false
@Published var isLoadingLiveStatus: Bool = false
private let fpApiService: FPAPIService
let creatorId: String
var shouldUpdatePlayer = false
var liveStatusTimer: Timer?
init(fpApiService: FPAPIService, creatorId: String) {
self.fpApiService = fpApiService
self.creatorId = creatorId
}
func load() {
Task { @MainActor in
state = .loading
let type: CDNV2API.ModelType_getDeliveryInfo = .live
logger.debug("Loading livestream information.", metadata: [
"type": "\(type)",
"id": "\(creatorId)",
])
// First, load the creator information. We do get some static information
// at app startup from the authentication code, but since we want to load
// the livestream thumbnail, we don't want this to be out of date and stale,
// in the case that it was updated since app launch when a livestream begins.
let creatorsResponse: [CreatorModelV2]
do {
creatorsResponse = try await fpApiService.getInfo(creatorGUID: [creatorId])
} catch {
self.state = .failed(error)
return
}
guard let creator = creatorsResponse.first(where: { $0.id == self.creatorId }), let livestream = creator.liveStream else {
self.logger.error("Did not receive creator information correctly. Reporting the error to the user.")
self.state = .failed(LivestreamError.missingCreator)
return
}
let cdnResponse: CdnDeliveryV3Response
do {
cdnResponse = try await fpApiService.getDeliveryInfo(scenario: .live, entityId: livestream.id, outputKind: nil)
} catch {
self.state = .failed(error)
return
}
self.logger.notice("Received livestream information.", metadata: [
"origins": "\(cdnResponse.groups.flatMap({ $0.origins ?? [] }).map(\.url).joined(separator: ", "))",
])
// Only use the first group, for now.
let group = cdnResponse.groups.first
let urls = group?.variants.compactMap({ variant -> URL? in
return DeliveryHelper.getBestUrl(variant: variant, group: group)
})
// Livestreams should only really have a single variant. Use the first one returned.
guard let newUrl = urls?.first else {
self.state = .failed(LivestreamError.badUrl)
return
}
self.shouldUpdatePlayer = true
self.state = .loaded((creator, cdnResponse, newUrl))
self.startLoadingLiveStatus()
self.loadLiveStatus()
}
}
func loadLiveStatus() {
guard case let .loaded((_, _, url)) = self.state else {
return
}
logger.debug("Loading livestream status", metadata: [
"id": "\(creatorId)",
])
self.isLoadingLiveStatus = true
fpApiService
.getLivestream(url: URI(string: url.absoluteString))
.whenComplete({ result in
DispatchQueue.main.async {
self.isLoadingLiveStatus = false
switch result {
case let .success(clientResponse):
if clientResponse.status == .ok {
self.isLive = true
self.logger.debug("Livestream is live", metadata: [
"id": "\(self.creatorId)",
])
} else {
self.isLive = false
self.logger.debug("Livestream is not live", metadata: [
"id": "\(self.creatorId)",
])
}
case let .failure(error):
self.logger.warning("Encountered unexpected error when loading livestream status. Error: \(String(reflecting: error))")
self.isLive = false
}
}
})
}
func stopLoadingLiveStatus() {
logger.debug("Stopping livestream polling", metadata: [
"id": "\(creatorId)",
])
self.liveStatusTimer?.invalidate()
self.liveStatusTimer = nil
}
func startLoadingLiveStatus() {
let interval: TimeInterval = 5.0
logger.info("Starting livestream polling", metadata: [
"id": "\(creatorId)",
"interval": "\(interval)",
])
guard self.liveStatusTimer == nil else {
return
}
self.liveStatusTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: { _ in
self.loadLiveStatus()
})
self.loadLiveStatus()
}
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 avMetadataItems(for creator: CreatorModelV2) -> [AVMetadataItem] {
return [
metadataItem(identifier: .commonIdentifierTitle, value: creator.liveStream?.title ?? "Livestream"),
metadataItem(identifier: .commonIdentifierDescription, value: creator.liveStream?.description ?? ""),
]
}
func createAVPlayerItem() -> AVPlayerItem {
self.logger.notice("Creating new AVPlayerItem for livestream playback.", metadata: [
"id": "\(creatorId)",
])
guard case let .loaded((creator, _, url)) = self.state else {
self.logger.critical("No playable URL was found for the livestream. Invalid state. Closing the application.")
fatalError()
}
self.logger.debug("Setting up AVPlayerItem with matched content url.", metadata: [
"url": "\(url)",
])
let asset = AVURLAsset(url: url, options: [AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies as Any])
let templateItem = AVPlayerItem(asset: asset)
templateItem.externalMetadata = avMetadataItems(for: creator)
return templateItem
}
}