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>
162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
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);
|
|
}
|