Tabbed Scripts UI keeps Odido and F1TV separate; F1TV syncs calendar sessions into Events and exposes hub play APIs for upcoming Android TV work.
198 lines
6.1 KiB
TypeScript
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);
|
|
}
|