Fix : quality selection
All checks were successful
release / build (push) Successful in 26s
release / verify-windows (push) Successful in 1m28s

This commit is contained in:
jeanotx32
2026-08-12 14:19:02 +02:00
parent 399f54f290
commit 043823600b
3 changed files with 183 additions and 67 deletions

View File

@@ -297,13 +297,15 @@ en 720p — OBS ne peut pas inventer des pixels absents du flux source.
Stripchat ne démarre pas toujours sur sa meilleure qualité disponible, et chaque nouvelle page
(donc chaque nouveau stream ouvert) repart de ce choix du site, pas du précédent réglage
manuel. En mode BiDi, l'agent clique le bouton de qualité du lecteur puis la meilleure option
du menu, en un seul aller-retour :
du menu :
```js
document.querySelector('.player-resolution').click();
// attend le menu, puis clique l'option marquée « (Source) », ou à défaut celle dont la
// valeur numérique (extraite de « 1080p », « 960p »…) est la plus élevée
```
1. repère le bouton (`.player-resolution`) et clique dessus,
2. attend le menu, puis clique l'option marquée « (Source) », ou à défaut celle dont la
valeur numérique (extraite de « 1080p », « 960p »…) est la plus élevée.
Le clic part par un vrai évènement souris WebDriver (`input.performActions`), pas par un
`.click()` scripté dans la page : un essai au `.click()` DOM laissait le menu fermé — le site
ignore apparemment les clics non fiables (`isTrusted: false`) sur ce bouton.
La comparaison porte sur la valeur numérique, pas sur la position dans la liste : une échelle
tronquée (rien au-dessus de 480p) ou une valeur atypique — souvent le cas des flux
@@ -313,7 +315,8 @@ trompent donc pas le choix.
Couplé à la structure actuelle du lecteur Stripchat — deux classes CSS
(`.player-resolution`, `.player-resolution-tooltip__button--resolution`) relevées en
inspectant la page en direct. Sans effet, mais sans erreur non plus (juste un avertissement
dans l'historique), si Stripchat change ce balisage.
dans l'historique), si Stripchat change ce balisage — l'avertissement inclut alors un extrait
du menu tel qu'ouvert, pour diagnostiquer sans repasser par les DevTools.
#### Lancement simple

View File

@@ -23,6 +23,23 @@ export interface StreamQualityOutcome {
already?: boolean;
/** Raison de l'échec, présente seulement si `!ok`. */
reason?: string;
/** Extrait de diagnostic (menu trouvé mais vide, p. ex.) — pour le journal, pas pour l'utilisateur. */
debug?: string;
}
/** Une option de résolution repérée dans le menu, avec ses coordonnées écran. */
interface TooltipOption {
label: string;
x: number;
y: number;
active: boolean;
}
/** État du menu de qualité au moment de la lecture. */
interface TooltipState {
found: boolean;
options: TooltipOption[];
debug?: string;
}
/**
@@ -173,11 +190,16 @@ export class FirefoxController {
*
* Stripchat ne démarre pas toujours sur son maximum : la valeur active au
* chargement varie, et chaque nouvelle page (donc chaque nouveau stream
* ouvert) repart du choix du site, pas du précédent. Le tout se joue en un
* seul aller-retour BiDi — un script auto-suffisant qui ouvre le menu,
* attend son montage, et clique la meilleure option — plutôt qu'une suite
* d'appels synchronisés par des délais fixes, plus fragile face à la
* variabilité du temps de rendu.
* ouvert) repart du choix du site, pas du précédent.
*
* Le clic part par `input.performActions` (un vrai évènement souris, comme
* la touche plein écran plus haut), pas par un `.click()` scripté dans la
* page : un premier essai au `.click()` DOM laissait le menu fermé — le
* bouton existait bien, mais l'ouverture ne se déclenchait pas depuis un
* évènement non fiable (`isTrusted: false`). D'où l'aller-retour en
* plusieurs étapes BiDi (repérer le bouton, cliquer réellement, attendre le
* menu, repérer puis cliquer la meilleure option) plutôt qu'un unique
* script auto-suffisant.
*
* Couplé à la structure actuelle du lecteur (`.player-resolution`,
* `.player-resolution-tooltip__button--resolution`) : sans effet, mais sans
@@ -187,21 +209,37 @@ export class FirefoxController {
async selectMaxStreamQuality(): Promise<StreamQualityOutcome> {
await this.ensureReady();
const expression = `(async () => {
const button = document.querySelector('.player-resolution');
if (!button) return JSON.stringify({ ok: false, reason: 'bouton qualité introuvable' });
button.click();
// Sans activation de l'onglet, le clic partirait vers une page en
// arrière-plan, dont le lecteur ignore les évènements souris.
await this.send('browsingContext.activate', { context: this.context }, 5000).catch(() =>
undefined,
);
const buttonPoint = await this.locate("document.querySelector('.player-resolution')").catch(
() => null,
);
if (!buttonPoint) return { ok: false, reason: 'bouton qualité introuvable' };
await this.click(buttonPoint.x, buttonPoint.y);
// Le menu s'anime à l'ouverture — quelques essais espacés plutôt qu'une
// seule lecture immédiate.
const deadline = Date.now() + 3000;
let options = [];
let tooltip: TooltipState | null = null;
while (Date.now() < deadline) {
options = [...document.querySelectorAll(
'.player-resolution-tooltip__resolutions .player-resolution-tooltip__button--resolution'
)];
if (options.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 100));
tooltip = await this.readTooltip().catch(() => null);
if (tooltip?.found && tooltip.options.length > 0) break;
await delay(150);
}
if (!tooltip?.found) return { ok: false, reason: 'menu de qualité non ouvert' };
if (tooltip.options.length === 0) {
return {
ok: false,
reason: 'menu ouvert mais aucune option de résolution trouvée',
debug: tooltip.debug,
};
}
if (options.length === 0) return JSON.stringify({ ok: false, reason: 'menu de qualité non ouvert' });
// « (Source) » est le signal le plus sûr — c'est le site qui désigne le
// flux non dégradé, quelle que soit sa valeur numérique. À défaut, on
@@ -209,41 +247,23 @@ export class FirefoxController {
// décroissant : une échelle tronquée (rien au-dessus de 480p) ou une
// valeur atypique (souvent le cas des flux verticaux, pas calée sur
// l'échelle 240/480/720/1080) ne doit pas tromper le choix.
const height = (el) => {
// Antislash doublé à dessein : cette expression vit dans un template
// literal (le code de la page, pas du TypeScript exécuté ici). Un
// seul antislash y est une séquence d'échappement inconnue, que le
// moteur JS avale silencieusement — «/(\\d+)/» redevient «/(d+)/»,
// qui ne trouve jamais rien, et le classement retombe alors sur
// l'ordre du DOM au lieu de la valeur réelle.
const match = /(\\d+)/.exec(el.getAttribute('data-resolution') || el.textContent);
return match ? parseInt(match[1], 10) : -1;
const height = (label: string) => {
const match = /(\d+)/.exec(label);
return match?.[1] ? parseInt(match[1], 10) : -1;
};
const best =
options.find((el) => /source/i.test(el.textContent)) ||
options.reduce((a, b) => (height(b) > height(a) ? b : a));
const already = best.classList.contains('player-resolution-tooltip__button--active');
const label = best.getAttribute('data-resolution') || best.textContent.trim();
tooltip.options.find((option) => /source/i.test(option.label)) ||
tooltip.options.reduce((a, b) => (height(b.label) > height(a.label) ? b : a));
if (!already) best.click();
// Referme le menu comme le ferait un clic ailleurs sur la page.
document.body.click();
return JSON.stringify({ ok: true, label, already });
})()`;
// BiDi sérialise un objet renvoyé par la page sous forme de paires
// [clé, valeur], pas comme un objet JS ordinaire : la chaîne JSON évite
// d'avoir à décoder ce format, une chaîne restant une chaîne.
const raw = await this.evaluate(expression).catch((err: Error) =>
JSON.stringify({ ok: false, reason: err.message }),
);
try {
return JSON.parse(typeof raw === 'string' ? raw : String(raw)) as StreamQualityOutcome;
} catch {
return { ok: false, reason: 'réponse illisible' };
if (!best.active) {
await this.click(best.x, best.y);
} else {
// Referme le menu sans rien changer.
await this.pressEscape();
}
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
return { ok: true, label: best.label, already: best.active };
}
/** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */
@@ -548,6 +568,96 @@ export class FirefoxController {
return result === true;
}
/**
* Coordonnées écran du premier élément que `expression` désigne, ou `null`
* s'il est absent. `expression` est une IIFE renvoyant l'élément (pas ses
* coordonnées) — le calcul de rectangle est fait ici, une fois pour toutes.
*/
private async locate(expression: string): Promise<{ x: number; y: number } | null> {
const raw = await this.evaluate(`JSON.stringify((() => {
const el = (${expression});
if (!el) return null;
el.scrollIntoView({ block: 'center', inline: 'center' });
const rect = el.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
})())`);
if (typeof raw !== 'string') return null;
return JSON.parse(raw) as { x: number; y: number } | null;
}
/** État actuel du menu de qualité — ouvert ou non, options et leurs coordonnées. */
private async readTooltip(): Promise<TooltipState> {
// BiDi sérialise un objet renvoyé par la page sous forme de paires
// [clé, valeur], pas comme un objet JS ordinaire : la chaîne JSON évite
// d'avoir à décoder ce format, une chaîne restant une chaîne.
const raw = await this.evaluate(`JSON.stringify((() => {
const tooltip = document.querySelector('.player-resolution-tooltip');
if (!tooltip) return { found: false, options: [] };
const options = [...tooltip.querySelectorAll(
'.player-resolution-tooltip__button--resolution'
)].map((el) => {
const rect = el.getBoundingClientRect();
return {
label: el.getAttribute('data-resolution') || el.textContent.trim(),
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
active: el.classList.contains('player-resolution-tooltip__button--active'),
};
});
// Le menu est ouvert (found) mais peut-être avec un autre balisage que
// celui attendu : ce fragment aide à le repérer sans nouvelle capture
// manuelle depuis les DevTools.
return { found: true, options, debug: options.length === 0 ? tooltip.outerHTML.slice(0, 800) : undefined };
})())`);
if (typeof raw !== 'string') return { found: false, options: [] };
return JSON.parse(raw) as TooltipState;
}
/** Clic souris réel (pas un `.click()` scripté) à des coordonnées de viewport. */
private async click(x: number, y: number): Promise<void> {
await this.send(
'input.performActions',
{
context: this.context,
actions: [
{
type: 'pointer',
id: 'stream-control-pointer',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', x: Math.round(x), y: Math.round(y), origin: 'viewport' },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerUp', button: 0 },
],
},
],
},
5000,
);
// Laisse la page réagir (ouverture animée du menu, p. ex.) avant la suite.
await delay(150);
}
private async pressEscape(): Promise<void> {
await this.send(
'input.performActions',
{
context: this.context,
actions: [
{
type: 'key',
id: 'stream-control-keyboard',
actions: [
{ type: 'keyDown', value: webdriverKey('escape') },
{ type: 'keyUp', value: webdriverKey('escape') },
],
},
],
},
5000,
).catch(() => undefined);
}
private async evaluate(expression: string, userActivation = false): Promise<unknown> {
const response = await this.send<{
type: string;

View File

@@ -432,7 +432,10 @@ async function selectMaxStreamQuality(): Promise<void> {
.catch((err: Error) => ({ ok: false, reason: err.message }));
if (!result.ok) {
report('warn', `Qualité du flux non réglée : ${result.reason}`, 'quality.failed');
// Le diagnostic (menu ouvert mais balisage inattendu) part dans le même
// message : plus vite exploitable que de redemander une capture DevTools.
const suffix = result.debug ? `${result.debug}` : '';
report('warn', `Qualité du flux non réglée : ${result.reason}${suffix}`, 'quality.failed');
return;
}
report(