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).
|
// 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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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é. */
|
||||||
|
|||||||
@@ -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,24 +152,46 @@ 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">
|
||||||
<button className="primary" onClick={() => void runBulk('record.start')}>
|
{route === 'agents' && (
|
||||||
● Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
<>
|
||||||
</button>
|
<button className="primary" onClick={() => void runBulk('record.start')}>
|
||||||
<button className="danger" onClick={() => void runBulk('record.stop')}>
|
● Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||||
■ Arrêter{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
</button>
|
||||||
</button>
|
<button className="danger" onClick={() => void runBulk('record.stop')}>
|
||||||
<button className="ghost" onClick={() => void runBulk('obs.connect')}>
|
■ Arrêter{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||||
Reconnecter OBS
|
</button>
|
||||||
</button>
|
<button className="ghost" onClick={() => void runBulk('obs.connect')}>
|
||||||
<button className="ghost" onClick={() => void addAgent()}>
|
Reconnecter OBS
|
||||||
+ Agent
|
</button>
|
||||||
</button>
|
<button className="ghost" onClick={() => void addAgent()}>
|
||||||
|
+ Agent
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button className="ghost" onClick={logout}>
|
<button className="ghost" onClick={logout}>
|
||||||
Quitter
|
Quitter
|
||||||
</button>
|
</button>
|
||||||
@@ -189,35 +214,37 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<WatchlistPanel
|
{route === 'streamers' ? (
|
||||||
targets={targets}
|
<StreamersPage
|
||||||
agents={agents}
|
targets={targets}
|
||||||
notify={notify}
|
agents={agents}
|
||||||
notificationsEnabled={notificationsEnabled}
|
notify={notify}
|
||||||
onEnableNotifications={() => void enableNotifications()}
|
notificationsEnabled={notificationsEnabled}
|
||||||
/>
|
onEnableNotifications={() => void enableNotifications()}
|
||||||
|
/>
|
||||||
<main className="grid">
|
) : (
|
||||||
{agents.length === 0 && (
|
<main className="grid">
|
||||||
<div className="empty">
|
{agents.length === 0 && (
|
||||||
<h2>Aucun agent enregistré</h2>
|
<div className="empty">
|
||||||
<p className="muted">
|
<h2>Aucun agent enregistré</h2>
|
||||||
Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton
|
<p className="muted">
|
||||||
d'enrôlement : il apparaîtra automatiquement.
|
Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton
|
||||||
</p>
|
d'enrôlement : il apparaîtra automatiquement.
|
||||||
</div>
|
</p>
|
||||||
)}
|
</div>
|
||||||
{agents.map((agent) => (
|
)}
|
||||||
<AgentCard
|
{agents.map((agent) => (
|
||||||
key={agent.id}
|
<AgentCard
|
||||||
agent={agent}
|
key={agent.id}
|
||||||
selected={selection.has(agent.id)}
|
agent={agent}
|
||||||
onToggleSelect={toggleSelect}
|
selected={selection.has(agent.id)}
|
||||||
onCommand={runCommand}
|
onToggleSelect={toggleSelect}
|
||||||
onOpenSettings={setSettingsFor}
|
onCommand={runCommand}
|
||||||
/>
|
onOpenSettings={setSettingsFor}
|
||||||
))}
|
/>
|
||||||
</main>
|
))}
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
<LogPanel logs={logs} />
|
<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 {
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 --- */
|
||||||
|
|
||||||
|
|||||||
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
|
└──► 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user