Feat : Streamer watch list
Some checks failed
release / build (push) Successful in 28s
release / verify-windows (push) Failing after 1m13s

This commit is contained in:
jeanotx32
2026-08-11 19:24:31 +02:00
parent b85a535dd8
commit e87afc49c0
13 changed files with 936 additions and 48 deletions

View File

@@ -330,11 +330,38 @@ export interface LogEntry {
ts: number;
}
/**
* Profil surveillé par le serveur : on colle l'URL d'un streamer, le serveur
* sonde son statut et signale les passages en direct.
*/
export interface WatchTarget {
id: string;
provider: WatchProvider;
username: string;
/** Nom lisible, à défaut le pseudo. */
label: string | null;
url: string;
/** Agent qui enregistrera ce streamer, s'il est assigné. */
agentId: string | null;
notify: boolean;
state: StreamState;
rawStatus: string | null;
/** Depuis quand l'état est stable. */
stateSince: number;
lastCheckedAt: number | null;
lastError: string | null;
createdAt: number;
}
export type ServerToDashboard =
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[] }
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] }
| { type: 'agent'; agent: AgentView }
| { type: 'agent.removed'; agentId: string }
| { type: 'log'; entry: LogEntry };
| { type: 'log'; entry: LogEntry }
| { type: 'target'; target: WatchTarget }
| { type: 'target.removed'; targetId: string }
/** Transition vers le direct : c'est ce qui déclenche la notification. */
| { type: 'target.live'; target: WatchTarget };
// ---------------------------------------------------------------------------
// Helpers
@@ -401,6 +428,77 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings {
};
}
/**
* Extrait le pseudo d'un profil Stripchat à partir d'une URL complète, d'une URL
* sans schéma, ou d'un pseudo saisi seul.
*/
export function parseStripchatUsername(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
const looksLikeUrl = trimmed.includes('/') || trimmed.includes('.');
if (looksLikeUrl) {
try {
const url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
if (!/(^|\.)stripchat\.com$/i.test(url.hostname)) return null;
const first = url.pathname.split('/').filter(Boolean)[0];
return first && USERNAME_PATTERN.test(first) ? first : null;
} catch {
return null;
}
}
return USERNAME_PATTERN.test(trimmed) ? trimmed : null;
}
const USERNAME_PATTERN = /^[A-Za-z0-9_.-]{2,64}$/;
export function stripchatProfileUrl(username: string): string {
return `https://fr.stripchat.com/${username}`;
}
/**
* Interroge le statut d'un modèle Stripchat.
*
* 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`.
*
* Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule
* définition de l'endpoint et du chemin du champ à maintenir.
*/
export async function fetchStripchatStatus(
username: string,
privateStatuses: string[],
timeoutMs = 8000,
): Promise<{ raw: string; state: StreamState }> {
const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent(
username,
)}/cam`;
const response = await fetch(url, {
headers: {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
accept: 'application/json',
},
signal: AbortSignal.timeout(timeoutMs),
});
if (response.status === 404) return { raw: 'notFound', state: 'offline' };
if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`);
const payload = (await response.json()) as {
user?: { user?: { status?: string } };
};
const raw = payload?.user?.user?.status;
if (typeof raw !== 'string') {
throw new Error('Réponse Stripchat inattendue : user.user.status absent');
}
return { raw, state: mapStreamStatus(raw, privateStatuses) };
}
/** Traduit un statut brut de l'API en état normalisé. */
export function mapStreamStatus(raw: string | undefined, privateStatuses: string[]): StreamState {
if (!raw) return 'unknown';