Feat : page streamer
Some checks failed
release / build (push) Successful in 27s
release / verify-windows (push) Failing after 1m3s

This commit is contained in:
jeanotx32
2026-08-11 20:04:08 +02:00
parent da9025fbf1
commit 4f7b8f56ae
12 changed files with 698 additions and 330 deletions

View File

@@ -87,6 +87,12 @@ function addColumnIfMissing(table: string, column: string, definition: string):
// que la table (nouveaux fournisseurs, nouveaux statuts). // que la table (nouveaux fournisseurs, nouveaux statuts).
addColumnIfMissing('agents', 'watch_json', 'TEXT'); addColumnIfMissing('agents', 'watch_json', 'TEXT');
// Enrichissement des profils surveillés : photo et historique de diffusion.
addColumnIfMissing('watch_targets', 'avatar_url', 'TEXT');
addColumnIfMissing('watch_targets', 'status_changed_at', 'INTEGER');
addColumnIfMissing('watch_targets', 'last_live_started_at', 'INTEGER');
addColumnIfMissing('watch_targets', 'last_live_ended_at', 'INTEGER');
export interface AgentRow { export interface AgentRow {
id: string; id: string;
name: string; name: string;
@@ -287,8 +293,14 @@ interface TargetRow {
last_checked_at: number | null; last_checked_at: number | null;
last_error: string | null; last_error: string | null;
created_at: number; created_at: number;
avatar_url: string | null;
status_changed_at: number | null;
last_live_started_at: number | null;
last_live_ended_at: number | null;
} }
const num = (value: number | null): number | null => (value === null ? null : Number(value));
function toTarget(row: TargetRow): WatchTarget { function toTarget(row: TargetRow): WatchTarget {
return { return {
id: row.id, id: row.id,
@@ -301,9 +313,13 @@ function toTarget(row: TargetRow): WatchTarget {
state: (row.state as StreamState) ?? 'unknown', state: (row.state as StreamState) ?? 'unknown',
rawStatus: row.raw_status, rawStatus: row.raw_status,
stateSince: Number(row.state_since), stateSince: Number(row.state_since),
lastCheckedAt: row.last_checked_at === null ? null : Number(row.last_checked_at), lastCheckedAt: num(row.last_checked_at),
lastError: row.last_error, lastError: row.last_error,
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
avatarUrl: row.avatar_url,
statusChangedAt: num(row.status_changed_at),
lastLiveStartedAt: num(row.last_live_started_at),
lastLiveEndedAt: num(row.last_live_ended_at),
}; };
} }
@@ -321,7 +337,9 @@ const targetStmts = {
), ),
updateState: db.prepare(` updateState: db.prepare(`
UPDATE watch_targets UPDATE watch_targets
SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ? SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?,
avatar_url = ?, status_changed_at = ?,
last_live_started_at = ?, last_live_ended_at = ?
WHERE id = ? WHERE id = ?
`), `),
remove: db.prepare('DELETE FROM watch_targets WHERE id = ?'), remove: db.prepare('DELETE FROM watch_targets WHERE id = ?'),
@@ -385,6 +403,10 @@ export const targetsRepo = {
rawStatus: string | null; rawStatus: string | null;
stateSince: number; stateSince: number;
lastError: string | null; lastError: string | null;
avatarUrl: string | null;
statusChangedAt: number | null;
lastLiveStartedAt: number | null;
lastLiveEndedAt: number | null;
}, },
): void { ): void {
targetStmts.updateState.run( targetStmts.updateState.run(
@@ -393,6 +415,10 @@ export const targetsRepo = {
state.stateSince, state.stateSince,
Date.now(), Date.now(),
state.lastError, state.lastError,
state.avatarUrl,
state.statusChangedAt,
state.lastLiveStartedAt,
state.lastLiveEndedAt,
id, id,
); );
}, },

View File

