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
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:
161
doc/research/reference-sources/fpts_index.ts
Normal file
161
doc/research/reference-sources/fpts_index.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user