Fix : quality selection
This commit is contained in:
17
README.md
17
README.md
@@ -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
|
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
|
(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
|
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
|
1. repère le bouton (`.player-resolution`) et clique dessus,
|
||||||
document.querySelector('.player-resolution').click();
|
2. attend le menu, puis clique l'option marquée « (Source) », ou à défaut celle dont la
|
||||||
// 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.
|
||||||
// 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
|
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
|
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
|
Couplé à la structure actuelle du lecteur Stripchat — deux classes CSS
|
||||||
(`.player-resolution`, `.player-resolution-tooltip__button--resolution`) relevées en
|
(`.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
|
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
|
#### Lancement simple
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ export interface StreamQualityOutcome {
|
|||||||
already?: boolean;
|
already?: boolean;
|
||||||
/** Raison de l'échec, présente seulement si `!ok`. */
|
/** Raison de l'échec, présente seulement si `!ok`. */
|
||||||
reason?: string;
|
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
|
* Stripchat ne démarre pas toujours sur son maximum : la valeur active au
|
||||||
* chargement varie, et chaque nouvelle page (donc chaque nouveau stream
|
* 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
|
* ouvert) repart du choix du site, pas du précédent.
|
||||||
* 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
|
* Le clic part par `input.performActions` (un vrai évènement souris, comme
|
||||||
* d'appels synchronisés par des délais fixes, plus fragile face à la
|
* la touche plein écran plus haut), pas par un `.click()` scripté dans la
|
||||||
* variabilité du temps de rendu.
|
* 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`,
|
* Couplé à la structure actuelle du lecteur (`.player-resolution`,
|
||||||
* `.player-resolution-tooltip__button--resolution`) : sans effet, mais sans
|
* `.player-resolution-tooltip__button--resolution`) : sans effet, mais sans
|
||||||
@@ -187,63 +209,61 @@ export class FirefoxController {
|
|||||||
async selectMaxStreamQuality(): Promise<StreamQualityOutcome> {
|
async selectMaxStreamQuality(): Promise<StreamQualityOutcome> {
|
||||||
await this.ensureReady();
|
await this.ensureReady();
|
||||||
|
|
||||||
const expression = `(async () => {
|
// Sans activation de l'onglet, le clic partirait vers une page en
|
||||||
const button = document.querySelector('.player-resolution');
|
// arrière-plan, dont le lecteur ignore les évènements souris.
|
||||||
if (!button) return JSON.stringify({ ok: false, reason: 'bouton qualité introuvable' });
|
await this.send('browsingContext.activate', { context: this.context }, 5000).catch(() =>
|
||||||
button.click();
|
undefined,
|
||||||
|
|
||||||
const deadline = Date.now() + 3000;
|
|
||||||
let options = [];
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
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
|
|
||||||
// compare les hauteurs réelles plutôt que de supposer un ordre de liste
|
|
||||||
// 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 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();
|
|
||||||
|
|
||||||
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 {
|
const buttonPoint = await this.locate("document.querySelector('.player-resolution')").catch(
|
||||||
return JSON.parse(typeof raw === 'string' ? raw : String(raw)) as StreamQualityOutcome;
|
() => null,
|
||||||
} catch {
|
);
|
||||||
return { ok: false, reason: 'réponse illisible' };
|
|
||||||
|
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 tooltip: TooltipState | null = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// « (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
|
||||||
|
// compare les hauteurs réelles plutôt que de supposer un ordre de liste
|
||||||
|
// 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 = (label: string) => {
|
||||||
|
const match = /(\d+)/.exec(label);
|
||||||
|
return match?.[1] ? parseInt(match[1], 10) : -1;
|
||||||
|
};
|
||||||
|
const best =
|
||||||
|
tooltip.options.find((option) => /source/i.test(option.label)) ||
|
||||||
|
tooltip.options.reduce((a, b) => (height(b.label) > height(a.label) ? b : a));
|
||||||
|
|
||||||
|
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. */
|
/** 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;
|
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> {
|
private async evaluate(expression: string, userActivation = false): Promise<unknown> {
|
||||||
const response = await this.send<{
|
const response = await this.send<{
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -432,7 +432,10 @@ async function selectMaxStreamQuality(): Promise<void> {
|
|||||||
.catch((err: Error) => ({ ok: false, reason: err.message }));
|
.catch((err: Error) => ({ ok: false, reason: err.message }));
|
||||||
|
|
||||||
if (!result.ok) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
report(
|
report(
|
||||||
|
|||||||
Reference in New Issue
Block a user