@@ -55,6 +55,8 @@ class Watchlist {
let next: StreamState; let next: StreamState;
let raw: string | null = null; let raw: string | null = null;
let error: string | null = null; let error: string | null = null;
let avatarUrl = target.avatarUrl;
let statusChangedAt = target.statusChangedAt;
try { try {
const result = await fetchStripchatStatus( const result = await fetchStripchatStatus(
@@ -63,6 +65,9 @@ class Watchlist {
); );
next = result.state; next = result.state;
raw = result.raw; raw = result.raw;
// On conserve la dernière valeur connue si la plateforme ne la renvoie pas.
avatarUrl = result.avatarUrl ?? target.avatarUrl;
statusChangedAt = result.statusChangedAt ?? target.statusChangedAt;
} catch (err) { } catch (err) {
// Une sonde en échec ne change pas l'état connu : on garde le dernier // 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. // verdict fiable plutôt que d'annoncer un faux passage hors-ligne.
@@ -71,11 +76,26 @@ class Watchlist {
} }
const changed = next !== target.state; const changed = next !== target.state;
// Fin de diffusion : on fige le stream qui vient de se terminer. Son début
// est le statusChangedAt d'avant la transition, que la plateforme vient de
// remplacer par celui du nouveau statut.
let lastLiveStartedAt = target.lastLiveStartedAt;
let lastLiveEndedAt = target.lastLiveEndedAt;
if (changed && target.state === 'public') {
lastLiveStartedAt = target.statusChangedAt ?? target.stateSince;
lastLiveEndedAt = Date.now();
}
targetsRepo.updateState(target.id, { targetsRepo.updateState(target.id, {
state: next, state: next,
rawStatus: raw ?? target.rawStatus, rawStatus: raw ?? target.rawStatus,
stateSince: changed ? Date.now() : target.stateSince, stateSince: changed ? Date.now() : target.stateSince,
lastError: error, lastError: error,
avatarUrl,
statusChangedAt,
lastLiveStartedAt,
lastLiveEndedAt,
}); });
const updated = targetsRepo.get(target.id); const updated = targetsRepo.get(target.id);

View File

@@ -346,11 +346,22 @@ export interface WatchTarget {
notify: boolean; notify: boolean;
state: StreamState; state: StreamState;
rawStatus: string | null; rawStatus: string | null;
/** Depuis quand l'état est stable. */ /** Depuis quand l'état est stable, selon nos propres observations. */
stateSince: number; stateSince: number;
lastCheckedAt: number | null; lastCheckedAt: number | null;
lastError: string | null; lastError: string | null;
createdAt: number; createdAt: number;
/** Photo de profil. */
avatarUrl: string | null;
/**
* Début du statut courant d'après la plateforme. Quand `state` vaut `public`,
* c'est l'heure de début du stream en cours.
*/
statusChangedAt: number | null;
/** Dernier stream terminé : début et fin observés. */
lastLiveStartedAt: number | null;
lastLiveEndedAt: number | null;
} }
export type ServerToDashboard = export type ServerToDashboard =
@@ -467,11 +478,23 @@ export function stripchatProfileUrl(username: string): string {
* Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule * Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule
* définition de l'endpoint et du chemin du champ à maintenir. * définition de l'endpoint et du chemin du champ à maintenir.
*/ */
export interface StripchatStatus {
raw: string;
state: StreamState;
/** Photo de profil, absente si le modèle n'en a pas. */
avatarUrl: string | null;
/**
* Début du statut courant. Quand le modèle est public, c'est l'heure de début
* du stream en cours — bien plus précis que notre propre première observation.
*/
statusChangedAt: number | null;
}
export async function fetchStripchatStatus( export async function fetchStripchatStatus(
username: string, username: string,
privateStatuses: string[], privateStatuses: string[],
timeoutMs = 8000, timeoutMs = 8000,
): Promise<{ raw: string; state: StreamState }> { ): Promise<StripchatStatus> {
const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent( const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent(
username, username,
)}/cam`; )}/cam`;
@@ -485,18 +508,30 @@ export async function fetchStripchatStatus(
signal: AbortSignal.timeout(timeoutMs), signal: AbortSignal.timeout(timeoutMs),
}); });
if (response.status === 404) return { raw: 'notFound', state: 'offline' }; if (response.status === 404) {
return { raw: 'notFound', state: 'offline', avatarUrl: null, statusChangedAt: null };
}
if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`); if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`);
const payload = (await response.json()) as { const payload = (await response.json()) as {
user?: { user?: { status?: string } }; user?: { user?: { status?: string; avatarUrl?: string; statusChangedAt?: string } };
}; };
const raw = payload?.user?.user?.status; const profile = payload?.user?.user;
const raw = profile?.status;
if (typeof raw !== 'string') { if (typeof raw !== 'string') {
throw new Error('Réponse Stripchat inattendue : user.user.status absent'); throw new Error('Réponse Stripchat inattendue : user.user.status absent');
} }
return { raw, state: mapStreamStatus(raw, privateStatuses) }; const changedAt = profile?.statusChangedAt
? Date.parse(profile.statusChangedAt)
: Number.NaN;
return {
raw,
state: mapStreamStatus(raw, privateStatuses),
avatarUrl: typeof profile?.avatarUrl === 'string' && profile.avatarUrl ? profile.avatarUrl : null,
statusChangedAt: Number.isFinite(changedAt) ? changedAt : null,
};
} }
/** Traduit un statut brut de l'API en état normalisé. */ /** Traduit un statut brut de l'API en état normalisé. */

