stremio/apps/master-api/src/f1tv/accounts.ts
Jos Vooges | STH 8685870358 Add F1TV Scripts integration with multi-account sync and Events merge.
Tabbed Scripts UI keeps Odido and F1TV separate; F1TV syncs calendar sessions into Events and exposes hub play APIs for upcoming Android TV work.
2026-09-22 03:43:56 +02:00

198 lines
6.1 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { decryptSecret, encryptSecret } from "../security/crypto";
export type F1tvAccountHealth = "ok" | "dead" | "unknown";
export type F1tvAccountStored = {
id: string;
label: string;
email: string;
/** Optioneel — voor latere browser-assisted login. */
passwordEnc?: string;
ascendontokenEnc?: string;
entitlementtokenEnc?: string;
sessionId?: string;
enabled: boolean;
healthStatus?: F1tvAccountHealth;
healthCheckedAt?: string;
healthError?: string;
};
export type F1tvAccountPublic = {
id: string;
label: string;
email: string;
hasPassword: boolean;
hasAscendonToken: boolean;
hasEntitlementToken: boolean;
hasSessionId: boolean;
enabled: boolean;
sessionCount: number;
healthStatus: F1tvAccountHealth;
healthCheckedAt: string | null;
healthError: string | null;
};
export type F1tvAccountCreds = {
id: string;
label: string;
email: string;
password: string | null;
ascendontoken: string | null;
entitlementtoken: string | null;
sessionId: string | null;
enabled: boolean;
};
/** contentId → accountIds die dit item mogen spelen. */
export type F1tvContentOwners = Record<string, string[]>;
const rrCursor = new Map<string, number>();
export function accountToPublic(
account: F1tvAccountStored,
contentOwners: F1tvContentOwners
): F1tvAccountPublic {
let sessionCount = 0;
for (const owners of Object.values(contentOwners)) {
if (owners.includes(account.id)) sessionCount += 1;
}
return {
id: account.id,
label: account.label || account.email,
email: account.email,
hasPassword: !!account.passwordEnc,
hasAscendonToken: !!account.ascendontokenEnc,
hasEntitlementToken: !!account.entitlementtokenEnc,
hasSessionId: !!account.sessionId?.trim(),
enabled: account.enabled !== false,
sessionCount,
healthStatus: account.healthStatus ?? "unknown",
healthCheckedAt: account.healthCheckedAt ?? null,
healthError: account.healthError ?? null,
};
}
export function decryptAccount(
account: F1tvAccountStored,
sessionSecret: string
): F1tvAccountCreds {
return {
id: account.id,
label: account.label || account.email,
email: account.email,
password: account.passwordEnc
? decryptSecret(account.passwordEnc, sessionSecret)
: null,
ascendontoken: account.ascendontokenEnc
? decryptSecret(account.ascendontokenEnc, sessionSecret)
: null,
entitlementtoken: account.entitlementtokenEnc
? decryptSecret(account.entitlementtokenEnc, sessionSecret)
: null,
sessionId: account.sessionId?.trim() || null,
enabled: account.enabled !== false,
};
}
export function createAccountStored(
sessionSecret: string,
input: {
email: string;
password?: string;
label?: string;
enabled?: boolean;
id?: string;
ascendontoken?: string;
entitlementtoken?: string;
sessionId?: string;
}
): F1tvAccountStored {
const email = input.email.trim().toLowerCase();
if (!email) throw new Error("E-mail verplicht");
const password = input.password?.trim();
const ascendontoken = input.ascendontoken?.trim();
const entitlementtoken = input.entitlementtoken?.trim();
if (!password && !ascendontoken && !entitlementtoken) {
throw new Error("Wachtwoord of tokens verplicht");
}
return {
id: input.id?.trim() || randomUUID(),
label: (input.label ?? "").trim() || email,
email,
passwordEnc: password ? encryptSecret(password, sessionSecret) : undefined,
ascendontokenEnc: ascendontoken
? encryptSecret(ascendontoken, sessionSecret)
: undefined,
entitlementtokenEnc: entitlementtoken
? encryptSecret(entitlementtoken, sessionSecret)
: undefined,
sessionId: input.sessionId?.trim() || undefined,
enabled: input.enabled !== false,
};
}
export function parseAccountsJson(raw: unknown): F1tvAccountStored[] {
if (!Array.isArray(raw)) return [];
const out: F1tvAccountStored[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
const id = String(row.id ?? "").trim();
const email = String(row.email ?? "").trim().toLowerCase();
if (!id || !email) continue;
out.push({
id,
label: String(row.label ?? "").trim() || email,
email,
passwordEnc: String(row.passwordEnc ?? "").trim() || undefined,
ascendontokenEnc: String(row.ascendontokenEnc ?? "").trim() || undefined,
entitlementtokenEnc: String(row.entitlementtokenEnc ?? "").trim() || undefined,
sessionId: String(row.sessionId ?? "").trim() || undefined,
enabled: row.enabled !== false,
healthStatus:
row.healthStatus === "ok" ||
row.healthStatus === "dead" ||
row.healthStatus === "unknown"
? row.healthStatus
: undefined,
healthCheckedAt:
typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined,
healthError: typeof row.healthError === "string" ? row.healthError : undefined,
});
}
return out;
}
export function parseContentOwners(raw: unknown): F1tvContentOwners {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: F1tvContentOwners = {};
for (const [contentId, value] of Object.entries(raw as Record<string, unknown>)) {
if (!contentId.trim()) continue;
const ids = Array.isArray(value)
? value.map((v) => String(v).trim()).filter(Boolean)
: [];
if (ids.length) out[contentId] = [...new Set(ids)];
}
return out;
}
export function pickAccountIdForContent(
contentId: string,
ownerIds: string[],
enabledAccountIds: string[]
): string | null {
const pool = (ownerIds.length ? ownerIds : enabledAccountIds).filter((id) =>
enabledAccountIds.includes(id)
);
if (!pool.length) return null;
const key = contentId || "__global__";
const cursor = rrCursor.get(key) ?? 0;
const chosen = pool[cursor % pool.length];
rrCursor.set(key, cursor + 1);
return chosen;
}
export function accountHasPlayTokens(account: F1tvAccountStored): boolean {
return !!(account.ascendontokenEnc && account.entitlementtokenEnc);
}