Feat : Streamer watch list
This commit is contained in:
@@ -1,12 +1,8 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { LogLevel, StreamState, WatchSettings, WatchState } from '@stream-control/shared';
|
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';
|
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 {
|
export interface ProbeResult {
|
||||||
/** Statut brut renvoyé par la plateforme. */
|
/** Statut brut renvoyé par la plateforme. */
|
||||||
raw: string;
|
raw: string;
|
||||||
@@ -14,40 +10,10 @@ export interface ProbeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sonde Stripchat.
|
* 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.
|
||||||
* 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.)
|
|
||||||
*/
|
*/
|
||||||
export async function probeStripchat(
|
export const probeStripchat = fetchStripchatStatus;
|
||||||
username: string,
|
|
||||||
privateStatuses: string[],
|
|
||||||
): Promise<ProbeResult> {
|
|
||||||
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) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ce que le surveillant doit pouvoir demander à OBS. */
|
/** Ce que le surveillant doit pouvoir demander à OBS. */
|
||||||
export interface WatchActions {
|
export interface WatchActions {
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
DEFAULT_OBS_SETTINGS,
|
DEFAULT_OBS_SETTINGS,
|
||||||
isAgentAction,
|
isAgentAction,
|
||||||
normalizeWatchSettings,
|
normalizeWatchSettings,
|
||||||
|
parseStripchatUsername,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.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 { hub } from './hub.ts';
|
||||||
|
import { watchlist } from './watchlist.ts';
|
||||||
|
|
||||||
export const api: Router = Router();
|
export const api: Router = Router();
|
||||||
|
|
||||||
@@ -188,6 +190,157 @@ api.post('/commands/bulk', async (req, res) => {
|
|||||||
res.json({ results });
|
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 -----------------------------------------------------------------
|
// --- Divers -----------------------------------------------------------------
|
||||||
|
|
||||||
api.get('/logs', (req, res) => {
|
api.get('/logs', (req, res) => {
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export const config = {
|
|||||||
dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'),
|
dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'),
|
||||||
|
|
||||||
statusIntervalMs: int('STATUS_INTERVAL_MS', 2000),
|
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),
|
agentTimeoutMs: int('AGENT_TIMEOUT_MS', 15_000),
|
||||||
commandTimeoutMs: int('COMMAND_TIMEOUT_MS', 15_000),
|
commandTimeoutMs: int('COMMAND_TIMEOUT_MS', 15_000),
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import type {
|
|||||||
LogLevel,
|
LogLevel,
|
||||||
ObsSettings,
|
ObsSettings,
|
||||||
Platform,
|
Platform,
|
||||||
|
StreamState,
|
||||||
WatchSettings,
|
WatchSettings,
|
||||||
|
WatchTarget,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import {
|
import {
|
||||||
DEFAULT_OBS_SETTINGS,
|
DEFAULT_OBS_SETTINGS,
|
||||||
DEFAULT_WATCH_SETTINGS,
|
DEFAULT_WATCH_SETTINGS,
|
||||||
normalizeWatchSettings,
|
normalizeWatchSettings,
|
||||||
safeJsonParse,
|
safeJsonParse,
|
||||||
|
stripchatProfileUrl,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
|
|
||||||
@@ -49,6 +52,26 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts DESC);
|
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. */
|
/** 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 {
|
interface LogRow {
|
||||||
id: number;
|
id: number;
|
||||||
agent_id: string | null;
|
agent_id: string | null;
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import type {
|
|||||||
LogLevel,
|
LogLevel,
|
||||||
ServerToAgent,
|
ServerToAgent,
|
||||||
ServerToDashboard,
|
ServerToDashboard,
|
||||||
|
WatchTarget,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { emptyStatus } from '@stream-control/shared';
|
import { emptyStatus } from '@stream-control/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
import { agentsRepo, logsRepo, type AgentRecord } from './db.ts';
|
import { agentsRepo, logsRepo, targetsRepo, type AgentRecord } from './db.ts';
|
||||||
|
|
||||||
interface PendingCommand {
|
interface PendingCommand {
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
@@ -190,6 +191,7 @@ class Hub {
|
|||||||
type: 'snapshot',
|
type: 'snapshot',
|
||||||
agents: this.views(),
|
agents: this.views(),
|
||||||
logs: logsRepo.recent(200),
|
logs: logsRepo.recent(200),
|
||||||
|
targets: targetsRepo.list(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +213,21 @@ class Hub {
|
|||||||
this.broadcast({ type: 'agent.removed', agentId });
|
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. */
|
/** Journalise un évènement : persistance + diffusion temps réel. */
|
||||||
log(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry {
|
log(agentId: string | null, level: LogLevel, message: string, ts = Date.now()): LogEntry {
|
||||||
const entry = logsRepo.append(agentId, level, message, ts);
|
const entry = logsRepo.append(agentId, level, message, ts);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { verifySession } from './auth.ts';
|
|||||||
import { api } from './api.ts';
|
import { api } from './api.ts';
|
||||||
import { hub } from './hub.ts';
|
import { hub } from './hub.ts';
|
||||||
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
|
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
|
||||||
|
import { watchlist } from './watchlist.ts';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
@@ -91,6 +92,8 @@ const heartbeat = setInterval(() => {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
heartbeat.unref();
|
heartbeat.unref();
|
||||||
|
|
||||||
|
watchlist.start();
|
||||||
|
|
||||||
server.listen(config.port, config.host, () => {
|
server.listen(config.port, config.host, () => {
|
||||||
console.log(`stream-control · http://${config.host}:${config.port}`);
|
console.log(`stream-control · http://${config.host}:${config.port}`);
|
||||||
console.log(` agents → ws://${config.host}:${config.port}/ws/agent`);
|
console.log(` agents → ws://${config.host}:${config.port}/ws/agent`);
|
||||||
|
|||||||
115
packages/server/src/watchlist.ts
Normal file
115
packages/server/src/watchlist.ts
Normal file
@@ -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<WatchTarget | null> {
|
||||||
|
const target = targetsRepo.get(id);
|
||||||
|
if (!target) return null;
|
||||||
|
await this.probe(target);
|
||||||
|
return targetsRepo.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const watchlist = new Watchlist();
|
||||||
@@ -330,11 +330,38 @@ export interface LogEntry {
|
|||||||
ts: number;
|
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 =
|
export type ServerToDashboard =
|
||||||
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[] }
|
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] }
|
||||||
| { type: 'agent'; agent: AgentView }
|
| { type: 'agent'; agent: AgentView }
|
||||||
| { type: 'agent.removed'; agentId: string }
|
| { 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
|
// 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é. */
|
/** Traduit un statut brut de l'API en état normalisé. */
|
||||||
export function mapStreamStatus(raw: string | undefined, privateStatuses: string[]): StreamState {
|
export function mapStreamStatus(raw: string | undefined, privateStatuses: string[]): StreamState {
|
||||||
if (!raw) return 'unknown';
|
if (!raw) return 'unknown';
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
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 { api, getToken } from './api';
|
||||||
import { useRealtime } from './useRealtime';
|
import { useRealtime } from './useRealtime';
|
||||||
import { Login } from './components/Login';
|
import { Login } from './components/Login';
|
||||||
import { AgentCard } from './components/AgentCard';
|
import { AgentCard } from './components/AgentCard';
|
||||||
import { AgentSettings } from './components/AgentSettings';
|
import { AgentSettings } from './components/AgentSettings';
|
||||||
import { LogPanel } from './components/LogPanel';
|
import { LogPanel } from './components/LogPanel';
|
||||||
|
import { WatchlistPanel } from './components/WatchlistPanel';
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -19,13 +20,51 @@ export function App() {
|
|||||||
const [toast, setToast] = useState<Toast | null>(null);
|
const [toast, setToast] = useState<Toast | null>(null);
|
||||||
const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(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 onUnauthorized = useCallback(() => setAuthenticated(false), []);
|
||||||
const { agents, logs, connected } = useRealtime(authenticated, onUnauthorized);
|
|
||||||
|
|
||||||
const notify = useCallback((message: string, tone: 'info' | 'error' = 'info') => {
|
const notify = useCallback((message: string, tone: 'info' | 'error' = 'info') => {
|
||||||
setToast({ message, tone });
|
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(() => {
|
useEffect(() => {
|
||||||
if (!toast) return;
|
if (!toast) return;
|
||||||
const timer = window.setTimeout(() => setToast(null), 4000);
|
const timer = window.setTimeout(() => setToast(null), 4000);
|
||||||
@@ -150,6 +189,14 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<WatchlistPanel
|
||||||
|
targets={targets}
|
||||||
|
agents={agents}
|
||||||
|
notify={notify}
|
||||||
|
notificationsEnabled={notificationsEnabled}
|
||||||
|
onEnableNotifications={() => void enableNotifications()}
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="grid">
|
<main className="grid">
|
||||||
{agents.length === 0 && (
|
{agents.length === 0 && (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
|
|||||||
@@ -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';
|
const TOKEN_KEY = 'stream-control.session';
|
||||||
|
|
||||||
@@ -97,6 +97,32 @@ export const api = {
|
|||||||
|
|
||||||
enrollment: () =>
|
enrollment: () =>
|
||||||
request<{ enabled: boolean; token: string | null; serverUrl: string }>('/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<Pick<WatchTarget, 'label' | 'agentId' | 'notify'>>) =>
|
||||||
|
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). */
|
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */
|
||||||
|
|||||||
200
packages/web/src/components/WatchlistPanel.tsx
Normal file
200
packages/web/src/components/WatchlistPanel.tsx
Normal file
@@ -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<StreamState, { text: string; tone: string }> = {
|
||||||
|
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 (
|
||||||
|
<section className="watchlist">
|
||||||
|
<header className="watchlist-head">
|
||||||
|
<h3>Veille</h3>
|
||||||
|
<span className="muted small">
|
||||||
|
{targets.length} profil(s) · {live} en direct
|
||||||
|
</span>
|
||||||
|
<div className="spacer" />
|
||||||
|
{!notificationsEnabled && (
|
||||||
|
<button className="ghost" onClick={onEnableNotifications}>
|
||||||
|
🔔 Activer les notifications
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="row watchlist-add" onSubmit={add}>
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
placeholder="https://fr.stripchat.com/pseudo — ou simplement le pseudo"
|
||||||
|
aria-label="Lien du profil à surveiller"
|
||||||
|
/>
|
||||||
|
<button className="primary" type="submit" disabled={busy || !url.trim()}>
|
||||||
|
Surveiller
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{targets.length === 0 ? (
|
||||||
|
<p className="muted small">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="target-list">
|
||||||
|
{targets.map((target) => (
|
||||||
|
<TargetRow key={target.id} target={target} agents={agents} notify={notify} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<unknown>, 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 (
|
||||||
|
<div className={`target tone-${state.tone}`}>
|
||||||
|
<span className={`badge ${state.tone}`}>{state.text}</span>
|
||||||
|
|
||||||
|
<a className="target-name" href={target.url} target="_blank" rel="noreferrer">
|
||||||
|
{target.label ?? target.username}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="target-agent"
|
||||||
|
value={target.agentId ?? ''}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) =>
|
||||||
|
void run(() => api.updateTarget(target.id, { agentId: event.target.value || null }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">— aucun agent —</option>
|
||||||
|
{agents.map((candidate) => (
|
||||||
|
<option key={candidate.id} value={candidate.id}>
|
||||||
|
{candidate.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span className="small muted target-meta">
|
||||||
|
{target.lastError
|
||||||
|
? `sonde en échec : ${target.lastError}`
|
||||||
|
: target.lastCheckedAt
|
||||||
|
? `sondé ${formatRelative(target.lastCheckedAt)}`
|
||||||
|
: 'pas encore sondé'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="target-actions">
|
||||||
|
{recording ? (
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
disabled={busy || !agent?.online}
|
||||||
|
onClick={() => void run(() => api.stopTarget(target.id), 'Enregistrement arrêté')}
|
||||||
|
>
|
||||||
|
■ Arrêter
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={busy || !agent?.online || target.state !== 'public'}
|
||||||
|
title={
|
||||||
|
!target.agentId
|
||||||
|
? 'Assigne un agent'
|
||||||
|
: !agent?.online
|
||||||
|
? 'Agent hors-ligne'
|
||||||
|
: target.state !== 'public'
|
||||||
|
? 'Le streamer n\'est pas en direct'
|
||||||
|
: 'Lancer l\'enregistrement'
|
||||||
|
}
|
||||||
|
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
|
||||||
|
>
|
||||||
|
● Enregistrer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="icon"
|
||||||
|
disabled={busy}
|
||||||
|
title="Sonder maintenant"
|
||||||
|
onClick={() => void run(() => api.checkTarget(target.id))}
|
||||||
|
>
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon"
|
||||||
|
disabled={busy}
|
||||||
|
title="Retirer de la veille"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Retirer « ${target.username} » de la veille ?`)) {
|
||||||
|
void run(() => api.removeTarget(target.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -401,6 +401,85 @@ fieldset.group > legend {
|
|||||||
white-space: nowrap;
|
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 --- */
|
/* --- Journal --- */
|
||||||
|
|
||||||
.logs {
|
.logs {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
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';
|
import { dashboardSocketUrl } from './api';
|
||||||
|
|
||||||
const MAX_LOGS = 400;
|
const MAX_LOGS = 400;
|
||||||
@@ -7,6 +7,7 @@ const MAX_LOGS = 400;
|
|||||||
export interface RealtimeState {
|
export interface RealtimeState {
|
||||||
agents: AgentView[];
|
agents: AgentView[];
|
||||||
logs: LogEntry[];
|
logs: LogEntry[];
|
||||||
|
targets: WatchTarget[];
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,16 +15,27 @@ export interface RealtimeState {
|
|||||||
* Maintient une connexion au flux `/ws/dashboard` avec reconnexion automatique
|
* Maintient une connexion au flux `/ws/dashboard` avec reconnexion automatique
|
||||||
* et applique les mises à jour incrémentales d'agents et de journal.
|
* 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<AgentView[]>([]);
|
const [agents, setAgents] = useState<AgentView[]>([]);
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [targets, setTargets] = useState<WatchTarget[]>([]);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const retryRef = useRef(1000);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setAgents([]);
|
setAgents([]);
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
|
setTargets([]);
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -46,6 +58,22 @@ export function useRealtime(enabled: boolean, onUnauthorized: () => void): Realt
|
|||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
setAgents(message.agents);
|
setAgents(message.agents);
|
||||||
setLogs(message.logs);
|
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;
|
break;
|
||||||
case 'agent':
|
case 'agent':
|
||||||
setAgents((current) => {
|
setAgents((current) => {
|
||||||
@@ -84,5 +112,5 @@ export function useRealtime(enabled: boolean, onUnauthorized: () => void): Realt
|
|||||||
};
|
};
|
||||||
}, [enabled, onUnauthorized]);
|
}, [enabled, onUnauthorized]);
|
||||||
|
|
||||||
return { agents, logs, connected };
|
return { agents, logs, targets, connected };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user