Feat : STRCHA Stream status

This commit is contained in:
jeanotx32
2026-08-11 00:52:33 +02:00
parent 6f11b72cbb
commit b529417940
13 changed files with 1038 additions and 14 deletions

View File

@@ -27,12 +27,16 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
changer le dossier d'enregistrement.
- Journal d'évènements horodaté, persistant et diffusé en direct.
- Reconnexion automatique de bout en bout (agent → serveur, agent → OBS, dashboard → serveur).
- [Pause automatique pendant les shows privés](#pause-automatique-pendant-les-shows-privés)
(Stripchat), avec reprise et rappel du plein écran au retour du flux public.
## Prérequis
- Node.js **22+** sur le serveur et sur chaque VM (le serveur utilise `node:sqlite`).
- OBS **28+** sur chaque VM, avec *Outils → Paramètres du serveur WebSocket* activé.
Note le port (4455 par défaut) et le mot de passe.
- Pour le rappel du plein écran sous Ubuntu : `xdotool` et une session X11
(voir [Prérequis pour le rappel du plein écran](#prérequis-pour-le-rappel-du-plein-écran)).
## Démarrage rapide (développement)
@@ -108,6 +112,65 @@ planifiée « à l'ouverture de session » fournie :
Les paramètres OBS édités dans le dashboard sont poussés à chaud vers l'agent : pas
besoin de se connecter à la VM pour changer un mot de passe obs-websocket.
## Pause automatique pendant les shows privés
Quand un streamer bascule en show privé, le flux public est remplacé par un écran
d'attente : l'enregistrement continue mais ne capte plus rien d'utile, et le lecteur
sort du plein écran. L'agent peut surveiller le statut du streamer et réagir seul.
Configuration par agent, dans *Configuration → Surveillance du stream* :
| Réglage | Effet |
| --- | --- |
| Pseudo du streamer | Celui de l'URL de sa page |
| Intervalle de sonde | Fréquence d'interrogation de l'API (10 s par défaut, plancher 3 s) |
| Lectures avant pause | Lectures « privé » consécutives exigées avant d'agir (2 par défaut) |
| Statuts « privé » | Statuts déclenchant la pause — retire `groupShow` pour continuer à enregistrer les shows de groupe |
| Touche / fenêtre / délai | Raccourci plein écran à renvoyer au lecteur après le show |
### Ce que fait l'agent
1. Il interroge `GET /api/front/v2/models/username/{pseudo}/cam` et lit `user.user.status`.
Valeurs relevées en production : `public`, `private`, `p2p`, `groupShow`, `idle`.
2. Statut privé confirmé → `PauseRecord`, en mémorisant que **c'est lui** qui a mis en pause.
3. Retour au public → `ResumeRecord`, puis envoi de la touche plein écran après le délai
configuré, le temps que le lecteur ait rechargé le flux.
Trois garde-fous, parce qu'une automatisation qui coupe un enregistrement au mauvais
moment coûte plus cher que quelques secondes d'écran d'attente enregistrées :
- **La pause exige plusieurs lectures consécutives, la reprise agit immédiatement.** Une
fausse pause perd du contenu réel ; une fausse reprise ne coûte rien.
- **Une sonde en échec ne déclenche jamais rien.** API injoignable ou réponse inattendue :
l'agent conserve le dernier état connu et ne touche pas à l'enregistrement.
- **Une pause manuelle n'est jamais reprise automatiquement.** L'agent ne reprend que ce
qu'il a lui-même mis en pause.
Un passage `hors-ligne` (`idle`) ne provoque ni pause ni reprise : seul le retour effectif
du flux public relance l'enregistrement.
### Prérequis pour le rappel du plein écran
L'envoi de touche se fait au niveau du système, pas via OBS.
| OS | Mécanisme | À prévoir |
| --- | --- | --- |
| Ubuntu | `xdotool windowactivate` + XTEST | `apt install xdotool`, session **X11** (pas Wayland), `DISPLAY` accessible à l'agent |
| Windows | `SetForegroundWindow` + `SendKeys` | L'agent doit tourner dans la session interactive — d'où la tâche planifiée plutôt qu'un service |
| macOS | AppleScript System Events | Autorisation Accessibilité (prévu pour le développement) |
Dans les deux cas la fenêtre du lecteur passe **au premier plan** : les navigateurs
ignorent les évènements clavier synthétiques envoyés sans focus (`XSendEvent`). Sans
conséquence sur une VM d'enregistrement dédiée, gênant si quelqu'un s'en sert en même
temps.
Le bouton ⛶ sur la fiche de l'agent renvoie la touche à la demande, et ⟳ force une sonde
immédiate — les deux servent à valider le titre de fenêtre sans attendre un vrai show privé.
Si le rappel du plein écran s'avère fragile sur ta VM, l'alternative sans clavier est de
lancer le navigateur en mode kiosque (`chromium --kiosk`) : il n'y a alors plus de plein
écran à restaurer.
## API HTTP
Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de session>`.
@@ -128,7 +191,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.start`,
`record.stop`, `record.pause`, `record.resume`, `record.split`, `stream.start`,
`stream.stop`, `scene.set`, `profile.set`, `collection.set`, `recordDirectory.set`,
`agent.ping`.
`watch.check`, `hotkey.fullscreen`, `agent.ping`.
## Structure
@@ -142,7 +205,9 @@ Actions disponibles : `obs.connect`, `obs.disconnect`, `obs.refresh`, `record.st
Points d'entrée utiles : [packages/shared/src/index.ts](packages/shared/src/index.ts)
(le protocole), [packages/server/src/hub.ts](packages/server/src/hub.ts) (état central et
dispatch), [packages/agent/src/obs.ts](packages/agent/src/obs.ts) (traduction
action → obs-websocket).
action → obs-websocket), [packages/agent/src/watcher.ts](packages/agent/src/watcher.ts)
(sonde de statut et machine à états pause/reprise),
[packages/agent/src/hotkey.ts](packages/agent/src/hotkey.ts) (envoi de touche par OS).
## Sécurité

View File

@@ -0,0 +1,188 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
export interface HotkeyRequest {
/** Touche à envoyer : « f », « F11 », « space »… */
key: string;
/** Fragment du titre de la fenêtre cible (insensible à la casse). */
windowMatch: string;
}
export interface HotkeyResult {
method: string;
window?: string;
}
/**
* Jeu de touches autorisé. Ces valeurs viennent de la configuration serveur et
* finissent dans une ligne de commande : on refuse tout ce qui sort du lot
* plutôt que d'échapper au cas par cas.
*/
const KEY_PATTERN = /^(?:[A-Za-z0-9]|F[1-9]|F1[0-2]|space|Return|Escape|Tab)$/;
function assertKey(key: string): string {
if (!KEY_PATTERN.test(key)) {
throw new Error(
`Touche « ${key} » non autorisée (lettres, chiffres, F1-F12, space, Return, Escape, Tab)`,
);
}
return key;
}
/**
* Envoie une touche au lecteur vidéo, par exemple pour rebasculer en plein
* écran après un show privé.
*
* Limite assumée : sous X11 comme sous Windows, la fenêtre cible doit passer au
* premier plan. Les navigateurs ignorent les évènements clavier synthétiques
* envoyés par XSendEvent (`xdotool key --window`), donc on active la fenêtre et
* on injecte la touche via XTEST. Sur une VM d'enregistrement dédiée c'est sans
* conséquence, mais ça vole le focus si quelqu'un est en train de s'en servir.
*/
export async function sendHotkey(request: HotkeyRequest): Promise<HotkeyResult> {
const key = assertKey(request.key);
const match = request.windowMatch.trim();
if (!match) throw new Error('Aucun titre de fenêtre à cibler (windowMatch vide)');
switch (process.platform) {
case 'linux':
return sendLinux(key, match);
case 'win32':
return sendWindows(key, match);
case 'darwin':
return sendDarwin(key, match);
default:
throw new Error(`Envoi de touche non supporté sur ${process.platform}`);
}
}
// --- X11 --------------------------------------------------------------------
async function sendLinux(key: string, match: string): Promise<HotkeyResult> {
const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' };
let ids: string[];
try {
const { stdout } = await run(
'xdotool',
['search', '--onlyvisible', '--name', match],
{ env, timeout: 5000 },
);
ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
throw new Error('xdotool est absent — installe-le : apt install xdotool');
}
// xdotool sort en code 1 quand rien ne correspond.
throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`);
}
if (ids.length === 0) throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`);
// La dernière fenêtre listée est en général la plus récemment mappée.
const windowId = ids[ids.length - 1]!;
let title = windowId;
try {
const { stdout } = await run('xdotool', ['getwindowname', windowId], { env, timeout: 5000 });
title = stdout.trim() || windowId;
} catch {
/* le titre n'est qu'informatif */
}
await run('xdotool', ['windowactivate', '--sync', windowId], { env, timeout: 5000 });
await run('xdotool', ['key', '--clearmodifiers', key], { env, timeout: 5000 });
return { method: 'xdotool', window: title };
}
// --- Windows ----------------------------------------------------------------
/** Échappe une valeur pour une chaîne littérale PowerShell entre apostrophes. */
function psLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
async function sendWindows(key: string, match: string): Promise<HotkeyResult> {
// SendKeys interprète certains caractères ; les touches nommées se notent {F11}.
const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`;
const script = `
$ErrorActionPreference = 'Stop'
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class Win32 {
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
}
"@
$needle = ${psLiteral(match)}
$found = [IntPtr]::Zero
$foundTitle = ''
$callback = [Win32+EnumWindowsProc]{
param($hWnd, $lParam)
if (-not [Win32]::IsWindowVisible($hWnd)) { return $true }
$len = [Win32]::GetWindowTextLength($hWnd)
if ($len -eq 0) { return $true }
$sb = New-Object System.Text.StringBuilder($len + 1)
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity)
$title = $sb.ToString()
if ($title -like ('*' + $needle + '*')) { $script:found = $hWnd; $script:foundTitle = $title; return $false }
return $true
}
[void][Win32]::EnumWindows($callback, [IntPtr]::Zero)
if ($script:found -eq [IntPtr]::Zero) { Write-Error ('Aucune fenetre ne correspond a ' + $needle); exit 1 }
[void][Win32]::ShowWindow($script:found, 9)
[void][Win32]::SetForegroundWindow($script:found)
Start-Sleep -Milliseconds 300
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait(${psLiteral(sendKeysArg)})
Write-Output $script:foundTitle
`;
// -EncodedCommand évite tout problème de guillemets entre cmd.exe et PowerShell.
const encoded = Buffer.from(script, 'utf16le').toString('base64');
try {
const { stdout } = await run(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
{ timeout: 20_000, windowsHide: true },
);
return { method: 'SendKeys', window: stdout.trim() || undefined };
} catch (err) {
const stderr = (err as { stderr?: string }).stderr?.trim();
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
}
}
// --- macOS (confort de développement) ---------------------------------------
async function sendDarwin(key: string, match: string): Promise<HotkeyResult> {
const script = `
tell application "System Events"
set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}")
if (count of matches) = 0 then error "Aucun processus ne correspond"
set target to item 1 of matches
set frontmost of target to true
delay 0.3
keystroke "${key.toLowerCase()}"
end tell`;
try {
await run('osascript', ['-e', script], { timeout: 15_000 });
return { method: 'osascript', window: match };
} catch (err) {
const stderr = (err as { stderr?: string }).stderr?.trim();
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
}
}