View File

@@ -6,7 +6,8 @@ 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'; import { StreamersPage } from './components/StreamersPage';
import { useHashRoute } from './useHashRoute';
interface Toast { interface Toast {
message: string; message: string;
@@ -20,6 +21,7 @@ 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 [route, setRoute] = useHashRoute();
const [notificationsEnabled, setNotificationsEnabled] = useState( const [notificationsEnabled, setNotificationsEnabled] = useState(
() => typeof Notification !== 'undefined' && Notification.permission === 'granted', () => typeof Notification !== 'undefined' && Notification.permission === 'granted',
); );
@@ -79,6 +81,7 @@ export function App() {
const online = agents.filter((agent) => agent.online); const online = agents.filter((agent) => agent.online);
const recording = agents.filter((agent) => agent.status.recording); const recording = agents.filter((agent) => agent.status.recording);
const liveTargets = targets.filter((target) => target.state === 'public').length;
const runCommand = useCallback( const runCommand = useCallback(
async (id: string, action: AgentAction, params?: Record<string, unknown>) => { async (id: string, action: AgentAction, params?: Record<string, unknown>) => {
@@ -149,12 +152,32 @@ export function App() {
<div className="brand"> <div className="brand">
<span className={`dot ${connected ? 'ok' : 'offline'}`} /> <span className={`dot ${connected ? 'ok' : 'offline'}`} />
<h1>Stream Control</h1> <h1>Stream Control</h1>
<span className="muted small">
{online.length}/{agents.length} en ligne · {recording.length} en enregistrement
</span>
</div> </div>
<nav className="tabs">
<button
className={route === 'agents' ? 'tab active' : 'tab'}
onClick={() => setRoute('agents')}
>
Agents
<span className="muted small">
{' · '}
{online.length}/{agents.length}
{recording.length > 0 ? ` · ${recording.length}` : ''}
</span>
</button>
<button
className={route === 'streamers' ? 'tab active' : 'tab'}
onClick={() => setRoute('streamers')}
>
Streamers
{liveTargets > 0 && <span className="pill">{liveTargets}</span>}
</button>
</nav>
<div className="topbar-actions"> <div className="topbar-actions">
{route === 'agents' && (
<>
<button className="primary" onClick={() => void runBulk('record.start')}> <button className="primary" onClick={() => void runBulk('record.start')}>
Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'} Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
</button> </button>
@@ -167,6 +190,8 @@ export function App() {
<button className="ghost" onClick={() => void addAgent()}> <button className="ghost" onClick={() => void addAgent()}>
+ Agent + Agent
</button> </button>
</>
)}
<button className="ghost" onClick={logout}> <button className="ghost" onClick={logout}>
Quitter Quitter
</button> </button>
@@ -189,14 +214,15 @@ export function App() {
</div> </div>
)} )}
<WatchlistPanel {route === 'streamers' ? (
<StreamersPage
targets={targets} targets={targets}
agents={agents} agents={agents}
notify={notify} notify={notify}
notificationsEnabled={notificationsEnabled} notificationsEnabled={notificationsEnabled}
onEnableNotifications={() => void enableNotifications()} onEnableNotifications={() => void enableNotifications()}
/> />
) : (
<main className="grid"> <main className="grid">
{agents.length === 0 && ( {agents.length === 0 && (
<div className="empty"> <div className="empty">
@@ -218,6 +244,7 @@ export function App() {
/> />
))} ))}
</main> </main>
)}
<LogPanel logs={logs} /> <LogPanel logs={logs} />

View File

@@ -0,0 +1,264 @@
import { useState, type FormEvent } from 'react';
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
import { api } from '../api';
import { formatDateTime, formatDuration, 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 StreamersPage({
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é au suivi`);
setUrl('');
} catch (err) {
notify(err instanceof Error ? err.message : 'Ajout impossible', 'error');
} finally {
setBusy(false);
}
}
// En direct d'abord, puis show privé, puis le reste par ordre alphabétique.
const ordered = [...targets].sort((a, b) => {
const rank = (target: WatchTarget) =>
target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2;
return rank(a) - rank(b) || a.username.localeCompare(b.username);
});
return (
<div className="page">
<form className="row streamer-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 à suivre"
/>
<button className="primary" type="submit" disabled={busy || !url.trim()}>
Suivre
</button>
{!notificationsEnabled && (
<button type="button" className="ghost" onClick={onEnableNotifications}>
🔔 Activer les notifications
</button>
)}
</form>
{ordered.length === 0 ? (
<div className="empty">
<h2>Aucun streamer suivi</h2>
<p className="muted">
Colle le lien d'un profil ci-dessus : tu seras prévenu dès qu'il passe en direct,
et tu pourras lancer l'enregistrement d'un clic.
</p>
</div>
) : (
<div className="streamer-grid">
{ordered.map((target) => (
<StreamerCard key={target.id} target={target} agents={agents} notify={notify} />
))}
</div>
)}
</div>
);
}
function StreamerCard({
target,
agents,
notify,
}: {
target: WatchTarget;
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
}) {
const [busy, setBusy] = useState(false);
const [avatarBroken, setAvatarBroken] = useState(false);
const state = STATE_LABELS[target.state];
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const recording = agent?.status.recording ?? false;
const isLive = target.state === 'public';
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
const lastDuration =
target.lastLiveStartedAt && target.lastLiveEndedAt
? target.lastLiveEndedAt - target.lastLiveStartedAt
: null;
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);
}
}
const recordTitle = !target.agentId
? 'Assigne un agent à ce streamer'
: !agent?.online
? 'Agent hors-ligne'
: !isLive
? "Le streamer n'est pas en direct"
: 'Lancer l\'enregistrement';
return (
<article className={`streamer tone-${state.tone}`}>
<header className="streamer-head">
{target.avatarUrl && !avatarBroken ? (
<img
className="avatar"
src={target.avatarUrl}
alt=""
loading="lazy"
// Pas de fuite de l'URL du dashboard vers le CDN de la plateforme.
referrerPolicy="no-referrer"
onError={() => setAvatarBroken(true)}
/>
) : (
<div className="avatar avatar-fallback" aria-hidden="true">
{target.username.slice(0, 1).toUpperCase()}
</div>
)}
<div className="streamer-identity">
<a href={target.url} target="_blank" rel="noreferrer" className="streamer-name">
{target.label ?? target.username}
</a>
<span className="muted small">@{target.username}</span>
</div>
<span className={`badge ${state.tone}`}>{state.text}</span>
</header>
<dl className="streamer-facts">
<div>
<dt>{isLive ? 'En direct depuis' : 'Statut depuis'}</dt>
<dd>
{isLive
? formatDuration(liveFor)
: target.statusChangedAt
? formatRelative(target.statusChangedAt)
: '—'}
</dd>
</div>
<div>
<dt>Dernier stream</dt>
<dd>
{isLive ? (
<span className="muted">en cours</span>
) : target.lastLiveEndedAt ? (
<>
{formatDateTime(target.lastLiveEndedAt)}
{lastDuration ? <span className="muted"> · {formatDuration(lastDuration)}</span> : null}
</>
) : (
<span className="muted">jamais observé</span>
)}
</dd>
</div>
</dl>
{target.lastError && <p className="error small">Sonde en échec : {target.lastError}</p>}
<label className="field">
<span>Agent d'enregistrement</span>
<select
value={target.agentId ?? ''}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { agentId: event.target.value || null }))
}
>
<option value="">— aucun —</option>
{agents.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
{candidate.online ? '' : ' (hors-ligne)'}
</option>
))}
</select>
</label>
<div className="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 || !isLive}
title={recordTitle}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
● Enregistrer
</button>
)}
<div className="spacer" />
<button
className="icon"
disabled={busy}
title="Sonder maintenant"
onClick={() => void run(() => api.checkTarget(target.id))}
>
</button>
<button
className="icon"
disabled={busy}
title="Ne plus suivre"
onClick={() => {
if (confirm(`Ne plus suivre « ${target.username} » ?`)) {
void run(() => api.removeTarget(target.id));
}
}}
>
</button>
</div>
<footer className="card-foot muted small">
<span>
{target.lastCheckedAt ? `sondé ${formatRelative(target.lastCheckedAt)}` : 'pas encore sondé'}
</span>
{target.notify ? <span>🔔</span> : <span title="Notifications désactivées">🔕</span>}
</footer>
</article>
);
}

View File

@@ -1,200 +0,0 @@
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>
);
}

View File

@@ -36,3 +36,29 @@ export function formatPercent(value: number | undefined): string {
export function formatTime(ts: number): string { export function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('fr-FR'); return new Date(ts).toLocaleTimeString('fr-FR');
} }
/** Durée lisible : « 2 h 14 », « 45 min », « 30 s ». */
export function formatDuration(ms: number | null | undefined): string {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) return '—';
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds} s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours} h ${String(rest).padStart(2, '0')}` : `${hours} h`;
}
/** Date courte : « hier à 21:30 », « le 03/08 à 14:02 ». */
export function formatDateTime(ts: number | null | undefined): string {
if (!ts) return '—';
const date = new Date(ts);
const today = new Date();
const sameDay = date.toDateString() === today.toDateString();
const yesterday = new Date(today.getTime() - 86_400_000).toDateString() === date.toDateString();
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
if (sameDay) return `aujourd'hui à ${time}`;
if (yesterday) return `hier à ${time}`;
return `le ${date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} à ${time}`;
}

View File

@@ -401,84 +401,142 @@ fieldset.group > legend {
white-space: nowrap; white-space: nowrap;
} }
/* --- Veille --- */ /* --- Onglets --- */
.watchlist { .tabs {
display: flex; display: flex;
flex-direction: column; gap: 4px;
gap: 10px;
padding: 14px 18px;
background: var(--panel);
border-bottom: 1px solid var(--border);
} }
.watchlist-head { .tab {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px;
}
.watchlist-add {
max-width: 620px;
}
.target-list {
display: flex;
flex-direction: column;
gap: 6px; gap: 6px;
background: transparent;
border-color: transparent;
color: var(--muted);
font-weight: 600;
} }
.target { .tab.active {
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); background: var(--panel-2);
border-left: 3px solid transparent; border-color: var(--border);
border-radius: 8px; color: var(--text);
} }
.target.tone-rec { .pill {
min-width: 18px;
padding: 1px 6px;
border-radius: 999px;
background: var(--rec);
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
/* --- Page Streamers --- */
.page {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
align-content: start;
}
.streamer-add {
max-width: 720px;
flex-wrap: wrap;
}
.streamer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
align-content: start;
}
.streamer {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius);
}
.streamer.tone-rec {
border-left-color: var(--rec); border-left-color: var(--rec);
} }
.target.tone-warn { .streamer.tone-warn {
border-left-color: var(--warn); border-left-color: var(--warn);
} }
.streamer.tone-offline {
opacity: 0.72;
}
.target-name { .streamer-head {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 44px;
height: 44px;
flex: 0 0 44px;
border-radius: 50%;
object-fit: cover;
background: var(--panel-2);
}
.avatar-fallback {
display: grid;
place-items: center;
font-weight: 700;
color: var(--muted);
border: 1px solid var(--border);
}
.streamer-identity {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.streamer-name {
color: var(--text); color: var(--text);
font-weight: 600; font-weight: 600;
font-size: 15px;
text-decoration: none; text-decoration: none;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.target-name:hover { .streamer-name:hover {
text-decoration: underline; text-decoration: underline;
} }
.target-agent {
width: 100%; .streamer-facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0;
padding: 10px;
background: var(--panel-2);
border-radius: 8px;
} }
.target-meta { .streamer-facts > div {
min-width: 0;
}
.streamer-facts dt {
font-size: 11px;
color: var(--muted);
}
.streamer-facts dd {
margin: 0;
font-size: 13px;
font-variant-numeric: tabular-nums;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 --- */

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
export const ROUTES = ['agents', 'streamers'] as const;
export type Route = (typeof ROUTES)[number];
function currentRoute(): Route {
const raw = window.location.hash.replace(/^#\/?/, '');
return (ROUTES as readonly string[]).includes(raw) ? (raw as Route) : 'agents';
}
/**
* Routage minimal par ancre — l'application n'a que deux vues, et l'ancre suffit
* à les rendre partageables et à survivre à un rechargement.
*/
export function useHashRoute(): [Route, (route: Route) => void] {
const [route, setRoute] = useState<Route>(currentRoute);
useEffect(() => {
const onChange = () => setRoute(currentRoute());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, []);
return [route, (next: Route) => { window.location.hash = `#/${next}`; }];
}

View File

@@ -77,8 +77,53 @@ requête initiée par la page
└──► lecture normale, aucune requête supplémentaire vers la plateforme └──► lecture normale, aucune requête supplémentaire vers la plateforme
``` ```
Elle n'observe que `*.doppiocdn.com` (le CDN média), n'envoie rien ailleurs qu'en ### Sur la portée des permissions
`127.0.0.1`, et n'a aucune permission de lecture de page ni de cookies.
Le manifeste demande `<all_urls>`, et c'est délibéré : **une requête vers un
domaine non déclaré est purement invisible** pour une extension. Or les noms
d'hôte du CDN n'ont été observés que sur le leurre anonyme — rien ne garantit
qu'une session authentifiée soit servie par les mêmes. Restreindre la permission
aux hôtes connus risquait de ne rien capturer du tout, sans le moindre indice.
Le filtrage réel est dans le code, pas dans la permission :
- seules les URL finissant par `.m3u8`, `.m4s`, `.mp4` ou `.ts` sont lues ;
- tout le reste retourne immédiatement, sans être ouvert ;
- la seule destination sortante est `127.0.0.1:8099` ;
- aucune permission `tabs`, `cookies`, `storage` ni `scripting`.
L'extension journalise les hôtes qui servent du média. Une fois le bon CDN connu,
la permission peut être resserrée sur lui.
## Dépannage
### « Ne peut pas lire les données de ce site »
C'est l'indicateur de permissions de Firefox, et il parle du **site affiché dans
l'onglet**. La version 0.1.0 ne déclarait que le CDN, pas `stripchat.com` : sur cet
onglet, Firefox annonçait donc à juste titre n'avoir aucun droit de lecture.
Corrigé en 0.2.0 — recharge le module temporaire.
### Vérifier que la capture fonctionne vraiment
L'indicateur de permissions ne dit rien de l'activité réelle. Deux signaux fiables :
1. **Le compteur sur l'icône** de l'extension s'incrémente. En rouge, le récepteur
ne répond pas (`node receiver.mjs` non lancé).
2. **La console de l'extension** : `about:debugging#/runtime/this-firefox`
*Inspecter* en face de Stream Capture. Elle liste les hôtes qui servent du
média, et résume toutes les 30 s :
```
stream-capture : média servi par media-hls.doppiocdn.com
stream-capture : 47 copiés, 0 en échec — hôtes : media-hls.doppiocdn.com (47)
```
### Aucun hôte média détecté
Si la console ne mentionne aucun hôte alors que la vidéo joue, le lecteur ne passe
pas par des requêtes HTTP filtrables — par exemple du WebRTC, qui ne transite pas
par `webRequest`. Recopie-moi la sortie de la console : c'est l'information qui
manque pour trancher.
## Limites de ce banc d'essai ## Limites de ce banc d'essai

View File

@@ -6,43 +6,68 @@
* vers la page : le lecteur continue de fonctionner normalement, et l'extension * vers la page : le lecteur continue de fonctionner normalement, et l'extension
* n'émet aucune requête vers la plateforme. * n'émet aucune requête vers la plateforme.
* *
* C'est la différence avec un script qui irait rechercher le flux : ici on ne * Portée des permissions : le manifeste demande <all_urls> parce qu'une requête
* fait que conserver ce que la session authentifiée a légitimement reçu. * vers un domaine non autorisé est purement invisible pour une extension — et on
* ignore quels hôtes servent le flux d'une session authentifiée. Le filtrage
* réel est ici : seules les URL de média sont lues, et rien n'est transmis
* ailleurs qu'à 127.0.0.1.
*/ */
const RECEIVER = 'http://127.0.0.1:8099/ingest'; const RECEIVER = 'http://127.0.0.1:8099/ingest';
// Playlists et segments servis par le CDN média de la plateforme. /** Playlists et segments. Tout le reste est ignoré sans être lu. */
const MEDIA_PATTERN = /\.(m3u8|m4s|mp4|ts)(\?|$)/i; const MEDIA_PATTERN = /\.(m3u8|m4s|mp4|ts)(\?|$)/i;
let copied = 0; let copied = 0;
let failed = 0; let failed = 0;
let receiverWarned = false;
/** Hôtes ayant servi du média : sert à identifier le CDN réellement utilisé. */
const seenHosts = new Map();
function updateBadge() { function updateBadge() {
const text = failed ? `${copied}/${failed}!` : String(copied); const text = failed ? `${copied}/${failed}` : String(copied);
browser.browserAction.setBadgeText({ text: copied || failed ? text : '' }); browser.browserAction.setBadgeText({ text: copied || failed ? text : '' });
browser.browserAction.setBadgeBackgroundColor({ color: failed ? '#b91c1c' : '#2563eb' }); browser.browserAction.setBadgeBackgroundColor({ color: failed ? '#b91c1c' : '#2563eb' });
} }
function noteHost(url) {
try {
const { host } = new URL(url);
const count = (seenHosts.get(host) ?? 0) + 1;
seenHosts.set(host, count);
if (count === 1) {
console.log(`stream-capture : média servi par ${host}`);
}
} catch {
/* URL exotique : sans importance pour le diagnostic */
}
}
async function forward(url, chunks) { async function forward(url, chunks) {
try { try {
const blob = new Blob(chunks); const blob = new Blob(chunks);
const body = await blob.arrayBuffer(); const body = await blob.arrayBuffer();
await fetch(RECEIVER, { const response = await fetch(RECEIVER, {
method: 'POST', method: 'POST',
headers: { headers: { 'content-type': 'application/octet-stream', 'x-source-url': url },
'content-type': 'application/octet-stream',
'x-source-url': url,
},
body, body,
}); });
if (!response.ok) throw new Error(`récepteur : HTTP ${response.status}`);
copied += 1; copied += 1;
} catch (err) { } catch (err) {
// Récepteur non lancé : on n'interrompt surtout pas la lecture pour autant.
failed += 1; failed += 1;
console.warn('stream-capture : envoi impossible', err); // Récepteur non lancé : on n'interrompt surtout pas la lecture pour autant.
if (!receiverWarned) {
receiverWarned = true;
console.error(
`stream-capture : le récepteur ne répond pas sur ${RECEIVER}` +
'lance « node receiver.mjs » sur cette machine.',
err,
);
}
} }
updateBadge(); updateBadge();
} }
@@ -51,7 +76,17 @@ browser.webRequest.onBeforeRequest.addListener(
(details) => { (details) => {
if (!MEDIA_PATTERN.test(details.url)) return {}; if (!MEDIA_PATTERN.test(details.url)) return {};
const filter = browser.webRequest.filterResponseData(details.requestId); noteHost(details.url);
let filter;
try {
filter = browser.webRequest.filterResponseData(details.requestId);
} catch (err) {
// Requête non filtrable (cache, redirection…) : la lecture continue.
console.warn('stream-capture : filtrage impossible pour', details.url, err);
return {};
}
const chunks = []; const chunks = [];
filter.ondata = (event) => { filter.ondata = (event) => {
@@ -62,18 +97,30 @@ browser.webRequest.onBeforeRequest.addListener(
filter.onstop = () => { filter.onstop = () => {
filter.disconnect(); filter.disconnect();
void forward(details.url, chunks); if (chunks.length) void forward(details.url, chunks);
}; };
filter.onerror = () => { filter.onerror = () => {
// Le filtre a lâché : la requête suit son cours normalement. console.warn('stream-capture : filtre interrompu —', filter.error);
console.warn('stream-capture : filtre interrompu', filter.error);
}; };
return {}; return {};
}, },
{ urls: ['*://*.doppiocdn.com/*'] }, { urls: ['<all_urls>'] },
['blocking'], ['blocking'],
); );
console.log('stream-capture : actif, récepteur attendu sur', RECEIVER); /** Résumé périodique, à recopier en cas de souci. */
setInterval(() => {
if (!seenHosts.size) return;
const summary = [...seenHosts.entries()]
.map(([host, count]) => `${host} (${count})`)
.join(', ');
console.log(`stream-capture : ${copied} copiés, ${failed} en échec — hôtes : ${summary}`);
}, 30_000);
console.log(
'stream-capture actif. Récepteur attendu sur ' +
RECEIVER +
'. Aucun hôte média détecté pour l\'instant.',
);

View File

@@ -1,8 +1,8 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Stream Capture (test)", "name": "Stream Capture (test)",
"version": "0.1.0", "version": "0.2.0",
"description": "Copie les segments média que Firefox télécharge déjà, vers un récepteur local. Banc d'essai pour Stream Control.", "description": "Copie les segments média que Firefox télécharge déjà, vers un récepteur local (127.0.0.1). Banc d'essai pour Stream Control.",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
@@ -11,12 +11,7 @@
} }
}, },
"permissions": [ "permissions": ["webRequest", "webRequestBlocking", "<all_urls>"],
"webRequest",
"webRequestBlocking",
"*://*.doppiocdn.com/*",
"http://127.0.0.1/*"
],
"background": { "background": {
"scripts": ["background.js"] "scripts": ["background.js"]