From e87afc49c0f6dd3e617c4af584a3ee9d9f1afa69 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 11 Aug 2026 19:24:31 +0200 Subject: [PATCH] Feat : Streamer watch list --- packages/agent/src/watcher.ts | 42 +--- packages/server/src/api.ts | 155 +++++++++++++- packages/server/src/config.ts | 3 + packages/server/src/db.ts | 153 ++++++++++++++ packages/server/src/hub.ts | 19 +- packages/server/src/index.ts | 3 + packages/server/src/watchlist.ts | 115 ++++++++++ packages/shared/src/index.ts | 102 ++++++++- packages/web/src/App.tsx | 51 ++++- packages/web/src/api.ts | 28 ++- .../web/src/components/WatchlistPanel.tsx | 200 ++++++++++++++++++ packages/web/src/styles.css | 79 +++++++ packages/web/src/useRealtime.ts | 34 ++- 13 files changed, 936 insertions(+), 48 deletions(-) create mode 100644 packages/server/src/watchlist.ts create mode 100644 packages/web/src/components/WatchlistPanel.tsx diff --git a/packages/agent/src/watcher.ts b/packages/agent/src/watcher.ts index 15bebb0..3f43cbc 100644 --- a/packages/agent/src/watcher.ts +++ b/packages/agent/src/watcher.ts @@ -1,12 +1,8 @@ import { EventEmitter } from 'node:events'; import type { LogLevel, StreamState, WatchSettings, WatchState } from '@stream-control/shared'; -import { emptyWatchState, mapStreamStatus } from '@stream-control/shared'; +import { emptyWatchState, fetchStripchatStatus } from '@stream-control/shared'; import { sendHotkey } from './hotkey.ts'; -const PROBE_TIMEOUT_MS = 8000; -const BROWSER_UA = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; - export interface ProbeResult { /** Statut brut renvoyé par la plateforme. */ raw: string; @@ -14,40 +10,10 @@ export interface ProbeResult { } /** - * Sonde 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`. - * (`cam.privateMode` existe mais reste vide y compris pendant un privé : ne pas - * s'en servir.) + * Sonde Stripchat. L'implémentation vit dans le paquet partagé : le serveur s'en + * sert aussi pour la veille, et l'endpoint ne doit être défini qu'à un endroit. */ -export async function probeStripchat( - username: string, - privateStatuses: string[], -): Promise { - const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent( - username, - )}/cam`; - - const response = await fetch(url, { - headers: { 'user-agent': BROWSER_UA, accept: 'application/json' }, - signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), - }); - - 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; isLive?: boolean } }; - }; - 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) }; -} +export const probeStripchat = fetchStripchatStatus; /** Ce que le surveillant doit pouvoir demander à OBS. */ export interface WatchActions { diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 11fc583..d274301 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -6,11 +6,13 @@ import { DEFAULT_OBS_SETTINGS, isAgentAction, normalizeWatchSettings, + parseStripchatUsername, } from '@stream-control/shared'; import { config } from './config.ts'; import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts'; -import { agentsRepo, logsRepo } from './db.ts'; +import { agentsRepo, logsRepo, targetsRepo } from './db.ts'; import { hub } from './hub.ts'; +import { watchlist } from './watchlist.ts'; export const api: Router = Router(); @@ -188,6 +190,157 @@ api.post('/commands/bulk', async (req, res) => { res.json({ results }); }); +// --- Veille : profils surveillés --------------------------------------------- + +api.get('/watchlist', (_req, res) => { + res.json({ targets: targetsRepo.list() }); +}); + +/** Accepte une URL de profil complète ou un simple pseudo. */ +api.post('/watchlist', (req, res) => { + const input = typeof req.body?.url === 'string' ? req.body.url : ''; + const username = parseStripchatUsername(input); + if (!username) { + res.status(400).json({ + error: 'Lien ou pseudo Stripchat non reconnu (ex. https://fr.stripchat.com/pseudo)', + }); + return; + } + + const existing = targetsRepo.findByUsername('stripchat', username); + if (existing) { + res.status(409).json({ error: `« ${username} » est déjà surveillé`, target: existing }); + return; + } + + const target = targetsRepo.create({ + id: randomUUID(), + username, + label: typeof req.body?.label === 'string' && req.body.label.trim() ? req.body.label.trim() : null, + agentId: typeof req.body?.agentId === 'string' ? req.body.agentId : null, + notify: req.body?.notify !== false, + }); + + hub.log(null, 'info', `Veille : « ${username} » ajouté`); + hub.publishTarget(target); + res.status(201).json({ target }); + + // Premier verdict sans attendre le prochain cycle. + void watchlist.checkOne(target.id); +}); + +api.patch('/watchlist/:id', (req, res) => { + const target = targetsRepo.get(req.params.id); + if (!target) { + res.status(404).json({ error: 'Profil surveillé introuvable' }); + return; + } + + targetsRepo.updateSettings(target.id, { + label: typeof req.body?.label === 'string' ? req.body.label.trim() || null : target.label, + agentId: + req.body?.agentId === null || typeof req.body?.agentId === 'string' + ? req.body.agentId + : target.agentId, + notify: typeof req.body?.notify === 'boolean' ? req.body.notify : target.notify, + }); + + const updated = targetsRepo.get(target.id); + if (!updated) { + res.status(500).json({ error: 'Mise à jour impossible' }); + return; + } + hub.publishTarget(updated); + res.json({ target: updated }); +}); + +api.delete('/watchlist/:id', (req, res) => { + const target = targetsRepo.get(req.params.id); + if (!target) { + res.status(404).json({ error: 'Profil surveillé introuvable' }); + return; + } + targetsRepo.remove(target.id); + hub.publishTargetRemoval(target.id); + hub.log(null, 'info', `Veille : « ${target.username} » retiré`); + res.json({ ok: true }); +}); + +api.post('/watchlist/:id/check', async (req, res) => { + const target = await watchlist.checkOne(req.params.id); + if (!target) { + res.status(404).json({ error: 'Profil surveillé introuvable' }); + return; + } + res.json({ target }); +}); + +/** + * Lance l'enregistrement du profil sur l'agent qui lui est assigné. + * + * Configure au passage la surveillance de l'agent sur ce pseudo : l'agent mettra + * l'enregistrement en pause pendant les shows privés sans réglage supplémentaire. + */ +api.post('/watchlist/:id/record', async (req, res) => { + const target = targetsRepo.get(req.params.id); + if (!target) { + res.status(404).json({ error: 'Profil surveillé introuvable' }); + return; + } + if (!target.agentId) { + res.status(400).json({ error: 'Aucun agent assigné à ce profil' }); + return; + } + + const record = agentsRepo.get(target.agentId); + if (!record) { + res.status(404).json({ error: "L'agent assigné n'existe plus" }); + return; + } + + agentsRepo.updateSettings(record.id, { + name: record.name, + obs: record.obs, + autoConnectObs: record.autoConnectObs, + watch: normalizeWatchSettings({ ...record.watch, enabled: true, username: target.username }), + notes: record.notes, + }); + + const configured = agentsRepo.get(record.id); + if (configured) { + hub.pushConfig(configured); + hub.publishAgent(configured.id); + } + + try { + await hub.sendCommand(record.id, 'record.start'); + hub.log( + record.id, + 'info', + `Enregistrement de « ${target.username} » démarré depuis la veille`, + ); + res.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + hub.log(record.id, 'error', `Démarrage de « ${target.username} » en échec : ${message}`); + res.status(502).json({ ok: false, error: message }); + } +}); + +api.post('/watchlist/:id/stop', async (req, res) => { + const target = targetsRepo.get(req.params.id); + if (!target?.agentId) { + res.status(400).json({ error: 'Aucun agent assigné à ce profil' }); + return; + } + try { + await hub.sendCommand(target.agentId, 'record.stop'); + res.json({ ok: true }); + } catch (err) { + res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } +}); + // --- Divers ----------------------------------------------------------------- api.get('/logs', (req, res) => { diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 9e716f1..7888829 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -45,6 +45,9 @@ export const config = { dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'), statusIntervalMs: int('STATUS_INTERVAL_MS', 2000), + + /** Fréquence de sondage des profils surveillés (veille). */ + watchlistIntervalMs: int('WATCHLIST_INTERVAL_MS', 30_000), agentTimeoutMs: int('AGENT_TIMEOUT_MS', 15_000), commandTimeoutMs: int('COMMAND_TIMEOUT_MS', 15_000), diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index a9fea40..431081c 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -6,13 +6,16 @@ import type { LogLevel, ObsSettings, Platform, + StreamState, WatchSettings, + WatchTarget, } from '@stream-control/shared'; import { DEFAULT_OBS_SETTINGS, DEFAULT_WATCH_SETTINGS, normalizeWatchSettings, safeJsonParse, + stripchatProfileUrl, } from '@stream-control/shared'; import { config } from './config.ts'; @@ -49,6 +52,26 @@ db.exec(` ); CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts DESC); + + -- Profils surveillés : le serveur sonde leur statut et signale les passages + -- en direct. Indépendant des agents : on peut veiller sans rien enregistrer. + CREATE TABLE IF NOT EXISTS watch_targets ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL DEFAULT 'stripchat', + username TEXT NOT NULL, + label TEXT, + agent_id TEXT, + notify INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL DEFAULT 'unknown', + raw_status TEXT, + state_since INTEGER NOT NULL, + last_checked_at INTEGER, + last_error TEXT, + created_at INTEGER NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity + ON watch_targets (provider, username); `); /** Migrations additives : sûres à rejouer sur une base déjà peuplée. */ @@ -249,6 +272,136 @@ export const agentsRepo = { }, }; +// --- Profils surveillés ------------------------------------------------------ + +interface TargetRow { + id: string; + provider: string; + username: string; + label: string | null; + agent_id: string | null; + notify: number; + state: string; + raw_status: string | null; + state_since: number; + last_checked_at: number | null; + last_error: string | null; + created_at: number; +} + +function toTarget(row: TargetRow): WatchTarget { + return { + id: row.id, + provider: (row.provider as WatchTarget['provider']) ?? 'stripchat', + username: row.username, + label: row.label, + url: stripchatProfileUrl(row.username), + agentId: row.agent_id, + notify: Number(row.notify) === 1, + state: (row.state as StreamState) ?? 'unknown', + rawStatus: row.raw_status, + stateSince: Number(row.state_since), + lastCheckedAt: row.last_checked_at === null ? null : Number(row.last_checked_at), + lastError: row.last_error, + createdAt: Number(row.created_at), + }; +} + +const targetStmts = { + list: db.prepare('SELECT * FROM watch_targets ORDER BY username COLLATE NOCASE'), + get: db.prepare('SELECT * FROM watch_targets WHERE id = ?'), + getByName: db.prepare('SELECT * FROM watch_targets WHERE provider = ? AND username = ?'), + insert: db.prepare(` + INSERT INTO watch_targets (id, provider, username, label, agent_id, notify, + state, state_since, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'unknown', ?, ?) + `), + updateSettings: db.prepare( + 'UPDATE watch_targets SET label = ?, agent_id = ?, notify = ? WHERE id = ?', + ), + updateState: db.prepare(` + UPDATE watch_targets + SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ? + WHERE id = ? + `), + remove: db.prepare('DELETE FROM watch_targets WHERE id = ?'), +}; + +export const targetsRepo = { + list(): WatchTarget[] { + return (targetStmts.list.all() as unknown as TargetRow[]).map(toTarget); + }, + + get(id: string): WatchTarget | null { + const row = targetStmts.get.get(id) as unknown as TargetRow | undefined; + return row ? toTarget(row) : null; + }, + + findByUsername(provider: string, username: string): WatchTarget | null { + const row = targetStmts.getByName.get(provider, username) as unknown as TargetRow | undefined; + return row ? toTarget(row) : null; + }, + + create(input: { + id: string; + username: string; + provider?: string; + label?: string | null; + agentId?: string | null; + notify?: boolean; + }): WatchTarget { + const now = Date.now(); + targetStmts.insert.run( + input.id, + input.provider ?? 'stripchat', + input.username, + input.label ?? null, + input.agentId ?? null, + input.notify === false ? 0 : 1, + now, + now, + ); + const created = targetsRepo.get(input.id); + if (!created) throw new Error(`Échec de création du profil surveillé ${input.username}`); + return created; + }, + + updateSettings( + id: string, + settings: { label: string | null; agentId: string | null; notify: boolean }, + ): void { + targetStmts.updateSettings.run( + settings.label, + settings.agentId, + settings.notify ? 1 : 0, + id, + ); + }, + + updateState( + id: string, + state: { + state: StreamState; + rawStatus: string | null; + stateSince: number; + lastError: string | null; + }, + ): void { + targetStmts.updateState.run( + state.state, + state.rawStatus, + state.stateSince, + Date.now(), + state.lastError, + id, + ); + }, + + remove(id: string): void { + targetStmts.remove.run(id); + }, +}; + interface LogRow { id: number; agent_id: string | null; diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts index e0ad7e1..5fbed5e 100644 --- a/packages/server/src/hub.ts +++ b/packages/server/src/hub.ts @@ -8,10 +8,11 @@ import type { LogLevel, ServerToAgent, ServerToDashboard, + WatchTarget, } from '@stream-control/shared'; import { emptyStatus } from '@stream-control/shared'; import { config } from './config.ts'; -import { agentsRepo, logsRepo, type AgentRecord } from './db.ts'; +import { agentsRepo, logsRepo, targetsRepo, type AgentRecord } from './db.ts'; interface PendingCommand { resolve: (value: unknown) => void; @@ -190,6 +191,7 @@ class Hub { type: 'snapshot', agents: this.views(), logs: logsRepo.recent(200), + targets: targetsRepo.list(), }); } @@ -211,6 +213,21 @@ class Hub { this.broadcast({ type: 'agent.removed', agentId }); } + // --- Profils surveillés ------------------------------------------------- + + publishTarget(target: WatchTarget): void { + this.broadcast({ type: 'target', target }); + } + + publishTargetRemoval(targetId: string): void { + this.broadcast({ type: 'target.removed', targetId }); + } + + /** Passage en direct : le dashboard en fait une notification. */ + publishTargetLive(target: WatchTarget): void { + this.broadcast({ type: 'target.live', target }); + } + /** Journalise un évènement : persistance + diffusion temps réel. */ log(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry { const entry = logsRepo.append(agentId, level, message, ts); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8140478..2ae915a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,7 @@ import { verifySession } from './auth.ts'; import { api } from './api.ts'; import { hub } from './hub.ts'; import { authenticateAgent, handleAgentConnection } from './agentGateway.ts'; +import { watchlist } from './watchlist.ts'; const app = express(); app.disable('x-powered-by'); @@ -91,6 +92,8 @@ const heartbeat = setInterval(() => { }, 5000); heartbeat.unref(); +watchlist.start(); + server.listen(config.port, config.host, () => { console.log(`stream-control · http://${config.host}:${config.port}`); console.log(` agents → ws://${config.host}:${config.port}/ws/agent`); diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts new file mode 100644 index 0000000..4fce8f7 --- /dev/null +++ b/packages/server/src/watchlist.ts @@ -0,0 +1,115 @@ +import type { StreamState, WatchTarget } from '@stream-control/shared'; +import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/shared'; +import { config } from './config.ts'; +import { targetsRepo } from './db.ts'; +import { hub } from './hub.ts'; + +/** + * Sonde périodiquement les profils surveillés et signale les passages en direct. + * + * Côté serveur et non côté agent : un profil se surveille indépendamment de + * toute machine d'enregistrement, et une seule requête suffit quel que soit le + * nombre d'agents. + */ +class Watchlist { + private timer: NodeJS.Timeout | null = null; + private running = false; + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => void this.tick(), config.watchlistIntervalMs); + this.timer.unref?.(); + void this.tick(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + /** Sonde immédiate d'un seul profil, déclenchée depuis le dashboard. */ + async checkOne(id: string): Promise { + const target = targetsRepo.get(id); + if (!target) return null; + await this.probe(target); + return targetsRepo.get(id); + } + + private async tick(): Promise { + if (this.running) return; // un cycle lent ne doit pas s'empiler sur le suivant + this.running = true; + try { + const targets = targetsRepo.list(); + // Séquentiel et espacé : une poignée de profils ne justifie pas de + // marteler l'API en parallèle. + for (const target of targets) { + await this.probe(target); + await delay(250); + } + } finally { + this.running = false; + } + } + + private async probe(target: WatchTarget): Promise { + let next: StreamState; + let raw: string | null = null; + let error: string | null = null; + + try { + const result = await fetchStripchatStatus( + target.username, + DEFAULT_WATCH_SETTINGS.privateStatuses, + ); + next = result.state; + raw = result.raw; + } catch (err) { + // Une sonde en échec ne change pas l'état connu : on garde le dernier + // verdict fiable plutôt que d'annoncer un faux passage hors-ligne. + error = err instanceof Error ? err.message : String(err); + next = target.state; + } + + const changed = next !== target.state; + targetsRepo.updateState(target.id, { + state: next, + rawStatus: raw ?? target.rawStatus, + stateSince: changed ? Date.now() : target.stateSince, + lastError: error, + }); + + const updated = targetsRepo.get(target.id); + if (!updated) return; + + hub.publishTarget(updated); + + if (changed) { + const name = updated.label ?? updated.username; + hub.log(null, 'info', `Veille : ${name} est passé « ${labelOf(next)} »`); + + // Seul le passage effectif au flux public déclenche une notification. + if (next === 'public' && target.state !== 'public' && updated.notify) { + hub.publishTargetLive(updated); + } + } + } +} + +function labelOf(state: StreamState): string { + switch (state) { + case 'public': + return 'en direct'; + case 'private': + return 'en show privé'; + case 'offline': + return 'hors-ligne'; + default: + return 'inconnu'; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const watchlist = new Watchlist(); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5ed289f..a9410a4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index a020937..0cd0b91 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { AgentAction, AgentView } from '@stream-control/shared'; +import type { AgentAction, AgentView, WatchTarget } from '@stream-control/shared'; import { api, getToken } from './api'; import { useRealtime } from './useRealtime'; import { Login } from './components/Login'; import { AgentCard } from './components/AgentCard'; import { AgentSettings } from './components/AgentSettings'; import { LogPanel } from './components/LogPanel'; +import { WatchlistPanel } from './components/WatchlistPanel'; interface Toast { message: string; @@ -19,13 +20,51 @@ export function App() { const [toast, setToast] = useState(null); const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null); + const [notificationsEnabled, setNotificationsEnabled] = useState( + () => typeof Notification !== 'undefined' && Notification.permission === 'granted', + ); + const onUnauthorized = useCallback(() => setAuthenticated(false), []); - const { agents, logs, connected } = useRealtime(authenticated, onUnauthorized); const notify = useCallback((message: string, tone: 'info' | 'error' = 'info') => { setToast({ message, tone }); }, []); + /** Un profil surveillé vient de passer en direct. */ + const onLive = useCallback((target: WatchTarget) => { + const name = target.label ?? target.username; + setToast({ message: `${name} est en direct`, tone: 'info' }); + + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + const notification = new Notification(`${name} est en direct`, { + body: 'Ouvre le dashboard pour lancer l\'enregistrement.', + // Un même streamer ne doit pas empiler les notifications. + tag: `stream-control-${target.id}`, + }); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + } + }, []); + + const { agents, logs, targets, connected } = useRealtime(authenticated, onUnauthorized, onLive); + + const enableNotifications = useCallback(async () => { + if (typeof Notification === 'undefined') { + notify('Ce navigateur ne gère pas les notifications', 'error'); + return; + } + const permission = await Notification.requestPermission(); + setNotificationsEnabled(permission === 'granted'); + notify( + permission === 'granted' + ? 'Notifications activées' + : 'Notifications refusées — à réautoriser dans les préférences du site', + permission === 'granted' ? 'info' : 'error', + ); + }, [notify]); + useEffect(() => { if (!toast) return; const timer = window.setTimeout(() => setToast(null), 4000); @@ -150,6 +189,14 @@ export function App() { )} + void enableNotifications()} + /> +
{agents.length === 0 && (
diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index d7b97e2..3e6ba9f 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -1,4 +1,4 @@ -import type { AgentAction, AgentView, LogEntry } from '@stream-control/shared'; +import type { AgentAction, AgentView, LogEntry, WatchTarget } from '@stream-control/shared'; const TOKEN_KEY = 'stream-control.session'; @@ -97,6 +97,32 @@ export const api = { enrollment: () => request<{ enabled: boolean; token: string | null; serverUrl: string }>('/enrollment'), + + // --- Veille --------------------------------------------------------------- + + watchlist: () => request<{ targets: WatchTarget[] }>('/watchlist'), + + addTarget: (url: string, agentId: string | null) => + request<{ target: WatchTarget }>('/watchlist', { + method: 'POST', + body: JSON.stringify({ url, agentId }), + }), + + updateTarget: (id: string, body: Partial>) => + request<{ target: WatchTarget }>(`/watchlist/${id}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + + removeTarget: (id: string) => request<{ ok: true }>(`/watchlist/${id}`, { method: 'DELETE' }), + + checkTarget: (id: string) => + request<{ target: WatchTarget }>(`/watchlist/${id}/check`, { method: 'POST' }), + + recordTarget: (id: string) => + request<{ ok: boolean }>(`/watchlist/${id}/record`, { method: 'POST' }), + + stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { method: 'POST' }), }; /** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */ diff --git a/packages/web/src/components/WatchlistPanel.tsx b/packages/web/src/components/WatchlistPanel.tsx new file mode 100644 index 0000000..9f01ba7 --- /dev/null +++ b/packages/web/src/components/WatchlistPanel.tsx @@ -0,0 +1,200 @@ +import { useState, type FormEvent } from 'react'; +import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared'; +import { api } from '../api'; +import { formatRelative } from '../format'; + +const STATE_LABELS: Record = { + public: { text: 'en direct', tone: 'rec' }, + private: { text: 'show privé', tone: 'warn' }, + offline: { text: 'hors-ligne', tone: 'offline' }, + unknown: { text: '…', tone: 'offline' }, +}; + +interface Props { + targets: WatchTarget[]; + agents: AgentView[]; + notify: (message: string, tone?: 'info' | 'error') => void; + notificationsEnabled: boolean; + onEnableNotifications: () => void; +} + +export function WatchlistPanel({ + targets, + agents, + notify, + notificationsEnabled, + onEnableNotifications, +}: Props) { + const [url, setUrl] = useState(''); + const [busy, setBusy] = useState(false); + + async function add(event: FormEvent) { + event.preventDefault(); + if (!url.trim()) return; + setBusy(true); + try { + const { target } = await api.addTarget(url.trim(), null); + notify(`« ${target.username} » ajouté à la veille`); + setUrl(''); + } catch (err) { + notify(err instanceof Error ? err.message : 'Ajout impossible', 'error'); + } finally { + setBusy(false); + } + } + + const live = targets.filter((target) => target.state === 'public').length; + + return ( +
+
+

Veille

+ + {targets.length} profil(s) · {live} en direct + +
+ {!notificationsEnabled && ( + + )} +
+ +
+ setUrl(event.target.value)} + placeholder="https://fr.stripchat.com/pseudo — ou simplement le pseudo" + aria-label="Lien du profil à surveiller" + /> + +
+ + {targets.length === 0 ? ( +

+ Colle le lien d'un profil : tu seras prévenu dès qu'il passe en direct, et tu + pourras lancer l'enregistrement d'un clic. +

+ ) : ( +
+ {targets.map((target) => ( + + ))} +
+ )} +
+ ); +} + +function TargetRow({ + target, + agents, + notify, +}: { + target: WatchTarget; + agents: AgentView[]; + notify: (message: string, tone?: 'info' | 'error') => void; +}) { + const [busy, setBusy] = useState(false); + const state = STATE_LABELS[target.state]; + const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null; + const recording = agent?.status.recording ?? false; + + async function run(action: () => Promise, success?: string) { + setBusy(true); + try { + await action(); + if (success) notify(success); + } catch (err) { + notify(err instanceof Error ? err.message : 'Action impossible', 'error'); + } finally { + setBusy(false); + } + } + + return ( +
+ {state.text} + + + {target.label ?? target.username} + + + + + + {target.lastError + ? `sonde en échec : ${target.lastError}` + : target.lastCheckedAt + ? `sondé ${formatRelative(target.lastCheckedAt)}` + : 'pas encore sondé'} + + +
+ {recording ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 2f3f096..deeead3 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -401,6 +401,85 @@ fieldset.group > legend { white-space: nowrap; } +/* --- Veille --- */ + +.watchlist { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 18px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +.watchlist-head { + display: flex; + align-items: center; + gap: 10px; +} +.watchlist-add { + max-width: 620px; +} + +.target-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.target { + display: grid; + grid-template-columns: 96px minmax(120px, 1fr) 160px minmax(0, 1.2fr) auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + background: var(--panel-2); + border-left: 3px solid transparent; + border-radius: 8px; +} +.target.tone-rec { + border-left-color: var(--rec); +} +.target.tone-warn { + border-left-color: var(--warn); +} + +.target-name { + color: var(--text); + font-weight: 600; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.target-name:hover { + text-decoration: underline; +} +.target-agent { + width: 100%; +} +.target-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.target-actions { + display: flex; + gap: 6px; + align-items: center; +} + +@media (max-width: 900px) { + .target { + grid-template-columns: 96px 1fr; + row-gap: 6px; + } + .target-meta { + grid-column: 1 / -1; + } + .target-actions { + grid-column: 1 / -1; + } +} + /* --- Journal --- */ .logs { diff --git a/packages/web/src/useRealtime.ts b/packages/web/src/useRealtime.ts index cc985ce..66f6505 100644 --- a/packages/web/src/useRealtime.ts +++ b/packages/web/src/useRealtime.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import type { AgentView, LogEntry, ServerToDashboard } from '@stream-control/shared'; +import type { AgentView, LogEntry, ServerToDashboard, WatchTarget } from '@stream-control/shared'; import { dashboardSocketUrl } from './api'; const MAX_LOGS = 400; @@ -7,6 +7,7 @@ const MAX_LOGS = 400; export interface RealtimeState { agents: AgentView[]; logs: LogEntry[]; + targets: WatchTarget[]; connected: boolean; } @@ -14,16 +15,27 @@ export interface RealtimeState { * Maintient une connexion au flux `/ws/dashboard` avec reconnexion automatique * et applique les mises à jour incrémentales d'agents et de journal. */ -export function useRealtime(enabled: boolean, onUnauthorized: () => void): RealtimeState { +export function useRealtime( + enabled: boolean, + onUnauthorized: () => void, + onLive?: (target: WatchTarget) => void, +): RealtimeState { const [agents, setAgents] = useState([]); const [logs, setLogs] = useState([]); + const [targets, setTargets] = useState([]); const [connected, setConnected] = useState(false); const retryRef = useRef(1000); + // Gardé dans une ref : la connexion ne doit pas être relancée à chaque + // nouvelle identité de callback. + const onLiveRef = useRef(onLive); + onLiveRef.current = onLive; + useEffect(() => { if (!enabled) { setAgents([]); setLogs([]); + setTargets([]); setConnected(false); return; } @@ -46,6 +58,22 @@ export function useRealtime(enabled: boolean, onUnauthorized: () => void): Realt case 'snapshot': setAgents(message.agents); setLogs(message.logs); + setTargets(message.targets); + break; + case 'target': + setTargets((current) => { + const index = current.findIndex((target) => target.id === message.target.id); + if (index === -1) return [...current, message.target]; + const next = [...current]; + next[index] = message.target; + return next; + }); + break; + case 'target.removed': + setTargets((current) => current.filter((target) => target.id !== message.targetId)); + break; + case 'target.live': + onLiveRef.current?.(message.target); break; case 'agent': setAgents((current) => { @@ -84,5 +112,5 @@ export function useRealtime(enabled: boolean, onUnauthorized: () => void): Realt }; }, [enabled, onUnauthorized]); - return { agents, logs, connected }; + return { agents, logs, targets, connected }; }