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,161 @@
import got from "got";
import * as client from "openid-client";
import { Api } from "./api.js";
import { CDN } from "./cdn.js";
import { Creator } from "./creator.js";
import { User } from "./user.js";
import { TokenEndpointResponse } from "openid-client";
import { Content } from "./content.js";
import { Core } from "./Core.js";
export const version = "5.2.8";
export type AuthToken = TokenEndpointResponse & { expiresEpoch?: number };
export type OnDeviceCode = (response: client.DeviceAuthorizationResponse) => any;
export type OnAuthToken = (authToken: AuthToken) => void;
export type AuthConfig = {
authToken?: AuthToken;
onAuthToken?: OnAuthToken;
onDeviceCode: OnDeviceCode;
clientId: string;
clientSecret?: string;
serverUrl?: string;
};
export type FloatplaneSettings = {
authConfig: AuthConfig;
userAgent?: string;
baseUrl?: string;
};
export class Floatplane {
public got: typeof got;
public user: User;
public api: Api;
public creator: Creator;
public cdn: CDN;
public content: Content;
private oauthConfig?: client.Configuration;
public readonly authConfig: AuthConfig;
public readonly userAgent: string = `FloatplaneAPI/${version} (Inrix, +https://github.com/Inrixia/floatplaneAPI.ts), CFNetwork`;
constructor({ authConfig, baseUrl, userAgent }: FloatplaneSettings) {
this.authConfig = authConfig;
baseUrl ??= Core.BaseUrl;
const headers = {
"User-Agent": userAgent ?? this.userAgent,
accept: "application/json",
connection: "keep-alive",
};
this.got = got.extend({
headers,
retry: {
limit: 5, // Maximum number of retries
},
hooks: {
beforeRequest: [
async ({ headers, url, prefixUrl }) => {
if (!new URL(url ?? prefixUrl).href.startsWith(baseUrl)) return;
await this.refreshAuthToken();
if (this.authToken?.access_token) headers.authorization = `Bearer ${this.authToken.access_token}`;
},
],
},
});
this.user = new User(this.got, baseUrl);
this.api = new Api(this.got, baseUrl);
this.creator = new Creator(this.got, baseUrl);
this.cdn = new CDN(this.got, baseUrl);
this.content = new Content(this.got, baseUrl);
}
public extend(...params: Parameters<typeof got.extend>) {
this.got = this.got.extend(...params);
this.user.got = this.got;
this.api.got = this.got;
this.creator.got = this.got;
this.cdn.got = this.got;
this.content.got = this.got;
}
private get authToken() {
return this.authConfig.authToken;
}
private set authToken(authToken: AuthToken | null | undefined) {
if (authToken) authToken.expiresEpoch = Date.now() + (authToken?.expires_in ?? 30) * 1000;
this.authConfig.authToken = authToken ?? undefined;
if (this.authConfig.onAuthToken && authToken) this.authConfig.onAuthToken(authToken);
}
/**
* Time untl token expiry in ms
*/
public authTokenExpiresIn(authToken: AuthToken | null | undefined = this.authToken): number {
const expiresEpoch = authToken?.expiresEpoch;
if (!expiresEpoch) return -1;
return Math.max(expiresEpoch - Date.now(), 0);
}
public refreshAuthToken = async () => {
if (!this.authToken) return this.login();
if (this.authTokenExpiresIn() < 60000) {
const refreshToken = this.authToken.refresh_token;
if (!refreshToken) throw new Error("No refresh token available to refresh OAuth token!");
this.oauthConfig ??= await client.discovery(
new URL(this.authConfig.serverUrl ?? "https://auth.floatplane.com/realms/floatplane"),
this.authConfig.clientId,
this.authConfig.clientSecret,
);
const refreshedTokenSet = await client.refreshTokenGrant(this.oauthConfig, refreshToken);
if (refreshedTokenSet.access_token === undefined) throw new Error("No access token received when refreshing token!");
this.authToken = refreshedTokenSet;
}
};
/**
* Login to floatplane so future requests are authenticated using the Device flow
* @returns {Promise<User>} User object.
*/
deviceLogin = async (onDeviceCode = this.authConfig.onDeviceCode) => {
this.oauthConfig ??= await client.discovery(
new URL(this.authConfig.serverUrl ?? "https://auth.floatplane.com/realms/floatplane"),
this.authConfig.clientId,
this.authConfig.clientSecret,
);
if (!this.authToken) {
const response = await client.initiateDeviceAuthorization(this.oauthConfig, { scope: "openid profile email offline_access" });
await onDeviceCode(response);
const authToken = await client.pollDeviceAuthorizationGrant(this.oauthConfig, response);
if (authToken.access_token === undefined) throw new Error("No access token received from device authorization flow!");
this.authToken = authToken;
}
return this.user.self();
};
private login = this.deviceLogin;
/**
* Returns true if authenticated or Error if not.
*/
isAuthenticated = async (): Promise<Error | true> =>
this.user
.self()
.then((): true => true)
.catch((err: Error) => err);
}