Feat : Handling idle state
All checks were successful
release / build (push) Successful in 25s
release / verify-windows (push) Successful in 1m14s

This commit is contained in:
jeanotx32
2026-08-11 23:31:26 +02:00
parent 2380196169
commit 242bbf1383
6 changed files with 108 additions and 11 deletions

View File

@@ -239,9 +239,33 @@ aucun autre moyen de connaître.
La rétention est celle du journal global (`LOG_RETENTION`) : les entrées les plus
anciennes sont purgées, toutes VM confondues.
## Statuts Stripchat
Le champ autoritatif est `user.user.status`. Deux familles, deux traitements :
| Statut brut | Signification | Traitement |
| --- | --- | --- |
| `public` | diffusion publique | enregistré |
| `private`, `p2p`, `groupShow`, `virtualPrivate`, `ticketShow` | show payant | pause, sans clôture |
| `idle` | **« revient bientôt »** — connecté mais ne diffuse pas | pause, sans clôture |
| `off`, `notFound`, `deleted` | déconnecté | pause puis [clôture différée](#passage-hors-ligne) |
`idle` se distingue de `off` par `isLive: false` mais `isOnline: true` : le streamer est
toujours là, il s'est simplement absenté. Le flux revient, donc l'enregistrement se met en
pause et attend, exactement comme pendant un show privé — il ne se clôt pas.
Ne pas confondre avec `offlineStatus`, un champ voisin : c'est le message d'absence libre
du modèle (« I'll be back soon »), qui reste renseigné pendant qu'il diffuse. Il ne dit
rien de l'état courant et n'est pas utilisé.
La liste des statuts mis en pause est modifiable par agent, dans « Surveillance du stream ».
Retirer `groupShow` pour continuer à enregistrer les shows de groupe, par exemple. Les
agents créés avant l'ajout d'`idle` sont migrés au démarrage du serveur, sauf si leur liste
a été personnalisée.
## Passage hors-ligne
Quand le statut brut passe à « off » (ou « idle »), deux temps distincts :
Quand le statut brut passe à `off`, deux temps distincts :
1. **Pause immédiate.** L'écran d'attente n'a rien à faire dans le fichier. Aucun délai,
aucune confirmation : reprendre ne coûte rien si la lecture était fausse.

View File

@@ -112,6 +112,45 @@ addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER');
// longtemps. Desactivé par défaut : rien ne doit s'enclencher sans un geste.
addColumnIfMissing('watch_targets', 'auto_record', 'INTEGER NOT NULL DEFAULT 0');
/**
* `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de
* la pause dans les versions antérieures. Les agents créés avant ce changement
* portent la liste figée dans leur JSON et n'hériteraient pas du nouveau défaut.
*
* La migration ne touche que les listes laissées telles quelles : dès qu'un
* opérateur a personnalisé la sienne, elle ne correspond plus et reste intacte.
* Elle est de ce fait idempotente — après un passage, plus aucune ligne ne
* satisfait la condition.
*/
const LEGACY_PRIVATE_STATUSES = ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow'];
function migrateIdleStatus(): void {
const rows = db.prepare('SELECT id, watch_json FROM agents WHERE watch_json IS NOT NULL').all() as
unknown as Array<{ id: string; watch_json: string }>;
const update = db.prepare('UPDATE agents SET watch_json = ? WHERE id = ?');
let migrated = 0;
for (const row of rows) {
const watch = safeJsonParse<WatchSettings>(row.watch_json);
const statuses = watch?.privateStatuses;
if (!Array.isArray(statuses)) continue;
if (statuses.length !== LEGACY_PRIVATE_STATUSES.length) continue;
if (!LEGACY_PRIVATE_STATUSES.every((status) => statuses.includes(status))) continue;
update.run(
JSON.stringify({ ...watch, privateStatuses: [...statuses, 'idle'] }),
row.id,
);
migrated += 1;
}
if (migrated > 0) {
console.log(`Migration : « idle » ajouté aux statuts de pause de ${migrated} agent(s)`);
}
}
migrateIdleStatus();
export interface AgentRow {
id: string;
name: string;

View File

@@ -334,8 +334,12 @@ export interface WatchSettings {
username: string;
pollIntervalMs: number;
/**
* Statuts bruts de l'API considérés comme « pas de flux public ».
* Valeurs observées côté Stripchat : public, private, p2p, groupShow, idle.
* Statuts bruts de l'API considérés comme « pas de flux public, mais le
* streamer est toujours là ». Ils mettent l'enregistrement en pause sans le
* clore : le flux est censé revenir.
*
* Énumération relevée sur l'API Stripchat : `public`, `private`, `p2p`,
* `groupShow`, `virtualPrivate`, `ticketShow`, `idle`, `off`.
*/
privateStatuses: string[];
/**
@@ -417,7 +421,10 @@ export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
provider: 'stripchat',
username: '',
pollIntervalMs: 10_000,
privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow'],
// `idle` est le « revient bientôt » : le modèle est connecté (isOnline) mais ne
// diffuse pas (isLive faux). Il relève de la pause, pas de la clôture — à la
// différence de `off`, qui est une déconnexion franche.
privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow', 'idle'],
confirmations: 2,
pauseOnPrivate: true,
resumeOnPublic: true,
@@ -861,7 +868,13 @@ export function stripchatProfileUrl(username: string): string {
*
* Le champ autoritatif est `user.user.status` sur
* `/api/front/v2/models/username/{pseudo}/cam`. Valeurs relevées en production :
* `public`, `private`, `p2p`, `groupShow`, `idle`.
* `public`, `private`, `p2p`, `groupShow`, `virtualPrivate`, `idle`, `off`.
*
* Deux champs voisins prêtent à confusion et ne doivent pas servir ici :
* - `offlineStatus` est le message d'absence libre du modèle. Il reste
* renseigné pendant qu'il diffuse : il ne dit rien de l'état courant.
* - `isLive` / `isOnline` distinguent `idle` (connecté, ne diffuse pas) de
* `off` (déconnecté), ce que `status` exprime déjà.
*
* Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule
* définition de l'endpoint et du chemin du champ à maintenir.
@@ -928,6 +941,6 @@ export function mapStreamStatus(raw: string | undefined, privateStatuses: string
const value = raw.toLowerCase();
if (privateStatuses.some((status) => status.toLowerCase() === value)) return 'private';
if (value === 'public') return 'public';
// idle / off / offline / deleted : le modèle ne diffuse pas.
// off / offline / deleted / notFound : le modèle n'est plus là du tout.
return 'offline';
}

View File

@@ -218,7 +218,11 @@ function WatchStrip({
onCheck: () => void;
onFullscreen: () => void;
}) {
const info = WATCH_LABELS[watch.state];
// Même nuance que sur la page Streamers : « revient bientôt » n'est pas un show privé.
const info =
watch.state === 'private' && watch.rawStatus === 'idle'
? { text: 'revient bientôt', tone: 'warn' }
: WATCH_LABELS[watch.state];
const stale = watch.lastCheckedAt > 0 && Date.now() - watch.lastCheckedAt > 60_000;
return (

View File

@@ -508,8 +508,11 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
}
/>
<span className="muted small">
Statuts renvoyés par Stripchat : public, private, p2p, groupShow, idle. Retire
groupShow si tu veux continuer à enregistrer les shows de groupe.
Statuts renvoyés par Stripchat : public, private, p2p, groupShow,
virtualPrivate, ticketShow, idle, off. Ceux listés ici mettent en pause sans
clore. <strong>idle</strong> est le « revient bientôt » : le streamer est
connecté mais ne diffuse pas. Retire groupShow pour continuer à enregistrer
les shows de groupe.
</span>
</label>

View File

@@ -10,6 +10,18 @@ const STATE_LABELS: Record<StreamState, { text: string; tone: string }> = {
unknown: { text: '…', tone: 'offline' },
};
/**
* `idle` et `private` partagent le même état normalisé — les deux mettent
* l'enregistrement en pause sans le clore — mais ne veulent pas dire la même
* chose à l'écran : le premier est une absence, le second un show payant.
*/
function stateLabel(target: WatchTarget): { text: string; tone: string } {
if (target.state === 'private' && target.rawStatus === 'idle') {
return { text: 'revient bientôt', tone: 'warn' };
}
return STATE_LABELS[target.state];
}
interface Props {
targets: WatchTarget[];
agents: AgentView[];
@@ -114,7 +126,7 @@ function StreamerCard({
const [busy, setBusy] = useState(false);
const [avatarBroken, setAvatarBroken] = useState(false);
const state = STATE_LABELS[target.state];
const state = stateLabel(target);
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const recording = agent?.status.recording ?? false;
const isLive = target.state === 'public';
@@ -171,7 +183,9 @@ function StreamerCard({
<span className="muted small">@{target.username}</span>
</div>
<span className={`badge ${state.tone}`}>{state.text}</span>
<span className={`badge ${state.tone}`} title={`statut brut « ${target.rawStatus ?? '?'} »`}>
{state.text}
</span>
</header>
<dl className="streamer-facts">