Round-robin Viaplay VOD accounts and slow Token Tray login.

VOD play rotates accounts like live; tray types/clicks with human delays and pauses between accounts to reduce captcha triggers.
This commit is contained in:
Jos Vooges | STH 2026-09-23 02:26:07 +02:00
parent 8b39ce06ee
commit bedebf44f6
5 changed files with 140 additions and 44 deletions

View file

@ -281,7 +281,10 @@ export async function resolveViaplayGuidPlay(
let cachedKeys = await keysForMediaGuid(sessionSecret, guid);
let lastError: Error | null = null;
for (const account of accounts) {
// Zelfde round-robin als live: elke VOD-play start op een ander account.
const ordered = accountOrder(guid, accounts, []);
for (const account of ordered) {
try {
const play = await playByGuid(account, guid, {
deviceKey: tweaks.deviceKey,

View file

@ -3,6 +3,7 @@ import type { TrayConfig } from "./config";
import { saveConfig } from "./config";
import { loginViaplayUi } from "./login/viaplay";
import { loginF1tvUi } from "./login/f1tv";
import { betweenAccounts } from "./login/human";
export type AgentAccount = {
provider: "viaplay" | "f1tv";
@ -144,11 +145,17 @@ export async function refreshAccountsSilent(
const notify = opts?.notify !== false;
const summary: RefreshSummary = { ok: 0, failed: 0, skipped: 0, errors: [] };
let attempted = 0;
for (const acc of accounts) {
if (!acc.password?.trim()) {
summary.skipped += 1;
continue;
}
if (attempted > 0) {
// Captcha-vriendelijk: niet back-to-back inloggen op meerdere accounts
await betweenAccounts();
}
attempted += 1;
try {
await refreshOne(cfg, acc, headless);
summary.ok += 1;

View file

@ -1,6 +1,14 @@
import { chromium, type BrowserContext, type Page } from "playwright";
import path from "node:path";
import { app } from "electron";
import {
afterNavigate,
humanClick,
humanDelay,
humanType,
think,
waitForVisiblePassword,
} from "./human";
export type F1tvLoginResult = {
ascendontoken: string;
@ -20,8 +28,8 @@ async function dismissCookies(page: Page) {
for (const loc of candidates) {
try {
const btn = loc.first();
if (await btn.isVisible({ timeout: 1500 })) {
await btn.click({ timeout: 2000 });
if (await btn.isVisible({ timeout: 2500 })) {
await humanClick(btn, 3000);
return;
}
} catch {
@ -76,6 +84,7 @@ async function launchProfile(userDataDir: string, headless: boolean) {
headless,
viewport: { width: 1280, height: 900 } as const,
args: ["--disable-blink-features=AutomationControlled"],
slowMo: 40 + Math.floor(Math.random() * 50),
};
for (const channel of ["chrome", "msedge"] as const) {
try {
@ -97,10 +106,9 @@ async function launchProfile(userDataDir: string, headless: boolean) {
/** Login-formulier: zichtbaar password → e-mail in zelfde form (niet forgot-password). */
async function fillLogin(page: Page, email: string, password: string) {
await dismissCookies(page);
await think();
const passBox = page.locator('input[type="password"]').filter({ visible: true }).first();
await passBox.waitFor({ state: "visible", timeout: 60_000 });
const passBox = await waitForVisiblePassword(page, 60_000);
const form = page.locator("form").filter({ has: passBox }).first();
// F1 gebruikt type=text name=email class=txtEmail — niet type=email
const emailBox = form
@ -110,15 +118,16 @@ async function fillLogin(page: Page, email: string, password: string) {
.filter({ visible: true })
.first();
await emailBox.waitFor({ state: "visible", timeout: 15_000 });
await emailBox.click({ clickCount: 3 });
await emailBox.fill(email);
await passBox.fill(password);
await humanType(emailBox, email);
await think();
await humanType(passBox, password);
await humanDelay(700, 1800);
const submit = form
.getByRole("button", { name: /log\s*in|sign\s*in|continue|submit/i })
.or(form.locator('button[type="submit"], input[type="submit"], button.btnLogin, a.btnLogin'))
.first();
await submit.click();
await humanClick(submit);
}
export async function loginF1tvUi(opts: {
@ -139,7 +148,7 @@ export async function loginF1tvUi(opts: {
"https://f1tv.formula1.com/",
]) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
await new Promise((r) => setTimeout(r, 2000));
await afterNavigate();
await dismissCookies(page);
let tokens = await readTokens(context);
@ -150,9 +159,9 @@ export async function loginF1tvUi(opts: {
const signIn = page.getByRole("link", { name: /sign in|log in/i }).or(
page.getByRole("button", { name: /sign in|log in/i })
);
if (await signIn.first().isVisible({ timeout: 2000 })) {
await signIn.first().click();
await new Promise((r) => setTimeout(r, 2000));
if (await signIn.first().isVisible({ timeout: 2500 })) {
await humanClick(signIn.first());
await afterNavigate();
}
} catch {
/* ignore */
@ -168,9 +177,9 @@ export async function loginF1tvUi(opts: {
}
}
const deadline = Date.now() + 120_000;
const deadline = Date.now() + 150_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
await humanDelay(2200, 4000);
if (!/f1tv\.formula1\.com/i.test(page.url())) {
await page
.goto("https://f1tv.formula1.com/", {
@ -178,6 +187,7 @@ export async function loginF1tvUi(opts: {
timeout: 45_000,
})
.catch(() => undefined);
await afterNavigate();
}
const tokens = await readTokens(context);
if (tokens) return tokens;

View file

@ -0,0 +1,66 @@
import type { Locator, Page } from "playwright";
/** Willekeurige pauze (ms). */
export function humanDelay(minMs: number, maxMs: number): Promise<void> {
const lo = Math.max(0, Math.min(minMs, maxMs));
const hi = Math.max(minMs, maxMs);
const ms = lo + Math.floor(Math.random() * (hi - lo + 1));
return new Promise((r) => setTimeout(r, ms));
}
/** Korte “denk”-pauze tussen UI-stappen. */
export function think(): Promise<void> {
return humanDelay(450, 1400);
}
/** Langere pauze na navigatie / pagina-load. */
export function afterNavigate(): Promise<void> {
return humanDelay(1800, 4200);
}
/** Pauze tussen accounts bij batch-refresh (captcha-vriendelijk). */
export function betweenAccounts(): Promise<void> {
return humanDelay(12_000, 28_000);
}
/** Klik met kleine delay (geen instant robot-click). */
export async function humanClick(locator: Locator, timeoutMs = 8000): Promise<void> {
await think();
await locator.click({ timeout: timeoutMs, delay: 80 + Math.floor(Math.random() * 160) });
await humanDelay(300, 900);
}
/**
* Typ als mens: focus → selecteer → per teken met wisselende vertraging.
* Vermijdt instant `.fill()` dat bot-detectie triggert.
*/
export async function humanType(
locator: Locator,
text: string,
opts?: { clearFirst?: boolean }
): Promise<void> {
await locator.waitFor({ state: "visible", timeout: 30_000 });
await think();
await locator.click({
clickCount: opts?.clearFirst === false ? 1 : 3,
delay: 60 + Math.floor(Math.random() * 100),
});
await humanDelay(200, 550);
// Per-character delay; geen instant fill()
await locator.pressSequentially(text, {
delay: 55 + Math.floor(Math.random() * 90),
});
await humanDelay(280, 800);
}
/** Wacht tot password zichtbaar is, met menselijke poll i.p.v. agressief spammen. */
export async function waitForVisiblePassword(page: Page, timeoutMs = 60_000): Promise<Locator> {
const passBox = page.locator('input[type="password"]').filter({ visible: true }).first();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await passBox.isVisible().catch(() => false)) return passBox;
await humanDelay(700, 1600);
}
await passBox.waitFor({ state: "visible", timeout: 5_000 });
return passBox;
}

View file

@ -1,6 +1,14 @@
import { chromium, type BrowserContext, type Page } from "playwright";
import path from "node:path";
import { app } from "electron";
import {
afterNavigate,
humanClick,
humanDelay,
humanType,
think,
waitForVisiblePassword,
} from "./human";
export type ViaplayLoginResult = {
session: string;
@ -22,9 +30,8 @@ async function dismissCookies(page: Page) {
for (const loc of candidates) {
try {
const btn = loc.first();
if (await btn.isVisible({ timeout: 1500 })) {
await btn.click({ timeout: 2000 });
await new Promise((r) => setTimeout(r, 500));
if (await btn.isVisible({ timeout: 2500 })) {
await humanClick(btn, 3000);
return;
}
} catch {
@ -36,10 +43,9 @@ async function dismissCookies(page: Page) {
/** Vul login via zichtbaar wachtwoordveld → zelfde form (vermijdt verborgen forgot-password). */
async function fillLogin(page: Page, email: string, password: string) {
await dismissCookies(page);
await think();
const passBox = page.locator('input[type="password"]').filter({ visible: true }).first();
await passBox.waitFor({ state: "visible", timeout: 45_000 });
const passBox = await waitForVisiblePassword(page, 45_000);
const form = page.locator("form").filter({ has: passBox }).first();
const emailInForm = form
.locator(
@ -49,9 +55,8 @@ async function fillLogin(page: Page, email: string, password: string) {
.first();
if (await emailInForm.count()) {
await emailInForm.fill(email);
await humanType(emailInForm, email);
} else {
// Fallback: zichtbaar e-mailveld op de pagina (niet forgotPassword)
const emailBox = page
.locator(
'input[type="email"], input[autocomplete="username"], input[autocomplete="email"]'
@ -65,10 +70,12 @@ async function fillLogin(page: Page, email: string, password: string) {
.filter({ visible: true })
.first();
await emailBox.waitFor({ state: "visible", timeout: 15_000 });
await emailBox.fill(email);
await humanType(emailBox, email);
}
await passBox.fill(password);
await think();
await humanType(passBox, password);
await humanDelay(600, 1600);
const submit = form
.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen|continue/i })
@ -77,7 +84,7 @@ async function fillLogin(page: Page, email: string, password: string) {
page.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen/i })
)
.first();
await submit.click();
await humanClick(submit);
}
async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult | null> {
@ -98,6 +105,7 @@ async function launchProfile(userDataDir: string, headless: boolean) {
headless,
viewport: { width: 1280, height: 900 } as const,
args: ["--disable-blink-features=AutomationControlled"],
slowMo: 40 + Math.floor(Math.random() * 50),
};
for (const channel of ["chrome", "msedge"] as const) {
try {
@ -118,6 +126,7 @@ async function launchProfile(userDataDir: string, headless: boolean) {
/**
* Stille UI-login in echte Chromium (geen HTTP login-API → geen cmd-CAPTCHA).
* Tempo is expres menselijk — te snelle fills/clicks triggeren CAPTCHA.
*/
export async function loginViaplayUi(opts: {
accountId: string;
@ -135,6 +144,7 @@ export async function loginViaplayUi(opts: {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await afterNavigate();
await dismissCookies(page);
let tokens = await readCookies(context);
if (tokens) return tokens;
@ -147,7 +157,7 @@ export async function loginViaplayUi(opts: {
]) {
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
await new Promise((r) => setTimeout(r, 1200));
await afterNavigate();
await dismissCookies(page);
const hasPass = await page
.locator('input[type="password"]')
@ -161,9 +171,9 @@ export async function loginViaplayUi(opts: {
await fillLogin(page, opts.email, opts.password);
const deadline = Date.now() + 90_000;
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1500));
await humanDelay(2000, 3500);
tokens = await readCookies(context);
if (tokens) return tokens;
try {
@ -171,8 +181,8 @@ export async function loginViaplayUi(opts: {
.locator('[data-testid*="profile" i], [class*="profile" i] button, button')
.filter({ hasText: /./ })
.first();
if (await profile.isVisible({ timeout: 400 })) {
await profile.click().catch(() => undefined);
if (await profile.isVisible({ timeout: 800 })) {
await humanClick(profile).catch(() => undefined);
}
} catch {
/* ignore */