From 4f7b8f56aee7ae36447c57b0f32211bbfc661c47 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 11 Aug 2026 20:04:08 +0200 Subject: [PATCH] Feat : page streamer --- packages/server/src/db.ts | 30 +- packages/server/src/watchlist.ts | 20 ++ packages/shared/src/index.ts | 47 +++- packages/web/src/App.tsx | 117 +++++--- packages/web/src/components/StreamersPage.tsx | 264 ++++++++++++++++++ .../web/src/components/WatchlistPanel.tsx | 200 ------------- packages/web/src/format.ts | 26 ++ packages/web/src/styles.css | 158 +++++++---- packages/web/src/useHashRoute.ts | 25 ++ tools/stream-capture/README.md | 49 +++- tools/stream-capture/background.js | 81 ++++-- tools/stream-capture/manifest.json | 11 +- 12 files changed, 698 insertions(+), 330 deletions(-) create mode 100644 packages/web/src/components/StreamersPage.tsx delete mode 100644 packages/web/src/components/WatchlistPanel.tsx create mode 100644 packages/web/src/useHashRoute.ts diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 431081c..a6a3985 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -87,6 +87,12 @@ function addColumnIfMissing(table: string, column: string, definition: string): // que la table (nouveaux fournisseurs, nouveaux statuts). 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 { id: string; name: string; @@ -287,8 +293,14 @@ interface TargetRow { last_checked_at: number | null; last_error: string | null; 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 { return { id: row.id, @@ -301,9 +313,13 @@ function toTarget(row: TargetRow): WatchTarget { 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), + lastCheckedAt: num(row.last_checked_at), lastError: row.last_error, 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(` 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 = ? `), remove: db.prepare('DELETE FROM watch_targets WHERE id = ?'), @@ -385,6 +403,10 @@ export const targetsRepo = { rawStatus: string | null; stateSince: number; lastError: string | null; + avatarUrl: string | null; + statusChangedAt: number | null; + lastLiveStartedAt: number | null; + lastLiveEndedAt: number | null; }, ): void { targetStmts.updateState.run( @@ -393,6 +415,10 @@ export const targetsRepo = { state.stateSince, Date.now(), state.lastError, + state.avatarUrl, + state.statusChangedAt, + state.lastLiveStartedAt, + state.lastLiveEndedAt, id, ); }, diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index 4fce8f7..d495859 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -55,6 +55,8 @@ class Watchlist { let next: StreamState; let raw: string | null = null; let error: string | null = null; + let avatarUrl = target.avatarUrl; + let statusChangedAt = target.statusChangedAt; try { const result = await fetchStripchatStatus( @@ -63,6 +65,9 @@ class Watchlist { ); next = result.state; 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) { // 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. @@ -71,11 +76,26 @@ class Watchlist { } 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, { state: next, rawStatus: raw ?? target.rawStatus, stateSince: changed ? Date.now() : target.stateSince, lastError: error, + avatarUrl, + statusChangedAt, + lastLiveStartedAt, + lastLiveEndedAt, }); const updated = targetsRepo.get(target.id); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a9410a4..40a781a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -346,11 +346,22 @@ export interface WatchTarget { notify: boolean; state: StreamState; rawStatus: string | null; - /** Depuis quand l'état est stable. */ + /** Depuis quand l'état est stable, selon nos propres observations. */ stateSince: number; lastCheckedAt: number | null; lastError: string | null; 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 = @@ -467,11 +478,23 @@ export function stripchatProfileUrl(username: string): string { * Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule * 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( username: string, privateStatuses: string[], timeoutMs = 8000, -): Promise<{ raw: string; state: StreamState }> { +): Promise { const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent( username, )}/cam`; @@ -485,18 +508,30 @@ export async function fetchStripchatStatus( 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}`); 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') { 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é. */ diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 0cd0b91..b24077f 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -6,7 +6,8 @@ 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'; +import { StreamersPage } from './components/StreamersPage'; +import { useHashRoute } from './useHashRoute'; interface Toast { message: string; @@ -20,6 +21,7 @@ export function App() { const [toast, setToast] = useState(null); const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null); + const [route, setRoute] = useHashRoute(); const [notificationsEnabled, setNotificationsEnabled] = useState( () => typeof Notification !== 'undefined' && Notification.permission === 'granted', ); @@ -79,6 +81,7 @@ export function App() { const online = agents.filter((agent) => agent.online); const recording = agents.filter((agent) => agent.status.recording); + const liveTargets = targets.filter((target) => target.state === 'public').length; const runCommand = useCallback( async (id: string, action: AgentAction, params?: Record) => { @@ -149,24 +152,46 @@ export function App() {

