Feat : page streamer
This commit is contained in:
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<StripchatStatus> {
|
||||
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é. */
|
||||
|
||||
@@ -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<Toast | null>(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<string, unknown>) => {
|
||||
@@ -149,24 +152,46 @@ export function App() {
|
||||
<div className="brand">
|
||||
<span className={`dot ${connected ? 'ok' : 'offline'}`} />
|
||||
<h1>Stream Control</h1>
|
||||
<span className="muted small">
|
||||
{online.length}/{agents.length} en ligne · {recording.length} en enregistrement
|
||||
</span>
|
||||
</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">
|
||||
<button className="primary" onClick={() => void runBulk('record.start')}>
|
||||
● Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="danger" onClick={() => void runBulk('record.stop')}>
|
||||
■ Arrêter{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void runBulk('obs.connect')}>
|
||||
Reconnecter OBS
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void addAgent()}>
|
||||
+ Agent
|
||||
</button>
|
||||
{route === 'agents' && (
|
||||
<>
|
||||
<button className="primary" onClick={() => void runBulk('record.start')}>
|
||||
● Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="danger" onClick={() => void runBulk('record.stop')}>
|
||||
■ Arrêter{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void runBulk('obs.connect')}>
|
||||
Reconnecter OBS
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void addAgent()}>
|
||||
+ Agent
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="ghost" onClick={logout}>
|
||||
Quitter
|
||||
</button>
|
||||
@@ -189,35 +214,37 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WatchlistPanel
|
||||
targets={targets}
|
||||
agents={agents}
|
||||
notify={notify}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
onEnableNotifications={() => void enableNotifications()}
|
||||
/>
|
||||
|
||||
<main className="grid">
|
||||
{agents.length === 0 && (
|
||||
<div className="empty">
|
||||
<h2>Aucun agent enregistré</h2>
|
||||
<p className="muted">
|
||||
Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton
|
||||
d'enrôlement : il apparaîtra automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selection.has(agent.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onCommand={runCommand}
|
||||
onOpenSettings={setSettingsFor}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
{route === 'streamers' ? (
|
||||
<StreamersPage
|
||||
targets={targets}
|
||||
agents={agents}
|
||||
notify={notify}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
onEnableNotifications={() => void enableNotifications()}
|
||||
/>
|
||||
) : (
|
||||
<main className="grid">
|
||||
{agents.length === 0 && (
|
||||
<div className="empty">
|
||||
<h2>Aucun agent enregistré</h2>
|
||||
<p className="muted">
|
||||
Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton
|
||||
d'enrôlement : il apparaîtra automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selection.has(agent.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onCommand={runCommand}
|
||||
onOpenSettings={setSettingsFor}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
)}
|
||||
|
||||
<LogPanel logs={logs} />
|
||||
|
||||
|
||||
264
packages/web/src/components/StreamersPage.tsx
Normal file
264
packages/web/src/components/StreamersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 --- */
|
||||
|
||||
|
||||
25
packages/web/src/useHashRoute.ts
Normal file
25
packages/web/src/useHashRoute.ts
Normal 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}`; }];
|
||||
}
|
||||
@@ -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 `<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
|
||||
|
||||
|
||||
@@ -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 <all_urls> 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: ['<all_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.',
|
||||
);
|
||||
|
||||
@@ -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", "<all_urls>"],
|
||||
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
|
||||
Reference in New Issue
Block a user