Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled

- 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>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
import Foundation
import SwiftUI
import FloatplaneAPIClient
class AuthViewModel: BaseViewModel, ObservableObject {
@Published var isLoadingAuthStatus = true
@Published var isLoggedIn = false
@Published var userInfo = UserInfo()
@Published var authenticationCheckError: Error? = nil
@Published var showAuthenticationErrorAlert = false
@Published var showNoSubscriptionsAlert = false
@Published var isAttemptingLogin = false
@Published var showIncorrectLoginAlert = false
@Published var loginError: Error? = nil
@Published var isAttemptingSecondFactor = false
@Published var showIncorrectSecondFactorAlert = false
@Published var secondFactorError: Error? = nil
private let fpApiService: FPAPIService
init(fpApiService: FPAPIService) {
self.fpApiService = fpApiService
}
func determineAuthenticationStatus() {
Task { @MainActor in
isLoadingAuthStatus = true
logger.info("Determining authentication status. Retrieving the self object and list of subscriptions.")
// Get self and subscriptions
let userSelfResponse: UserV3API.GetStatus
do {
let appVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "<unknown>"
let bundleVersion = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "<unknown>"
userSelfResponse = try await fpApiService.getUserStatus(platform: .web, version: "Wasserflug-tvOS-\(appVersion)-\(bundleVersion)")
} catch {
self.logger.error("Encountered an unexpected error while loading user self. Reporting the error to the user. Showing login screen. Error: \(String(reflecting: error))")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
self.authenticationCheckError = error
self.showAuthenticationErrorAlert = true
return
}
let userSelf: UserSelfV3Response
switch userSelfResponse {
case let .http200(value: value, raw: raw):
guard let selfUser = value.selfUser else {
self.logger.notice("Received invalid 200 response for user self and user subscriptions. Assuming user is not logged in. Showing login screen to user.")
self.logger.debug("User self response: \(raw)")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
return
}
userSelf = selfUser
case let .http403(value: error, raw: _):
self.logger.notice("Received invalid 403 response for user self and user subscriptions. Assuming user is not logged in. Showing login screen to user.")
self.logger.debug("User self response: \(error)")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
return
case let .http400(value: error, raw: _),
let .http401(value: error, raw: _),
let .http404(value: error, raw: _),
let .http0(value: error, raw: _):
self.logger.error("Encountered an unexpected response status code while loading user self. Reporting the error to the user. Showing login screen. Error: \(String(reflecting: error))")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
self.authenticationCheckError = error
self.showAuthenticationErrorAlert = true
return
case .http429(raw: _):
self.logger.warning("Received HTTP 429 Too Many Requests.")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
self.authenticationCheckError = WasserflugError.http429
self.showAuthenticationErrorAlert = true
return
}
let userSubscriptions: [UserSubscriptionModel]
do {
userSubscriptions = try await self.fpApiService.listUserSubscriptionsV3()
} catch {
self.logger.error("Encountered an unexpected error while loading user subscriptions. Reporting the error to the user. Showing login screen. Error: \(String(reflecting: error))")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
self.authenticationCheckError = error
self.showAuthenticationErrorAlert = true
return
}
self.logger.notice("Recieved successful user self and subscription responses.", metadata: [
"userId": "\(userSelf.id)",
"username": "\(userSelf.username)",
"subIds": "\(userSubscriptions.map(\.creator))",
])
guard !userSubscriptions.isEmpty else {
self.logger.warning("Login was successful but the user profile did not return any subscriptions. Aborting authentication process and informing user of inability to proceed.")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
self.showNoSubscriptionsAlert = true
return
}
self.isLoggedIn = true
self.userInfo.userSelf = userSelf
self.userInfo.userSubscriptions = userSubscriptions
// With the subscriptions, get the creators of the subscriptions.
// Convert to Set to remove possible duplicates (which is possible).
let creatorGuids = Set<String>(self.userInfo.userSubscriptions.map(\.creator))
self.logger.info("Loading creator(s) information from subscriptions", metadata: [
"creatorGuids": "\(creatorGuids)",
])
// Get the creators and their channels.
// This used to use the V2 API because it allowed for pulling multiple creators in one request,
// which saves on number of requests and latency and such. But now that we're supporting channels,
// which are not included in detail in V2 responses, we need to get them one at a time. So, we
// can switch to the V3 API to do one request per creator which included channels.
var creatorInfos: [CreatorModelV3] = []
do {
for id in creatorGuids {
try creatorInfos.append(await self.fpApiService.getCreator(id: id))
}
} catch {
self.logger.error("Encountered an unexpected error while loading user subscriptions. Reporting the error to the user. Showing login screen. Error: \(String(reflecting: error))")
self.isLoggedIn = false
self.isLoadingAuthStatus = false
self.authenticationCheckError = error
self.showAuthenticationErrorAlert = true
return
}
self.logger.notice("Retrieved \(creatorInfos.count) creator(s) information.")
self.userInfo.creators = Dictionary(uniqueKeysWithValues: creatorInfos.map({ ($0.id, $0) }))
// With the creators, get the creator owners
let ownerIds = creatorInfos.map(\.owner.id)
self.logger.info("Loading creator owner(s) information.", metadata: [
"ownerIds": "\(ownerIds)",
])
self.isLoadingAuthStatus = false
}
}
func attemptLogin(username: String, password: String, isLoggedIn: @escaping () -> Void, needsSecondFactor: @escaping () -> Void) {
isAttemptingLogin = true
logger.info("Attempting login action.", metadata: [
"username": "\(username)",
])
fpApiService.login(username: username, password: password, captchaToken: nil)
.whenComplete { result in
DispatchQueue.main.async {
self.isAttemptingLogin = false
switch result {
case let .success(response):
switch response {
case let .http200(value: response, raw: clientResponse):
self.logger.debug("Successful login raw response: \(clientResponse.plaintextDebugContent)")
// Set global cookie
// FloatplaneAPIClientAPI.storeAuthentifcationCookies(from: clientResponse)
if response.needs2FA {
self.logger.notice("Successfully logged in as user '\(username)'. Requires second factor to continue authentication process.")
needsSecondFactor()
} else {
self.logger.notice("Successfully logged in as user '\(username)'. No second factor required.", metadata: [
"id": "\(response.user?.id ?? "<no user object found>")",
"username": "\(response.user?.username ?? "<no user object found>")",
])
isLoggedIn()
}
case let .http401(value: response, raw: clientResponse):
self.logger.debug("Unsuccessfull login raw response: \(clientResponse.plaintextDebugContent)")
self.logger.notice("Login attempt failed, received unauthorized response. ErrorModel: \(String(describing: response))")
self.showIncorrectLoginAlert = true
case let .http0(value: errorModel, raw: clientResponse),
let .http400(value: errorModel, raw: clientResponse),
let .http403(value: errorModel, raw: clientResponse),
let .http404(value: errorModel, raw: clientResponse):
self.logger.warning("Received an unexpected HTTP status (\(clientResponse.status.code)) while attempting login. Reporting the error to the user. Error Model: \(String(reflecting: errorModel)).")
self.showIncorrectLoginAlert = true
self.loginError = errorModel
case .http429(raw: _):
self.logger.warning("Received HTTP 429 Too Many Requests.")
self.showIncorrectLoginAlert = true
self.loginError = WasserflugError.http429
}
case let .failure(error):
self.logger.error("Encountered an unexpected error while attempting login. Reporting the error to the user. Error: \(String(reflecting: error))")
self.showIncorrectLoginAlert = true
self.loginError = error
}
}
}
}
func attemptSecondFactor(secondFactorCode: String, isLoggedIn: @escaping () -> Void) {
isAttemptingSecondFactor = true
logger.info("Attempting 2fa action for login process.")
// Sanitize the code
let secondFactorCode = secondFactorCode.filter(\.isNumber).stringValue
fpApiService.secondFactor(token: secondFactorCode)
.whenComplete { result in
DispatchQueue.main.async {
self.isAttemptingSecondFactor = false
switch result {
case let .success(response):
switch response {
case let .http200(value: response, raw: clientResponse):
self.logger.debug("Successful 2fa raw response: \(clientResponse.plaintextDebugContent)")
self.logger.notice("Successfully resolved second factor in login process.", metadata: [
"id": "\(response.user?.id ?? "<no user object found>")",
"username": "\(response.user?.username ?? "<no user object found>")",
])
// Set global cookie
FloatplaneAPIClientAPI.storeAuthentifcationCookies(from: clientResponse)
isLoggedIn()
case let .http401(value: response, raw: clientResponse):
self.logger.debug("Unsuccessfull 2fa raw response: \(clientResponse.plaintextDebugContent)")
self.logger.notice("2fa attempt failed, received unauthorized response. ErrorModel: \(String(describing: response))")
self.showIncorrectSecondFactorAlert = true
case let .http0(value: errorModel, raw: clientResponse),
let .http400(value: errorModel, raw: clientResponse),
let .http403(value: errorModel, raw: clientResponse),
let .http404(value: errorModel, raw: clientResponse):
self.logger.warning("Received an unexpected HTTP status (\(clientResponse.status.code)) while attempting 2fa. Reporting the error to the user. Error Model: \(String(reflecting: errorModel)).")
self.showIncorrectSecondFactorAlert = true
self.secondFactorError = errorModel
case .http429(raw: _):
self.logger.warning("Received HTTP 429 Too Many Requests.")
self.showIncorrectSecondFactorAlert = true
self.secondFactorError = WasserflugError.http429
}
case let .failure(error):
self.logger.error("Encountered an unexpected error while attempting 2fa. Reporting the error to the user. Error: \(String(reflecting: error))")
self.showIncorrectSecondFactorAlert = true
self.secondFactorError = error
}
}
}
}
}
class UserInfo: ObservableObject {
@Published var userSelf: UserSelfV3Response?
@Published var userSubscriptions: [UserSubscriptionModel] = []
@Published var creators: [String: CreatorModelV3] = [:] {
didSet {
setCreatorsInOrder()
}
}
@Published var creatorsInOrder: [CreatorModelV3] = []
private func setCreatorsInOrder() {
creatorsInOrder = []
for sub in userSubscriptions {
if !creatorsInOrder.contains(where: { $0.id == sub.creator }) {
if let creator = creators[sub.creator] {
creatorsInOrder.append(creator)
}
}
}
}
}