Stream Control

- - {online.length}/{agents.length} en ligne · {recording.length} en enregistrement -
+ +
- - - - + {route === 'agents' && ( + <> + + + + + + )} @@ -189,35 +214,37 @@ export function App() {
)} - void enableNotifications()} - /> - -
- {agents.length === 0 && ( -
-

Aucun agent enregistré

-

- Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton - d'enrôlement : il apparaîtra automatiquement. -

-
- )} - {agents.map((agent) => ( - - ))} -
+ {route === 'streamers' ? ( + void enableNotifications()} + /> + ) : ( +
+ {agents.length === 0 && ( +
+

Aucun agent enregistré

+

+ Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton + d'enrôlement : il apparaîtra automatiquement. +

+
+ )} + {agents.map((agent) => ( + + ))} +
+ )} diff --git a/packages/web/src/components/StreamersPage.tsx b/packages/web/src/components/StreamersPage.tsx new file mode 100644 index 0000000..d995682 --- /dev/null +++ b/packages/web/src/components/StreamersPage.tsx @@ -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 = { + 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 ( +
+
+ setUrl(event.target.value)} + placeholder="https://fr.stripchat.com/pseudo — ou simplement le pseudo" + aria-label="Lien du profil à suivre" + /> + + {!notificationsEnabled && ( + + )} +
+ + {ordered.length === 0 ? ( +
+

Aucun streamer suivi

+

+ 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. +

+
+ ) : ( +
+ {ordered.map((target) => ( + + ))} +
+ )} +
+ ); +} + +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, 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 ( +
+
+ {target.avatarUrl && !avatarBroken ? ( + setAvatarBroken(true)} + /> + ) : ( + + )} + + + + {state.text} +
+ +
+
+
{isLive ? 'En direct depuis' : 'Statut depuis'}
+
+ {isLive + ? formatDuration(liveFor) + : target.statusChangedAt + ? formatRelative(target.statusChangedAt) + : '—'} +
+
+
+
Dernier stream
+
+ {isLive ? ( + en cours + ) : target.lastLiveEndedAt ? ( + <> + {formatDateTime(target.lastLiveEndedAt)} + {lastDuration ? · {formatDuration(lastDuration)} : null} + + ) : ( + jamais observé + )} +
+
+
+ + {target.lastError &&

Sonde en échec : {target.lastError}

} + + + +
+ {recording ? ( + + ) : ( + + )} + +
+ + + +
+ +
+ + {target.lastCheckedAt ? `sondé ${formatRelative(target.lastCheckedAt)}` : 'pas encore sondé'} + + {target.notify ? 🔔 : 🔕} +
+
+ ); +} diff --git a/packages/web/src/components/WatchlistPanel.tsx b/packages/web/src/components/WatchlistPanel.tsx deleted file mode 100644 index 9f01ba7..0000000 --- a/packages/web/src/components/WatchlistPanel.tsx +++ /dev/null @@ -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 = { - 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/format.ts b/packages/web/src/format.ts index 3d07019..f3f56c1 100644 --- a/packages/web/src/format.ts +++ b/packages/web/src/format.ts @@ -36,3 +36,29 @@ export function formatPercent(value: number | undefined): string { export function formatTime(ts: number): string { 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}`; +} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index deeead3..149d1c6 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -401,84 +401,142 @@ fieldset.group > legend { white-space: nowrap; } -/* --- Veille --- */ +/* --- Onglets --- */ -.watchlist { +.tabs { display: flex; - flex-direction: column; - gap: 10px; - padding: 14px 18px; - background: var(--panel); - border-bottom: 1px solid var(--border); + gap: 4px; } -.watchlist-head { +.tab { display: flex; align-items: center; - gap: 10px; -} -.watchlist-add { - max-width: 620px; -} - -.target-list { - display: flex; - flex-direction: column; gap: 6px; + background: transparent; + border-color: transparent; + color: var(--muted); + font-weight: 600; } -.target { - display: grid; - grid-template-columns: 96px minmax(120px, 1fr) 160px minmax(0, 1.2fr) auto; - gap: 10px; - align-items: center; - padding: 8px 10px; +.tab.active { background: var(--panel-2); - border-left: 3px solid transparent; - border-radius: 8px; + border-color: var(--border); + 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); } -.target.tone-warn { +.streamer.tone-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); font-weight: 600; + font-size: 15px; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.target-name:hover { +.streamer-name:hover { 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; 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 --- */ diff --git a/packages/web/src/useHashRoute.ts b/packages/web/src/useHashRoute.ts new file mode 100644 index 0000000..ee7ade7 --- /dev/null +++ b/packages/web/src/useHashRoute.ts @@ -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(currentRoute); + + useEffect(() => { + const onChange = () => setRoute(currentRoute()); + window.addEventListener('hashchange', onChange); + return () => window.removeEventListener('hashchange', onChange); + }, []); + + return [route, (next: Route) => { window.location.hash = `#/${next}`; }]; +} diff --git a/tools/stream-capture/README.md b/tools/stream-capture/README.md index 3e3d898..b38bbd6 100644 --- a/tools/stream-capture/README.md +++ b/tools/stream-capture/README.md @@ -77,8 +77,53 @@ requête initiée par la page └──► 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 -`127.0.0.1`, et n'a aucune permission de lecture de page ni de cookies. +### Sur la portée des permissions + +Le manifeste demande ``, 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 diff --git a/tools/stream-capture/background.js b/tools/stream-capture/background.js index c44c8fe..38bc854 100644 --- a/tools/stream-capture/background.js +++ b/tools/stream-capture/background.js @@ -6,43 +6,68 @@ * vers la page : le lecteur continue de fonctionner normalement, et l'extension * n'émet aucune requête vers la plateforme. * - * C'est la différence avec un script qui irait rechercher le flux : ici on ne - * fait que conserver ce que la session authentifiée a légitimement reçu. + * Portée des permissions : le manifeste demande parce qu'une requête + * 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'; -// 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; let copied = 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() { - const text = failed ? `${copied}/${failed}!` : String(copied); + const text = failed ? `${copied}/${failed}` : String(copied); browser.browserAction.setBadgeText({ text: copied || failed ? text : '' }); 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) { try { const blob = new Blob(chunks); const body = await blob.arrayBuffer(); - await fetch(RECEIVER, { + const response = await fetch(RECEIVER, { method: 'POST', - headers: { - 'content-type': 'application/octet-stream', - 'x-source-url': url, - }, + headers: { 'content-type': 'application/octet-stream', 'x-source-url': url }, body, }); + if (!response.ok) throw new Error(`récepteur : HTTP ${response.status}`); copied += 1; } catch (err) { - // Récepteur non lancé : on n'interrompt surtout pas la lecture pour autant. 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(); } @@ -51,7 +76,17 @@ browser.webRequest.onBeforeRequest.addListener( (details) => { 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 = []; filter.ondata = (event) => { @@ -62,18 +97,30 @@ browser.webRequest.onBeforeRequest.addListener( filter.onstop = () => { filter.disconnect(); - void forward(details.url, chunks); + if (chunks.length) void forward(details.url, chunks); }; 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 {}; }, - { urls: ['*://*.doppiocdn.com/*'] }, + { urls: [''] }, ['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.', +); diff --git a/tools/stream-capture/manifest.json b/tools/stream-capture/manifest.json index ee6cb6c..f58c2f5 100644 --- a/tools/stream-capture/manifest.json +++ b/tools/stream-capture/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 2, "name": "Stream Capture (test)", - "version": "0.1.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.", + "version": "0.2.0", + "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": { "gecko": { @@ -11,12 +11,7 @@ } }, - "permissions": [ - "webRequest", - "webRequestBlocking", - "*://*.doppiocdn.com/*", - "http://127.0.0.1/*" - ], + "permissions": ["webRequest", "webRequestBlocking", ""], "background": { "scripts": ["background.js"]