View File

@@ -2,14 +2,24 @@
import os from 'node:os';
import { WebSocket } from 'ws';
import type {
AgentAction,
AgentStatus,
AgentToServer,
LogLevel,
ServerToAgent,
WatchSettings,
} from '@stream-control/shared';
import {
DEFAULT_WATCH_SETTINGS,
PROTOCOL_VERSION,
detectPlatform,
emptyStatus,
normalizeWatchSettings,
safeJsonParse,
} from '@stream-control/shared';
import { PROTOCOL_VERSION, detectPlatform, emptyStatus, safeJsonParse } from '@stream-control/shared';
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
const AGENT_VERSION = '0.1.0';
@@ -26,6 +36,16 @@ try {
const obs = new ObsController(config.obs);
const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
recordState: () => obs.recordState(),
pauseRecording: async () => {
await obs.execute('record.pause');
},
resumeRecording: async () => {
await obs.execute('record.resume');
},
});
let socket: WebSocket | null = null;
let statusTimer: NodeJS.Timeout | null = null;
let reconnectDelay = RECONNECT_MIN_MS;
@@ -47,6 +67,7 @@ function report(level: LogLevel, message: string): void {
}
obs.on('log', (level: LogLevel, message: string) => report(level, message));
watcher.on('log', (level: LogLevel, message: string) => report(level, message));
function connect(): void {
if (shuttingDown) return;
@@ -114,6 +135,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
}
obs.applySettings(message.obs);
applyWatchSettings(message.watch);
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
if (message.autoConnectObs && !obs.isConnected) {
@@ -125,6 +147,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
case 'config': {
obs.applySettings(message.obs);
applyWatchSettings(message.watch);
if (message.autoConnectObs && !obs.isConnected) {
obs.connect().catch((err: Error) => report('warn', err.message));
}
@@ -133,7 +156,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
case 'command': {
try {
const data = await obs.execute(message.action, message.params ?? {});
const data = await runAction(message.action, message.params ?? {});
send({ type: 'result', requestId: message.requestId, ok: true, data });
void pushStatus(); // état rafraîchi immédiatement après l'action
} catch (err) {
@@ -151,6 +174,25 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
}
}
/**
* Aiguille une action : la surveillance et le clavier sont gérés par l'agent,
* tout le reste part vers obs-websocket.
*/
async function runAction(action: AgentAction, params: Record<string, unknown>): Promise<unknown> {
switch (action) {
case 'watch.check':
return watcher.checkNow();
case 'hotkey.fullscreen':
return watcher.restoreFullscreen();
default:
return obs.execute(action, params);
}
}
function applyWatchSettings(raw: WatchSettings | undefined): void {
watcher.applySettings(normalizeWatchSettings(raw ?? DEFAULT_WATCH_SETTINGS));
}
// --- Boucle de statut --------------------------------------------------------
async function buildStatus(): Promise<AgentStatus> {
@@ -161,6 +203,7 @@ async function buildStatus(): Promise<AgentStatus> {
return {
...emptyStatus(),
...snapshot,
watch: watcher.snapshot,
lastRecordingPath: obs.recordingPath,
systemCpu: cpuUsagePercent(),
systemMemoryUsed: memory.used,
@@ -199,6 +242,7 @@ async function shutdown(signal: string): Promise<void> {
shuttingDown = true;
console.log(`\n${signal} reçu, arrêt de l'agent…`);
stopStatusLoop();
watcher.stop();
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
await obs.disconnect().catch(() => undefined);
socket?.close(1000, 'Arrêt de l\'agent');

View File

@@ -2,6 +2,12 @@ import { EventEmitter } from 'node:events';
import OBSWebSocket from 'obs-websocket-js';
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
/**
* Actions relevant d'OBS. `watch.*` et `hotkey.*` sont traitées en amont par
* l'agent : elles ne concernent pas la session obs-websocket.
*/
export type ObsAction = Exclude<AgentAction, 'watch.check' | 'hotkey.fullscreen'>;
type ObsSnapshot = Pick<
AgentStatus,
| 'obsConnected'
@@ -149,9 +155,16 @@ export class ObsController extends EventEmitter {
this.reconnectTimer = null;
}
/** État d'enregistrement à la demande, sans passer par l'instantané complet. */
async recordState(): Promise<{ active: boolean; paused: boolean }> {
if (!this.connected) return { active: false, paused: false };
const status = await this.obs.call('GetRecordStatus');
return { active: status.outputActive, paused: status.outputPaused };
}
// --- Exécution des actions du protocole ---------------------------------
async execute(action: AgentAction, params: Record<string, unknown> = {}): Promise<unknown> {
async execute(action: ObsAction, params: Record<string, unknown> = {}): Promise<unknown> {
switch (action) {
case 'obs.connect':
await this.connect();

View File

@@ -0,0 +1,284 @@
import { EventEmitter } from 'node:events';
import type { LogLevel, StreamState, WatchSettings, WatchState } from '@stream-control/shared';
import { emptyWatchState, mapStreamStatus } from '@stream-control/shared';
import { sendHotkey } from './hotkey.ts';
const PROBE_TIMEOUT_MS = 8000;
const BROWSER_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
export interface ProbeResult {
/** Statut brut renvoyé par la plateforme. */
raw: string;
state: StreamState;
}
/**
* Sonde Stripchat.
*
* Le champ autoritatif est `user.user.status` sur
* `/api/front/v2/models/username/{pseudo}/cam`. Valeurs relevées en production :
* `public`, `private`, `p2p`, `groupShow`, `idle`.
* (`cam.privateMode` existe mais reste vide y compris pendant un privé : ne pas
* s'en servir.)
*/
export async function probeStripchat(
username: string,
privateStatuses: string[],
): Promise<ProbeResult> {
const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent(
username,
)}/cam`;
const response = await fetch(url, {
headers: { 'user-agent': BROWSER_UA, accept: 'application/json' },
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
if (response.status === 404) return { raw: 'notFound', state: 'offline' };
if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`);
const payload = (await response.json()) as {
user?: { user?: { status?: string; isLive?: boolean } };
};
const raw = payload?.user?.user?.status;
if (typeof raw !== 'string') {
throw new Error('Réponse Stripchat inattendue : user.user.status absent');
}
return { raw, state: mapStreamStatus(raw, privateStatuses) };
}
/** Ce que le surveillant doit pouvoir demander à OBS. */
export interface WatchActions {
recordState(): Promise<{ active: boolean; paused: boolean }>;
pauseRecording(): Promise<void>;
resumeRecording(): Promise<void>;
}
/**
* Surveille le statut du stream source et met l'enregistrement en pause pendant
* les shows privés, puis le reprend et rappelle le plein écran à la reprise du
* flux public.
*
* Deux garde-fous délibérés :
* - la mise en pause exige N lectures « privé » consécutives, la reprise agit
* immédiatement (une fausse pause perd du contenu, une fausse reprise ne
* coûte que quelques secondes d'écran d'attente) ;
* - une erreur de sonde ne déclenche jamais d'action : on conserve l'état connu.
*/
export class StreamWatcher extends EventEmitter {
private settings: WatchSettings;
private state: WatchState;
private timer: NodeJS.Timeout | null = null;
private fullscreenTimer: NodeJS.Timeout | null = null;
private ticking = false;
/** Le flux public a été interrompu : il faudra rappeler le plein écran. */
private fullscreenPending = false;
constructor(
settings: WatchSettings,
private readonly actions: WatchActions,
) {
super();
this.settings = settings;
this.state = emptyWatchState(settings);
}
get snapshot(): WatchState {
return { ...this.state };
}
applySettings(settings: WatchSettings): void {
const restart =
settings.enabled !== this.settings.enabled ||
settings.username !== this.settings.username ||
settings.provider !== this.settings.provider ||
settings.pollIntervalMs !== this.settings.pollIntervalMs;
const identityChanged =
settings.username !== this.settings.username || settings.provider !== this.settings.provider;
this.settings = settings;
this.state.enabled = settings.enabled;
this.state.provider = settings.provider;
this.state.username = settings.username;
if (identityChanged) {
this.state.state = 'unknown';
this.state.rawStatus = undefined;
this.state.since = Date.now();
this.state.pendingConfirmations = 0;
this.state.autoPaused = false;
this.fullscreenPending = false;
}
if (restart) this.start();
}
start(): void {
this.stop();
if (!this.settings.enabled || !this.settings.username) return;
this.emit(
'log',
'info',
`Surveillance de « ${this.settings.username} » (${this.settings.provider}) toutes les ${
this.settings.pollIntervalMs / 1000
} s`,
);
void this.tick();
this.timer = setInterval(() => void this.tick(), this.settings.pollIntervalMs);
this.timer.unref?.();
}
stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
this.fullscreenTimer = null;
}
/** Sonde immédiate, utilisée par l'action `watch.check`. */
async checkNow(): Promise<WatchState> {
await this.tick();
return this.snapshot;
}
private async tick(): Promise<void> {
if (this.ticking) return; // une sonde lente ne doit pas s'empiler
if (!this.settings.enabled || !this.settings.username) return;
this.ticking = true;
try {
const result = await probeStripchat(this.settings.username, this.settings.privateStatuses);
this.state.lastCheckedAt = Date.now();
this.state.lastError = undefined;
this.state.rawStatus = result.raw;
await this.transition(result.state);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.state.lastCheckedAt = Date.now();
// Sonde en échec : on ne met surtout pas l'enregistrement en pause.
if (this.state.lastError !== message) {
this.emit('log', 'warn', `Sonde « ${this.settings.username} » en échec : ${message}`);
}
this.state.lastError = message;
} finally {
this.ticking = false;
}
}
private async transition(next: StreamState): Promise<void> {
const previous = this.state.state;
if (next !== previous) {
this.state.state = next;
this.state.since = Date.now();
this.emit(
'log',
'info',
`Stream « ${this.settings.username} » : ${label(previous)}${label(next)} (${this.state.rawStatus})`,
);
}
this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0;
if (next === 'private') {
// Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît.
this.fullscreenPending = true;
await this.handlePrivate();
return;
}
if (next === 'public') await this.handlePublic();
// 'offline' / 'unknown' : on ne touche à rien, l'opérateur reste maître.
}
private async handlePrivate(): Promise<void> {
if (!this.settings.pauseOnPrivate || this.state.autoPaused) return;
if (this.state.pendingConfirmations < this.settings.confirmations) return;
try {
const record = await this.actions.recordState();
if (!record.active || record.paused) return;
await this.actions.pauseRecording();
this.state.autoPaused = true;
this.emit('log', 'info', 'Show privé détecté : enregistrement mis en pause');
} catch (err) {
this.emit(
'log',
'error',
`Mise en pause automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
);
}
}
private async handlePublic(): Promise<void> {
if (this.state.autoPaused && this.settings.resumeOnPublic) {
try {
const record = await this.actions.recordState();
if (record.active && record.paused) {
await this.actions.resumeRecording();
this.emit('log', 'info', 'Flux public rétabli : enregistrement repris');
}
} catch (err) {
this.emit(
'log',
'error',
`Reprise automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
this.state.autoPaused = false;
}
}
if (this.fullscreenPending && this.settings.fullscreen.enabled) {
this.fullscreenPending = false;
this.scheduleFullscreen();
} else {
this.fullscreenPending = false;
}
}
/** Laisse au lecteur le temps de recharger le flux public avant d'envoyer la touche. */
private scheduleFullscreen(): void {
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
this.fullscreenTimer = setTimeout(() => {
this.fullscreenTimer = null;
void this.restoreFullscreen();
}, this.settings.fullscreen.delayMs);
this.fullscreenTimer.unref?.();
}
async restoreFullscreen(): Promise<{ method: string; window?: string }> {
const { key, windowMatch } = this.settings.fullscreen;
try {
const result = await sendHotkey({ key, windowMatch });
this.emit(
'log',
'info',
`Plein écran rappelé : touche « ${key} » envoyée à « ${result.window ?? windowMatch} »`,
);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emit('log', 'warn', `Rappel du plein écran impossible : ${message}`);
throw err;
}
}
}
function label(state: StreamState): string {
switch (state) {
case 'public':
return 'public';
case 'private':
return 'privé';
case 'offline':
return 'hors-ligne';
default:
return 'inconnu';
}
}
export type { LogLevel };

View File

@@ -110,6 +110,7 @@ export function handleAgentConnection(
obs: record.obs,
statusIntervalMs: config.statusIntervalMs,
autoConnectObs: record.autoConnectObs,
watch: record.watch,
});
break;
}

View File

@@ -1,7 +1,12 @@
import { randomUUID } from 'node:crypto';
import { Router } from 'express';
import type { ObsSettings } from '@stream-control/shared';
import { AGENT_ACTIONS, DEFAULT_OBS_SETTINGS, isAgentAction } from '@stream-control/shared';
import {
AGENT_ACTIONS,
DEFAULT_OBS_SETTINGS,
isAgentAction,
normalizeWatchSettings,
} from '@stream-control/shared';
import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo } from './db.ts';
@@ -81,6 +86,7 @@ api.patch('/agents/:id', (req, res) => {
obs,
autoConnectObs:
typeof req.body?.autoConnectObs === 'boolean' ? req.body.autoConnectObs : record.autoConnectObs,
watch: normalizeWatchSettings(req.body?.watch ?? record.watch),
notes: typeof req.body?.notes === 'string' ? req.body.notes : record.notes,
});

View File

@@ -1,8 +1,19 @@
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { LogEntry, LogLevel, ObsSettings, Platform } from '@stream-control/shared';
import { DEFAULT_OBS_SETTINGS } from '@stream-control/shared';
import type {
LogEntry,
LogLevel,
ObsSettings,
Platform,
WatchSettings,
} from '@stream-control/shared';
import {
DEFAULT_OBS_SETTINGS,
DEFAULT_WATCH_SETTINGS,
normalizeWatchSettings,
safeJsonParse,
} from '@stream-control/shared';
import { config } from './config.ts';
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
@@ -40,6 +51,19 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs (ts DESC);
`);
/** Migrations additives : sûres à rejouer sur une base déjà peuplée. */
function addColumnIfMissing(table: string, column: string, definition: string): void {
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{
name: string;
}>;
if (columns.some((entry) => entry.name === column)) return;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
// Surveillance du stream source : stockée en JSON, le schéma évolue plus vite
// que la table (nouveaux fournisseurs, nouveaux statuts).
addColumnIfMissing('agents', 'watch_json', 'TEXT');
export interface AgentRow {
id: string;
name: string;
@@ -51,6 +75,7 @@ export interface AgentRow {
obs_port: number;
obs_password: string;
auto_connect: number;
watch_json: string | null;
notes: string | null;
created_at: number;
last_seen_at: number | null;
@@ -65,6 +90,7 @@ export interface AgentRecord {
tokenHash: string;
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
notes: string | null;
createdAt: number;
lastSeenAt: number | null;
@@ -84,6 +110,9 @@ function toRecord(row: AgentRow): AgentRecord {
password: row.obs_password ?? '',
},
autoConnectObs: Number(row.auto_connect) === 1,
watch: normalizeWatchSettings(
row.watch_json ? safeJsonParse<WatchSettings>(row.watch_json) : DEFAULT_WATCH_SETTINGS,
),
notes: row.notes,
createdAt: Number(row.created_at),
lastSeenAt: row.last_seen_at === null ? null : Number(row.last_seen_at),
@@ -96,8 +125,9 @@ const stmts = {
getAgentByTokenHash: db.prepare('SELECT * FROM agents WHERE token_hash = ?'),
insertAgent: db.prepare(`
INSERT INTO agents (id, name, hostname, platform, agent_version, token_hash,
obs_host, obs_port, obs_password, auto_connect, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
obs_host, obs_port, obs_password, auto_connect, watch_json,
notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
updateIdentity: db.prepare(`
UPDATE agents SET hostname = ?, platform = ?, agent_version = ?, last_seen_at = ?
@@ -105,7 +135,7 @@ const stmts = {
`),
updateSettings: db.prepare(`
UPDATE agents SET name = ?, obs_host = ?, obs_port = ?, obs_password = ?,
auto_connect = ?, notes = ?
auto_connect = ?, watch_json = ?, notes = ?
WHERE id = ?
`),
touchAgent: db.prepare('UPDATE agents SET last_seen_at = ? WHERE id = ?'),
@@ -147,6 +177,7 @@ export const agentsRepo = {
agentVersion?: string | null;
obs?: Partial<ObsSettings>;
autoConnectObs?: boolean;
watch?: WatchSettings;
notes?: string | null;
}): AgentRecord {
const obs = { ...DEFAULT_OBS_SETTINGS, ...input.obs };
@@ -161,6 +192,7 @@ export const agentsRepo = {
obs.port,
obs.password,
input.autoConnectObs === false ? 0 : 1,
JSON.stringify(normalizeWatchSettings(input.watch ?? DEFAULT_WATCH_SETTINGS)),
input.notes ?? null,
Date.now(),
);
@@ -188,6 +220,7 @@ export const agentsRepo = {
name: string;
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
notes: string | null;
},
): void {
@@ -197,6 +230,7 @@ export const agentsRepo = {
settings.obs.port,
settings.obs.password,
settings.autoConnectObs ? 1 : 0,
JSON.stringify(normalizeWatchSettings(settings.watch)),
settings.notes,
id,
);

View File

@@ -135,6 +135,7 @@ class Hub {
type: 'config',
obs: record.obs,
autoConnectObs: record.autoConnectObs,
watch: record.watch,
};
connection.socket.send(JSON.stringify(message));
}
@@ -171,6 +172,7 @@ class Hub {
// Le mot de passe OBS n'est jamais renvoyé au navigateur.
obs: { ...record.obs, password: record.obs.password ? '********' : '' },
autoConnectObs: record.autoConnectObs,
watch: record.watch,
notes: record.notes,
status: this.statusOf(record.id),
};

View File

@@ -43,6 +43,8 @@ export const AGENT_ACTIONS = [
'profile.set',
'collection.set',
'recordDirectory.set',
'watch.check',
'hotkey.fullscreen',
'agent.ping',
] as const;
@@ -60,6 +62,97 @@ export interface AgentActionParams {
'recordDirectory.set': { directory: string };
}
// ---------------------------------------------------------------------------
// Surveillance du statut d'un stream (pause auto pendant les shows privés)
// ---------------------------------------------------------------------------
export const WATCH_PROVIDERS = ['stripchat'] as const;
export type WatchProvider = (typeof WATCH_PROVIDERS)[number];
/** État normalisé du stream surveillé. */
export type StreamState = 'public' | 'private' | 'offline' | 'unknown';
export interface FullscreenSettings {
/** Rappeler le plein écran à la fin d'un show privé. */
enabled: boolean;
/** Touche à envoyer au lecteur (raccourci plein écran, « f » sur Stripchat). */
key: string;
/** Fragment de titre de fenêtre identifiant le lecteur (insensible à la casse). */
windowMatch: string;
/** Délai avant l'envoi, le temps que le flux public soit rechargé. */
delayMs: number;
}
export interface WatchSettings {
enabled: boolean;
provider: WatchProvider;
/** Pseudo du streamer, tel qu'il apparaît dans l'URL de sa page. */
username: string;
pollIntervalMs: number;
/**
* Statuts bruts de l'API considérés comme « pas de flux public ».
* Valeurs observées côté Stripchat : public, private, p2p, groupShow, idle.
*/
privateStatuses: string[];
/**
* Lectures « privé » consécutives exigées avant de mettre en pause.
* Asymétrique volontairement : une fausse pause coûte du contenu perdu,
* une fausse reprise ne coûte que quelques secondes d'écran d'attente.
*/
confirmations: number;
pauseOnPrivate: boolean;
resumeOnPublic: boolean;
fullscreen: FullscreenSettings;
}
export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
enabled: false,
provider: 'stripchat',
username: '',
pollIntervalMs: 10_000,
privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow'],
confirmations: 2,
pauseOnPrivate: true,
resumeOnPublic: true,
fullscreen: {
enabled: true,
key: 'f',
windowMatch: 'Stripchat',
delayMs: 4000,
},
};
/** État courant de la surveillance, remonté avec le statut de l'agent. */
export interface WatchState {
enabled: boolean;
provider: WatchProvider;
username: string;
state: StreamState;
/** Statut brut renvoyé par l'API, utile pour diagnostiquer un mapping. */
rawStatus?: string;
/** Depuis quand l'état normalisé est stable. */
since: number;
lastCheckedAt: number;
lastError?: string;
/** Vrai si c'est la surveillance — et non l'opérateur — qui a mis en pause. */
autoPaused: boolean;
/** Lectures « privé » accumulées, en attente du seuil de confirmation. */
pendingConfirmations: number;
}
export function emptyWatchState(settings: WatchSettings): WatchState {
return {
enabled: settings.enabled,
provider: settings.provider,
username: settings.username,
state: 'unknown',
since: Date.now(),
lastCheckedAt: 0,
autoPaused: false,
pendingConfirmations: 0,
};
}
// ---------------------------------------------------------------------------
// Statut remonté par un agent
// ---------------------------------------------------------------------------
@@ -102,6 +195,9 @@ export interface AgentStatus {
diskFreeBytes?: number;
diskTotalBytes?: number;
/** Surveillance du stream source, absente si elle n'est pas configurée. */
watch?: WatchState;
updatedAt: number;
}
@@ -181,6 +277,7 @@ export interface WelcomeMessage {
statusIntervalMs: number;
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
autoConnectObs: boolean;
watch: WatchSettings;
}
export interface CommandMessage {
@@ -194,6 +291,7 @@ export interface ConfigMessage {
type: 'config';
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
}
export interface PingMessage {
@@ -218,6 +316,7 @@ export interface AgentView {
createdAt: number;
obs: ObsSettings;
autoConnectObs: boolean;
watch: WatchSettings;
notes: string | null;
status: AgentStatus;
}
@@ -255,3 +354,59 @@ export function safeJsonParse<T>(raw: string): T | null {
return null;
}
}
/**
* Normalise une configuration de surveillance venue du réseau ou de la base :
* champs manquants complétés, valeurs numériques bornées.
*/
export function normalizeWatchSettings(raw: unknown): WatchSettings {
const input = (raw ?? {}) as Partial<WatchSettings>;
const base = DEFAULT_WATCH_SETTINGS;
const fullscreen = (input.fullscreen ?? {}) as Partial<FullscreenSettings>;
const clamp = (value: unknown, fallback: number, min: number, max: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.round(parsed), min), max);
};
const statuses = Array.isArray(input.privateStatuses)
? input.privateStatuses.filter((s): s is string => typeof s === 'string' && s.trim() !== '')
: base.privateStatuses;
return {
enabled: input.enabled === true,
provider: WATCH_PROVIDERS.includes(input.provider as WatchProvider)
? (input.provider as WatchProvider)
: base.provider,
username: typeof input.username === 'string' ? input.username.trim() : base.username,
// Plancher à 3 s : inutile de marteler l'API, un show privé dure des minutes.
pollIntervalMs: clamp(input.pollIntervalMs, base.pollIntervalMs, 3000, 300_000),
privateStatuses: statuses.length > 0 ? statuses : base.privateStatuses,
confirmations: clamp(input.confirmations, base.confirmations, 1, 10),
pauseOnPrivate: input.pauseOnPrivate !== false,
resumeOnPublic: input.resumeOnPublic !== false,
fullscreen: {
enabled: fullscreen.enabled !== false,
key:
typeof fullscreen.key === 'string' && fullscreen.key.trim()
? fullscreen.key.trim()
: base.fullscreen.key,
windowMatch:
typeof fullscreen.windowMatch === 'string'
? fullscreen.windowMatch.trim()
: base.fullscreen.windowMatch,
delayMs: clamp(fullscreen.delayMs, base.fullscreen.delayMs, 0, 120_000),
},
};
}
/** Traduit un statut brut de l'API en état normalisé. */
export function mapStreamStatus(raw: string | undefined, privateStatuses: string[]): StreamState {
if (!raw) return 'unknown';
const value = raw.toLowerCase();
if (privateStatuses.some((status) => status.toLowerCase() === value)) return 'private';
if (value === 'public') return 'public';
// idle / off / offline / deleted : le modèle ne diffuse pas.
return 'offline';
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import type { AgentAction, AgentView } from '@stream-control/shared';
import type { AgentAction, AgentView, WatchState } from '@stream-control/shared';
import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format';
interface Props {
@@ -26,13 +26,16 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
const busy = pending !== null;
const obsReady = agent.online && status.obsConnected;
const watch = status.watch;
const pausedLabel = watch?.autoPaused ? 'Pause · show privé' : 'En pause';
const state = !agent.online
? { label: 'Hors-ligne', tone: 'offline' as const }
: !status.obsConnected
? { label: 'OBS déconnecté', tone: 'warn' as const }
: status.recording
? {
label: status.recordPaused ? 'En pause' : 'Enregistre',
label: status.recordPaused ? pausedLabel : 'Enregistre',
tone: status.recordPaused ? ('warn' as const) : ('rec' as const),
}
: { label: 'Prêt', tone: 'ok' as const };
@@ -65,6 +68,15 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
<p className="error small">{status.obsError}</p>
)}
{watch?.enabled && (
<WatchStrip
watch={watch}
busy={busy}
onCheck={() => void run('watch.check')}
onFullscreen={() => void run('hotkey.fullscreen')}
/>
)}
<div className="metrics">
<Metric label="Durée" value={formatTimecode(status.recordTimecode)} mono />
<Metric label="Fichier" value={formatBytes(status.recordBytes)} />
@@ -150,6 +162,56 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
);
}
const WATCH_LABELS: Record<WatchState['state'], { text: string; tone: string }> = {
public: { text: 'public', tone: 'ok' },
private: { text: 'show privé', tone: 'rec' },
offline: { text: 'hors-ligne', tone: 'offline' },
unknown: { text: 'inconnu', tone: 'offline' },
};
function WatchStrip({
watch,
busy,
onCheck,
onFullscreen,
}: {
watch: WatchState;
busy: boolean;
onCheck: () => void;
onFullscreen: () => void;
}) {
const info = WATCH_LABELS[watch.state];
const stale = watch.lastCheckedAt > 0 && Date.now() - watch.lastCheckedAt > 60_000;
return (
<div className="watch">
<div className="watch-head">
<span className={`badge ${info.tone}`}>{info.text}</span>
<span className="watch-name" title={`${watch.provider} · ${watch.username}`}>
{watch.username || '(aucun pseudo)'}
</span>
<div className="spacer" />
<button className="icon" disabled={busy} onClick={onCheck} title="Sonder maintenant">
</button>
<button className="icon" disabled={busy} onClick={onFullscreen} title="Remettre en plein écran">
</button>
</div>
{watch.lastError ? (
<span className="small error">Sonde en échec : {watch.lastError}</span>
) : (
<span className="small muted">
{watch.rawStatus ? `statut brut « ${watch.rawStatus} »` : 'pas encore sondé'}
{watch.autoPaused && ' · pause automatique active'}
{stale && ' · dernière sonde ancienne'}
</span>
)}
</div>
);
}
function Metric({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="metric">

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import type { AgentAction, AgentView } from '@stream-control/shared';
import type { AgentAction, AgentView, WatchSettings } from '@stream-control/shared';
import { api } from '../api';
interface Props {
@@ -19,6 +19,15 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
const [directory, setDirectory] = useState(agent.status.recordDirectory ?? '');
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [watch, setWatch] = useState<WatchSettings>(agent.watch);
function patchWatch(patch: Partial<WatchSettings>) {
setWatch((current) => ({ ...current, ...patch }));
}
function patchFullscreen(patch: Partial<WatchSettings['fullscreen']>) {
setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } }));
}
async function save() {
setBusy(true);
@@ -28,6 +37,7 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
notes,
autoConnectObs: autoConnect,
obs: { host, port: Number(port), password },
watch,
} as Partial<AgentView>);
notify('Configuration enregistrée');
onClose();
@@ -129,6 +139,127 @@ export function AgentSettings({ agent, onClose, onCommand, notify }: Props) {
</button>
</div>
<fieldset className="group">
<legend>Surveillance du stream</legend>
<label className="checkbox">
<input
type="checkbox"
checked={watch.enabled}
onChange={(event) => patchWatch({ enabled: event.target.checked })}
/>
<span>Mettre l'enregistrement en pause pendant les shows privés</span>
</label>
<div className="row">
<label className="field small-field">
<span>Plateforme</span>
<select
value={watch.provider}
onChange={(event) =>
patchWatch({ provider: event.target.value as WatchSettings['provider'] })
}
>
<option value="stripchat">Stripchat</option>
</select>
</label>
<label className="field grow">
<span>Pseudo du streamer</span>
<input
value={watch.username}
onChange={(event) => patchWatch({ username: event.target.value })}
placeholder="tel qu'il apparaît dans l'URL"
/>
</label>
</div>
<div className="row">
<label className="field grow">
<span>Intervalle de sonde (s)</span>
<input
value={String(Math.round(watch.pollIntervalMs / 1000))}
onChange={(event) =>
patchWatch({ pollIntervalMs: (Number(event.target.value) || 10) * 1000 })
}
inputMode="numeric"
/>
</label>
<label className="field grow">
<span>Lectures avant pause</span>
<input
value={String(watch.confirmations)}
onChange={(event) => patchWatch({ confirmations: Number(event.target.value) || 1 })}
inputMode="numeric"
/>
</label>
</div>
<label className="field">
<span>Statuts considérés comme « privé »</span>
<input
value={watch.privateStatuses.join(', ')}
onChange={(event) =>
patchWatch({
privateStatuses: event.target.value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
})
}
/>
<span className="muted small">
Statuts renvoyés par Stripchat : public, private, p2p, groupShow, idle. Retire
groupShow si tu veux continuer à enregistrer les shows de groupe.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={watch.fullscreen.enabled}
onChange={(event) => patchFullscreen({ enabled: event.target.checked })}
/>
<span>Rappeler le plein écran à la fin du show privé</span>
</label>
<div className="row">
<label className="field small-field">
<span>Touche</span>
<input
value={watch.fullscreen.key}
onChange={(event) => patchFullscreen({ key: event.target.value })}
/>
</label>
<label className="field grow">
<span>Titre de la fenêtre du lecteur</span>
<input
value={watch.fullscreen.windowMatch}
onChange={(event) => patchFullscreen({ windowMatch: event.target.value })}
placeholder="fragment du titre, ex. Stripchat"
/>
</label>
<label className="field small-field">
<span>Délai (s)</span>
<input
value={String(Math.round(watch.fullscreen.delayMs / 1000))}
onChange={(event) =>
patchFullscreen({ delayMs: (Number(event.target.value) || 0) * 1000 })
}
inputMode="numeric"
/>
</label>
</div>
<div className="row">
<button className="ghost" onClick={() => void onCommand(agent.id, 'watch.check')}>
Sonder maintenant
</button>
<button className="ghost" onClick={() => void onCommand(agent.id, 'hotkey.fullscreen')}>
Tester le plein écran
</button>
</div>
</fieldset>
<div className="danger-zone">
<div>
<strong>Jeton d'agent</strong>

View File

@@ -349,6 +349,45 @@ textarea:focus {
gap: 8px;
}
/* --- Bandeau de surveillance --- */
.watch {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
background: var(--panel-2);
border-radius: 8px;
}
.watch-head {
display: flex;
align-items: center;
gap: 8px;
}
.watch-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
fieldset.group {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 14px;
border: 1px solid var(--border);
border-radius: 8px;
}
fieldset.group > legend {
padding: 0 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.card-foot {
display: flex;
justify-content: space-between;