1137 lines
38 KiB
TypeScript
1137 lines
38 KiB
TypeScript
/**
|
|
* Protocole partagé entre le serveur de contrôle, les agents et le dashboard.
|
|
*
|
|
* Transport : WebSocket, messages JSON, un champ `type` discriminant.
|
|
* - agent <-> serveur : /ws/agent (l'agent initie la connexion sortante)
|
|
* - browser <- serveur : /ws/dashboard (flux de statut temps réel)
|
|
*/
|
|
|
|
export const PROTOCOL_VERSION = 1;
|
|
|
|
export type Platform = 'windows' | 'linux' | 'darwin' | 'unknown';
|
|
|
|
/** Paramètres de connexion à obs-websocket (plugin intégré à OBS >= 28). */
|
|
export interface ObsSettings {
|
|
host: string;
|
|
port: number;
|
|
/** Mot de passe obs-websocket ; chaîne vide si l'authentification est désactivée. */
|
|
password: string;
|
|
}
|
|
|
|
export const DEFAULT_OBS_SETTINGS: ObsSettings = {
|
|
host: '127.0.0.1',
|
|
port: 4455,
|
|
password: '',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actions pilotables sur un agent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const AGENT_ACTIONS = [
|
|
'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',
|
|
'watch.check',
|
|
'hotkey.fullscreen',
|
|
'browser.open',
|
|
'browser.close',
|
|
'browser.importSession',
|
|
'capture.start',
|
|
'capture.stop',
|
|
'preset.apply',
|
|
'agent.update',
|
|
'agent.ping',
|
|
] as const;
|
|
|
|
export type AgentAction = (typeof AGENT_ACTIONS)[number];
|
|
|
|
export function isAgentAction(value: unknown): value is AgentAction {
|
|
return typeof value === 'string' && (AGENT_ACTIONS as readonly string[]).includes(value);
|
|
}
|
|
|
|
/** Paramètres attendus par action (les autres actions n'en prennent aucun). */
|
|
export interface AgentActionParams {
|
|
'scene.set': { scene: string };
|
|
'profile.set': { profile: string };
|
|
'collection.set': { collection: string };
|
|
'recordDirectory.set': { directory: string };
|
|
/** Sans paramètre, l'agent applique le preset configuré côté serveur. */
|
|
'preset.apply': { presetId?: string; encoder?: RecordingEncoder };
|
|
}
|
|
|
|
/** Compte rendu de l'import de session (mode `bidi` uniquement). */
|
|
export interface SessionImportResult {
|
|
/** Profil Firefox personnel dont les cookies ont été copiés. */
|
|
sourceProfile: string;
|
|
/** Fichiers copiés : `cookies.sqlite`, son journal WAL, le stockage local… */
|
|
copied: string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Presets d'enregistrement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Un preset décrit un compromis qualité / charge CPU. Il se traduit en écritures
|
|
* dans le profil OBS courant (`SetProfileParameter`, section `SimpleOutput`) et
|
|
* en un réglage vidéo (`SetVideoSettings`).
|
|
*
|
|
* Deux limites d'OBS qu'aucun contournement propre ne lève :
|
|
* - les paramètres de profil sont relus à chaque démarrage d'enregistrement,
|
|
* donc un changement de qualité prend effet au prochain enregistrement ;
|
|
* - l'objet encodeur, lui, n'est instancié qu'au lancement d'OBS : changer
|
|
* `RecEncoder` exige un redémarrage d'OBS pour être réellement pris en compte.
|
|
*/
|
|
export interface RecordingEncoderInfo {
|
|
/** Valeur écrite dans `SimpleOutput/RecEncoder`. */
|
|
obsValue: string;
|
|
label: string;
|
|
/** Clé de profil pilotant le compromis vitesse/qualité ; null si l'encodeur n'en expose pas. */
|
|
speedKey: string | null;
|
|
/** Jeu de valeurs accepté par cette clé. */
|
|
speedFamily: SpeedFamily | null;
|
|
hint: string;
|
|
}
|
|
|
|
export type SpeedFamily = 'x264' | 'nvenc' | 'qsv' | 'amd';
|
|
|
|
/**
|
|
* Pas d'auto-détection : obs-websocket ne publie pas la liste des encodeurs
|
|
* disponibles, et le matériel diffère d'une VM à l'autre. C'est l'opérateur qui
|
|
* choisit, VM par VM.
|
|
*/
|
|
export const RECORDING_ENCODERS = {
|
|
x264: {
|
|
obsValue: 'x264',
|
|
label: 'x264 — logiciel (CPU)',
|
|
speedKey: 'Preset',
|
|
speedFamily: 'x264',
|
|
hint: 'Toujours disponible. Le seul choix sur un VPS sans GPU.',
|
|
},
|
|
x264_lowcpu: {
|
|
obsValue: 'x264_lowcpu',
|
|
label: 'x264 économe — logiciel (CPU)',
|
|
speedKey: null,
|
|
speedFamily: null,
|
|
hint: "Force le réglage le plus rapide de x264 : CPU au plancher, fichiers plus gros. Ignore la vitesse du preset.",
|
|
},
|
|
nvenc: {
|
|
obsValue: 'nvenc',
|
|
label: 'NVENC H.264 — NVIDIA',
|
|
speedKey: 'NVENCPreset2',
|
|
speedFamily: 'nvenc',
|
|
hint: 'Encodage déporté sur le GPU : CPU quasi nul. Exige une carte NVIDIA.',
|
|
},
|
|
nvenc_hevc: {
|
|
obsValue: 'nvenc_hevc',
|
|
label: 'NVENC HEVC — NVIDIA',
|
|
speedKey: 'NVENCPreset2',
|
|
speedFamily: 'nvenc',
|
|
hint: 'Environ 30 % de poids en moins que H.264, moins universellement lisible.',
|
|
},
|
|
qsv: {
|
|
obsValue: 'qsv',
|
|
label: 'Quick Sync H.264 — Intel',
|
|
speedKey: 'QSVPreset',
|
|
speedFamily: 'qsv',
|
|
hint: 'iGPU Intel. Disponible sur beaucoup de VM bureautiques.',
|
|
},
|
|
amd: {
|
|
obsValue: 'amd',
|
|
label: 'AMF H.264 — AMD',
|
|
speedKey: 'AMDPreset',
|
|
speedFamily: 'amd',
|
|
hint: 'GPU AMD.',
|
|
},
|
|
apple_h264: {
|
|
obsValue: 'apple_h264',
|
|
label: 'VideoToolbox H.264 — macOS',
|
|
speedKey: null,
|
|
speedFamily: null,
|
|
hint: 'macOS uniquement.',
|
|
},
|
|
} as const satisfies Record<string, RecordingEncoderInfo>;
|
|
|
|
export type RecordingEncoder = keyof typeof RECORDING_ENCODERS;
|
|
|
|
export const DEFAULT_RECORDING_ENCODER: RecordingEncoder = 'x264';
|
|
|
|
export function isRecordingEncoder(value: unknown): value is RecordingEncoder {
|
|
return typeof value === 'string' && Object.hasOwn(RECORDING_ENCODERS, value);
|
|
}
|
|
|
|
export interface RecordingPreset {
|
|
id: string;
|
|
label: string;
|
|
summary: string;
|
|
/** Charge CPU relative, 1 = la plus légère. Sert à ordonner l'interface. */
|
|
cpuCost: 1 | 2 | 3 | 4;
|
|
/** Hauteur de sortie ; `null` conserve la résolution de la scène. Jamais d'agrandissement. */
|
|
height: number | null;
|
|
/**
|
|
* Images par seconde, toujours explicite. Un « ne pas toucher » rendrait le
|
|
* résultat dépendant du preset appliqué juste avant : passer d'Économe à
|
|
* Qualité maximale garderait les 30 fps du premier, sans que rien ne le dise.
|
|
*/
|
|
fps: number;
|
|
/**
|
|
* `SimpleOutput/RecQuality` : OBS en déduit un CRF (x264) ou un CQP (matériel).
|
|
* On évite volontairement le mode à débit fixe, qui gâche des bits sur les
|
|
* plans statiques et sature sur les plans animés.
|
|
*/
|
|
quality: 'Small' | 'HQ' | 'Lossless';
|
|
/** `mkv` survit à un plantage ; `hybrid_mp4` est lisible partout. */
|
|
format: 'mkv' | 'hybrid_mp4';
|
|
audioBitrateKbps: number;
|
|
/** Valeur de vitesse à écrire, selon la famille d'encodeur retenue. */
|
|
speed: Record<SpeedFamily, string>;
|
|
}
|
|
|
|
/**
|
|
* Catalogue ordonné du plus léger au plus lourd.
|
|
*
|
|
* Le levier de qualité est le CRF (`quality`), celui de charge CPU est la
|
|
* vitesse d'encodage (`speed`) puis la définition. Un preset lourd sur une VM
|
|
* sous-dimensionnée fait chuter les images par seconde : la qualité perçue
|
|
* baisse alors, malgré un CRF meilleur. Surveille « Frames perdues » sur la
|
|
* fiche de l'agent après un changement.
|
|
*/
|
|
export const RECORDING_PRESETS: RecordingPreset[] = [
|
|
{
|
|
id: 'light',
|
|
label: 'Économe',
|
|
summary:
|
|
'720p 30 fps, encodage rapide. Pour les VM à petit CPU ou plusieurs captures en parallèle.',
|
|
cpuCost: 1,
|
|
height: 720,
|
|
fps: 30,
|
|
quality: 'Small',
|
|
format: 'hybrid_mp4',
|
|
audioBitrateKbps: 128,
|
|
speed: { x264: 'superfast', nvenc: 'p3', qsv: 'speed', amd: 'speed' },
|
|
},
|
|
{
|
|
id: 'balanced',
|
|
label: 'Équilibré',
|
|
summary: "1080p 30 fps, qualité élevée. Le compromis par défaut : bonne image sans saturer un CPU modeste.",
|
|
cpuCost: 2,
|
|
height: 1080,
|
|
fps: 30,
|
|
quality: 'HQ',
|
|
format: 'hybrid_mp4',
|
|
audioBitrateKbps: 160,
|
|
speed: { x264: 'veryfast', nvenc: 'p5', qsv: 'balanced', amd: 'balanced' },
|
|
},
|
|
{
|
|
id: 'archive',
|
|
label: 'Qualité maximale',
|
|
summary:
|
|
"Définition de la scène, 60 fps, encodage lent. Exige un CPU confortable ou un encodeur matériel.",
|
|
cpuCost: 3,
|
|
height: null,
|
|
fps: 60,
|
|
quality: 'HQ',
|
|
format: 'mkv',
|
|
audioBitrateKbps: 192,
|
|
speed: { x264: 'medium', nvenc: 'p6', qsv: 'quality', amd: 'quality' },
|
|
},
|
|
{
|
|
id: 'lossless',
|
|
label: 'Sans perte',
|
|
summary:
|
|
'Aucune compression destructrice, 60 fps. Des dizaines de Go par heure : à réserver aux captures courtes.',
|
|
cpuCost: 4,
|
|
height: null,
|
|
fps: 60,
|
|
quality: 'Lossless',
|
|
format: 'mkv',
|
|
audioBitrateKbps: 320,
|
|
// OBS impose ultrafast en sans perte ; les autres valeurs sont là pour la forme.
|
|
speed: { x264: 'ultrafast', nvenc: 'p1', qsv: 'speed', amd: 'speed' },
|
|
},
|
|
];
|
|
|
|
export const DEFAULT_RECORDING_PRESET_ID = 'balanced';
|
|
|
|
export function findRecordingPreset(id: string): RecordingPreset | null {
|
|
return RECORDING_PRESETS.find((preset) => preset.id === id) ?? null;
|
|
}
|
|
|
|
/** Preset choisi pour un agent. Réglable indépendamment sur chaque VM. */
|
|
export interface RecordingSettings {
|
|
/** Tant que c'est faux, l'agent ne touche jamais aux réglages d'OBS. */
|
|
enabled: boolean;
|
|
presetId: string;
|
|
encoder: RecordingEncoder;
|
|
}
|
|
|
|
export const DEFAULT_RECORDING_SETTINGS: RecordingSettings = {
|
|
enabled: false,
|
|
presetId: DEFAULT_RECORDING_PRESET_ID,
|
|
encoder: DEFAULT_RECORDING_ENCODER,
|
|
};
|
|
|
|
export function normalizeRecordingSettings(raw: unknown): RecordingSettings {
|
|
const input = (raw ?? {}) as Partial<RecordingSettings>;
|
|
const base = DEFAULT_RECORDING_SETTINGS;
|
|
return {
|
|
enabled: input.enabled === true,
|
|
presetId:
|
|
typeof input.presetId === 'string' && findRecordingPreset(input.presetId)
|
|
? input.presetId
|
|
: base.presetId,
|
|
encoder: isRecordingEncoder(input.encoder) ? input.encoder : base.encoder,
|
|
};
|
|
}
|
|
|
|
/** Compte rendu détaillé d'une application de preset, remonté au dashboard. */
|
|
export interface PresetApplyResult {
|
|
presetId: string;
|
|
presetLabel: string;
|
|
encoder: RecordingEncoder;
|
|
/** Paramètres écrits, sous la forme `clé=valeur`. */
|
|
applied: string[];
|
|
/** Paramètres refusés par OBS, avec le motif. */
|
|
skipped: string[];
|
|
/** Résumé lisible du réglage vidéo obtenu (définition et fluidité de sortie). */
|
|
video: string;
|
|
/**
|
|
* Vrai si l'encodeur configuré diffère de celui qu'OBS a chargé au démarrage :
|
|
* la nouvelle valeur est écrite, mais OBS continuera d'utiliser l'ancienne
|
|
* jusqu'à son redémarrage.
|
|
*/
|
|
restartRequired: boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Sélection automatique de la meilleure qualité offerte par le lecteur Stripchat.
|
|
*
|
|
* Stripchat ne démarre pas toujours sur sa meilleure qualité disponible — la
|
|
* valeur active par défaut varie, et rien ne garantit qu'elle corresponde au
|
|
* maximum réellement proposé pour ce streamer. Chaque nouvelle page (donc
|
|
* chaque nouveau stream ouvert) repart de ce choix du site, pas du précédent.
|
|
*
|
|
* Couplé à la structure actuelle du lecteur Stripchat (`.player-resolution`,
|
|
* `.player-resolution-tooltip__button--resolution`) : n'a d'effet qu'en mode
|
|
* de pilotage WebDriver BiDi, et se limite à ne rien faire si le site change
|
|
* son balisage.
|
|
*/
|
|
export interface StreamQualitySettings {
|
|
enabled: boolean;
|
|
/** Délai après le chargement de la page, le temps que le lecteur s'initialise. */
|
|
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, mais le
|
|
* streamer est toujours là ». Ils mettent l'enregistrement en pause sans le
|
|
* clore : le flux est censé revenir.
|
|
*
|
|
* Énumération relevée sur l'API Stripchat : `public`, `private`, `p2p`,
|
|
* `groupShow`, `virtualPrivate`, `ticketShow`, `idle`, `off`.
|
|
*/
|
|
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;
|
|
/**
|
|
* Clore l'enregistrement quand le flux passe hors-ligne (statut brut « off »,
|
|
* « idle »…), une fois le délai ci-dessous écoulé.
|
|
*
|
|
* Le passage hors-ligne met de toute façon l'enregistrement en pause : l'écran
|
|
* d'attente n'a rien à faire dans le fichier. Ce réglage ne décide que de la
|
|
* clôture définitive.
|
|
*/
|
|
stopOnOffline: boolean;
|
|
/**
|
|
* Temps hors-ligne toléré avant de clore. Une coupure brève ne doit pas
|
|
* découper le fichier en deux ; une vraie fin de diffusion, si. Le compte à
|
|
* rebours est annulé dès que le flux revient, public ou privé.
|
|
*/
|
|
offlineStopDelayMs: number;
|
|
fullscreen: FullscreenSettings;
|
|
streamQuality: StreamQualitySettings;
|
|
}
|
|
|
|
/**
|
|
* Pilotage du navigateur de la VM.
|
|
*
|
|
* On lance le navigateur déjà installé, avec son profil et sa session : c'est ce
|
|
* qui donne accès au flux comme si l'opérateur l'ouvrait lui-même. Aucune
|
|
* instance dédiée, aucun profil de test.
|
|
*/
|
|
export interface BrowserSettings {
|
|
enabled: boolean;
|
|
/**
|
|
* Comment l'agent obtient la page.
|
|
*
|
|
* `launch` relance l'exécutable à chaque fois et lui confie l'URL : simple,
|
|
* mais l'agent ne sait rien de ce qui se passe ensuite, et le plein écran
|
|
* dépend alors de xdotool — donc d'une session X11.
|
|
*
|
|
* `bidi` ouvre Firefox une fois et garde un canal WebDriver BiDi. L'agent
|
|
* navigue, déclenche le plein écran et le *vérifie* dans la page, sans passer
|
|
* par le serveur d'affichage : c'est le seul mode qui fonctionne en session
|
|
* Wayland native.
|
|
*/
|
|
mode: BrowserControlMode;
|
|
/** Exécutable du navigateur. */
|
|
command: string;
|
|
/**
|
|
* Arguments placés avant l'URL (mode `launch` seulement). Vide par défaut, et
|
|
* ce n'est pas un oubli : `--new-window` créerait une fenêtre neuve à chaque
|
|
* capture, avec un nouvel identifiant X11 — la source « capture de fenêtre »
|
|
* d'OBS perdrait sa cible et il faudrait la repointer à la main. Sans
|
|
* argument, Firefox confie l'URL à la fenêtre déjà ouverte, qu'OBS capture.
|
|
*/
|
|
args: string[];
|
|
/** Délai avant l'envoi du plein écran, le temps que le lecteur démarre. */
|
|
readyDelayMs: number;
|
|
/**
|
|
* Sort de la fenêtre à l'arrêt de l'enregistrement.
|
|
*
|
|
* `blank` charge une page vide : la fenêtre survit, donc la source OBS aussi,
|
|
* et le lecteur cesse de décoder la vidéo. `close` ferme la fenêtre — c'est
|
|
* précisément ce qui casse la capture OBS. `keep` ne touche à rien et laisse
|
|
* le flux tourner.
|
|
*/
|
|
onStop: BrowserStopAction;
|
|
/**
|
|
* Port local du « remote agent » de Firefox (mode `bidi`).
|
|
*
|
|
* Il n'écoute que sur la boucle locale et n'est ouvert que par l'instance que
|
|
* l'agent lance lui-même. Le rendre configurable permet de faire cohabiter
|
|
* plusieurs profils sur une même machine.
|
|
*/
|
|
remotePort: number;
|
|
/**
|
|
* Profil Firefox dédié (mode `bidi`). Vide : l'agent en gère un sous son
|
|
* répertoire de données.
|
|
*
|
|
* Un profil séparé est nécessaire, pas cosmétique : le remote agent ne
|
|
* s'active qu'au démarrage du processus, et deux instances ne peuvent pas
|
|
* partager un profil — pointer sur celui de l'opérateur donnerait
|
|
* « Firefox est déjà ouvert ».
|
|
*/
|
|
profileDir: string;
|
|
}
|
|
|
|
/**
|
|
* Ce que l'agent sait du navigateur qu'il pilote.
|
|
*
|
|
* Le mode `launch` ne permet rien de tel : on lance un processus et on espère.
|
|
* Ce retour est l'autre bénéfice de BiDi, à côté du plein écran — savoir que la
|
|
* page est bien celle attendue, sans aller regarder l'écran de la VM.
|
|
*/
|
|
export interface BrowserState {
|
|
/** Canal BiDi établi avec une instance vivante. */
|
|
connected: boolean;
|
|
/** URL de l'onglet piloté. */
|
|
url?: string;
|
|
/** Un élément de la page est en plein écran. */
|
|
fullscreen?: boolean;
|
|
/** Dernier échec de pilotage, effacé à la reconnexion. */
|
|
lastError?: string;
|
|
}
|
|
|
|
export const BROWSER_CONTROL_MODES = ['launch', 'bidi'] as const;
|
|
export type BrowserControlMode = (typeof BROWSER_CONTROL_MODES)[number];
|
|
|
|
export const BROWSER_STOP_ACTIONS = ['blank', 'keep', 'close'] as const;
|
|
export type BrowserStopAction = (typeof BROWSER_STOP_ACTIONS)[number];
|
|
|
|
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
|
|
enabled: false,
|
|
// `launch` reste le défaut : basculer un agent existant en `bidi` ouvre une
|
|
// nouvelle fenêtre Firefox, que la source OBS doit être repointée sur une
|
|
// fois. C'est un choix d'opérateur, pas un effet de bord de mise à jour.
|
|
mode: 'launch',
|
|
command: 'firefox',
|
|
args: [],
|
|
readyDelayMs: 8000,
|
|
onStop: 'blank',
|
|
remotePort: 9222,
|
|
profileDir: '',
|
|
};
|
|
|
|
export function normalizeBrowserSettings(raw: unknown): BrowserSettings {
|
|
const input = (raw ?? {}) as Partial<BrowserSettings>;
|
|
const base = DEFAULT_BROWSER_SETTINGS;
|
|
const delay = Number(input.readyDelayMs);
|
|
const port = Number(input.remotePort);
|
|
|
|
return {
|
|
enabled: input.enabled === true,
|
|
mode: (BROWSER_CONTROL_MODES as readonly string[]).includes(input.mode as string)
|
|
? (input.mode as BrowserControlMode)
|
|
: base.mode,
|
|
command:
|
|
typeof input.command === 'string' && input.command.trim()
|
|
? input.command.trim()
|
|
: base.command,
|
|
args: Array.isArray(input.args)
|
|
? input.args.filter((arg): arg is string => typeof arg === 'string' && arg.trim() !== '')
|
|
: base.args,
|
|
readyDelayMs: Number.isFinite(delay)
|
|
? Math.min(Math.max(Math.round(delay), 0), 120_000)
|
|
: base.readyDelayMs,
|
|
onStop: readStopAction(raw),
|
|
// Bornes hautes des ports non privilégiés : sous 1024, l'agent tourne sans
|
|
// droit de liaison et Firefox échouerait au démarrage.
|
|
remotePort: Number.isFinite(port)
|
|
? Math.min(Math.max(Math.round(port), 1024), 65_535)
|
|
: base.remotePort,
|
|
profileDir: typeof input.profileDir === 'string' ? input.profileDir.trim() : base.profileDir,
|
|
};
|
|
}
|
|
|
|
/** Lit le sort de la fenêtre, en acceptant l'ancien booléen `closeOnStop`. */
|
|
function readStopAction(raw: unknown): BrowserStopAction {
|
|
const input = (raw ?? {}) as Partial<BrowserSettings> & { closeOnStop?: boolean };
|
|
if ((BROWSER_STOP_ACTIONS as readonly string[]).includes(input.onStop as string)) {
|
|
return input.onStop as BrowserStopAction;
|
|
}
|
|
// Configuration écrite avant l'introduction du choix à trois valeurs.
|
|
if (typeof input.closeOnStop === 'boolean') return input.closeOnStop ? 'close' : 'keep';
|
|
return DEFAULT_BROWSER_SETTINGS.onStop;
|
|
}
|
|
|
|
export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
|
|
enabled: false,
|
|
provider: 'stripchat',
|
|
username: '',
|
|
pollIntervalMs: 10_000,
|
|
// `idle` est le « revient bientôt » : le modèle est connecté (isOnline) mais ne
|
|
// diffuse pas (isLive faux). Il relève de la pause, pas de la clôture — à la
|
|
// différence de `off`, qui est une déconnexion franche.
|
|
privateStatuses: ['private', 'p2p', 'groupShow', 'virtualPrivate', 'ticketShow', 'idle'],
|
|
confirmations: 2,
|
|
pauseOnPrivate: true,
|
|
resumeOnPublic: true,
|
|
stopOnOffline: true,
|
|
offlineStopDelayMs: 3_600_000,
|
|
fullscreen: {
|
|
enabled: true,
|
|
key: 'f',
|
|
windowMatch: 'Stripchat',
|
|
delayMs: 4000,
|
|
},
|
|
streamQuality: {
|
|
enabled: true,
|
|
delayMs: 3000,
|
|
},
|
|
};
|
|
|
|
/** É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;
|
|
/** Depuis quand le flux est hors-ligne, absent s'il ne l'est pas. */
|
|
offlineSince?: number;
|
|
/** Échéance de la clôture automatique, absente si aucun compte à rebours ne court. */
|
|
stopScheduledAt?: number;
|
|
/** 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AgentStatus {
|
|
/** L'agent a-t-il une session obs-websocket établie ? */
|
|
obsConnected: boolean;
|
|
obsVersion?: string;
|
|
obsError?: string;
|
|
|
|
recording: boolean;
|
|
recordPaused: boolean;
|
|
/** Durée d'enregistrement au format HH:MM:SS.mmm renvoyé par OBS. */
|
|
recordTimecode?: string;
|
|
recordBytes?: number;
|
|
/** Chemin du dernier fichier écrit (renseigné à l'arrêt de l'enregistrement). */
|
|
lastRecordingPath?: string;
|
|
recordDirectory?: string;
|
|
|
|
streaming: boolean;
|
|
streamTimecode?: string;
|
|
|
|
currentScene?: string;
|
|
scenes: string[];
|
|
currentProfile?: string;
|
|
profiles: string[];
|
|
currentCollection?: string;
|
|
collections: string[];
|
|
|
|
/** Statistiques OBS. */
|
|
cpuUsage?: number;
|
|
fps?: number;
|
|
/**
|
|
* Deux goulots distincts, à ne pas confondre — c'est eux qui désignent la
|
|
* cause d'une chute de performance.
|
|
*
|
|
* `renderSkipped` : la composition de la scène n'a pas tenu la cadence. En
|
|
* cause : la méthode de capture, la définition, ou un bureau sans
|
|
* accélération 3D. Changer d'encodeur n'y ferait rien.
|
|
*
|
|
* `droppedFrames` (encodage) : la scène était prête mais l'encodeur n'a pas
|
|
* suivi. Là, le preset et l'encodeur sont les bons leviers.
|
|
*/
|
|
droppedFrames?: number;
|
|
outputTotalFrames?: number;
|
|
renderSkippedFrames?: number;
|
|
renderTotalFrames?: number;
|
|
/** Durée moyenne de composition d'une image, en millisecondes. */
|
|
frameRenderTimeMs?: number;
|
|
|
|
/** Statistiques machine (collectées par l'agent, pas par OBS). */
|
|
systemCpu?: number;
|
|
systemMemoryUsed?: number;
|
|
systemMemoryTotal?: number;
|
|
diskFreeBytes?: number;
|
|
diskTotalBytes?: number;
|
|
|
|
/** Surveillance du stream source, absente si elle n'est pas configurée. */
|
|
watch?: WatchState;
|
|
|
|
/** Navigateur piloté, absent hors du mode `bidi`. */
|
|
browser?: BrowserState;
|
|
|
|
/**
|
|
* Empreinte courte du binaire en cours d'exécution. Deux agents partageant
|
|
* cette valeur tournent sur le même build — c'est ce qui rend visible un
|
|
* agent resté en arrière après une mise à jour.
|
|
*/
|
|
buildId?: string;
|
|
/** Vrai si l'agent sait se mettre à jour seul (URL de paquet connue). */
|
|
canSelfUpdate?: boolean;
|
|
|
|
updatedAt: number;
|
|
}
|
|
|
|
export function emptyStatus(): AgentStatus {
|
|
return {
|
|
obsConnected: false,
|
|
recording: false,
|
|
recordPaused: false,
|
|
streaming: false,
|
|
scenes: [],
|
|
profiles: [],
|
|
collections: [],
|
|
updatedAt: 0,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Messages agent -> serveur
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
|
|
/**
|
|
* Nature d'un évènement journalisé.
|
|
*
|
|
* Le message reste du texte libre, destiné à être lu ; ce champ le classe, pour
|
|
* que l'historique d'une VM puisse être filtré et illustré sans avoir à faire
|
|
* de l'analyse de chaîne. Il est facultatif : un agent d'une version antérieure
|
|
* n'en envoie pas, et son entrée s'affiche simplement sans pictogramme.
|
|
*
|
|
* Le préfixe porte la catégorie — l'interface s'en sert pour ses filtres, donc
|
|
* un nouvel évènement doit réutiliser un préfixe existant quand c'est possible.
|
|
*/
|
|
export const AGENT_EVENTS = [
|
|
'agent.connected',
|
|
'agent.disconnected',
|
|
'agent.enrolled',
|
|
'agent.updated',
|
|
'obs.connected',
|
|
'obs.disconnected',
|
|
'record.started',
|
|
'record.stopped',
|
|
'record.paused',
|
|
'record.resumed',
|
|
'record.split',
|
|
'stream.started',
|
|
'stream.stopped',
|
|
'capture.started',
|
|
'capture.stopped',
|
|
'watch.started',
|
|
'watch.private',
|
|
'watch.public',
|
|
'watch.offline',
|
|
'watch.failed',
|
|
'fullscreen.restored',
|
|
'fullscreen.failed',
|
|
'quality.selected',
|
|
'quality.failed',
|
|
'browser.opened',
|
|
'browser.closed',
|
|
'browser.imported',
|
|
'browser.importFailed',
|
|
'preset.applied',
|
|
'preset.failed',
|
|
'config.changed',
|
|
'command.failed',
|
|
] as const;
|
|
|
|
export type AgentEvent = (typeof AGENT_EVENTS)[number];
|
|
|
|
export function isAgentEvent(value: unknown): value is AgentEvent {
|
|
return typeof value === 'string' && (AGENT_EVENTS as readonly string[]).includes(value);
|
|
}
|
|
|
|
export interface HelloMessage {
|
|
type: 'hello';
|
|
protocol: number;
|
|
/** Absent lors du tout premier enrôlement : le serveur en attribue un. */
|
|
agentId?: string;
|
|
name: string;
|
|
hostname: string;
|
|
platform: Platform;
|
|
agentVersion: string;
|
|
}
|
|
|
|
export interface StatusMessage {
|
|
type: 'status';
|
|
status: AgentStatus;
|
|
}
|
|
|
|
export interface ResultMessage {
|
|
type: 'result';
|
|
requestId: string;
|
|
ok: boolean;
|
|
data?: unknown;
|
|
error?: string;
|
|
}
|
|
|
|
export interface LogMessage {
|
|
type: 'log';
|
|
level: LogLevel;
|
|
message: string;
|
|
ts: number;
|
|
event?: AgentEvent;
|
|
}
|
|
|
|
export interface PongMessage {
|
|
type: 'pong';
|
|
ts: number;
|
|
}
|
|
|
|
export type AgentToServer =
|
|
| HelloMessage
|
|
| StatusMessage
|
|
| ResultMessage
|
|
| LogMessage
|
|
| PongMessage;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Messages serveur -> agent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface WelcomeMessage {
|
|
type: 'welcome';
|
|
agentId: string;
|
|
/** Fourni uniquement lors de l'enrôlement : l'agent doit le persister. */
|
|
token?: string;
|
|
obs: ObsSettings;
|
|
/** Fréquence de remontée de statut demandée. */
|
|
statusIntervalMs: number;
|
|
/** Si vrai, l'agent tente de se connecter à OBS dès le démarrage. */
|
|
autoConnectObs: boolean;
|
|
watch: WatchSettings;
|
|
browser: BrowserSettings;
|
|
recording: RecordingSettings;
|
|
}
|
|
|
|
export interface CommandMessage {
|
|
type: 'command';
|
|
requestId: string;
|
|
action: AgentAction;
|
|
params?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ConfigMessage {
|
|
type: 'config';
|
|
obs: ObsSettings;
|
|
autoConnectObs: boolean;
|
|
watch: WatchSettings;
|
|
browser: BrowserSettings;
|
|
recording: RecordingSettings;
|
|
}
|
|
|
|
export interface PingMessage {
|
|
type: 'ping';
|
|
ts: number;
|
|
}
|
|
|
|
export type ServerToAgent = WelcomeMessage | CommandMessage | ConfigMessage | PingMessage;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vue agrégée exposée au dashboard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AgentView {
|
|
id: string;
|
|
name: string;
|
|
hostname: string | null;
|
|
platform: Platform;
|
|
agentVersion: string | null;
|
|
online: boolean;
|
|
lastSeenAt: number | null;
|
|
createdAt: number;
|
|
obs: ObsSettings;
|
|
autoConnectObs: boolean;
|
|
watch: WatchSettings;
|
|
browser: BrowserSettings;
|
|
recording: RecordingSettings;
|
|
notes: string | null;
|
|
status: AgentStatus;
|
|
}
|
|
|
|
export interface LogEntry {
|
|
id: number;
|
|
agentId: string | null;
|
|
agentName: string | null;
|
|
level: LogLevel;
|
|
message: string;
|
|
ts: number;
|
|
/** Absent sur les entrées anciennes et sur celles des agents non mis à jour. */
|
|
event: AgentEvent | null;
|
|
}
|
|
|
|
/**
|
|
* Profil surveillé par le serveur : on colle l'URL d'un streamer, le serveur
|
|
* sonde son statut et signale les passages en direct.
|
|
*/
|
|
export interface WatchTarget {
|
|
id: string;
|
|
provider: WatchProvider;
|
|
username: string;
|
|
/** Nom lisible, à défaut le pseudo. */
|
|
label: string | null;
|
|
url: string;
|
|
/** Agent qui enregistrera ce streamer, s'il est assigné. */
|
|
agentId: string | null;
|
|
notify: boolean;
|
|
/**
|
|
* Lancer l'enregistrement sans intervention dès que ce profil est en direct
|
|
* depuis assez longtemps. Exige un agent assigné, et un seul profil en
|
|
* automatisme par agent : une VM n'enregistre qu'un flux à la fois.
|
|
*/
|
|
autoRecord: boolean;
|
|
state: StreamState;
|
|
rawStatus: string | null;
|
|
/** Depuis quand l'état est stable, selon nos propres observations. */
|
|
stateSince: number;
|
|
lastCheckedAt: number | null;
|
|
lastError: string | null;
|
|
createdAt: number;
|
|
|
|
/** Photo de profil. */
|
|
avatarUrl: string | null;
|
|
/**
|
|
* Début du statut courant d'après la plateforme. Quand `state` vaut `public`,
|
|
* c'est l'heure de début du stream en cours.
|
|
*/
|
|
statusChangedAt: number | null;
|
|
/** Dernier stream terminé : début et fin observés. */
|
|
lastLiveStartedAt: number | null;
|
|
lastLiveEndedAt: number | null;
|
|
}
|
|
|
|
export type ServerToDashboard =
|
|
| { type: 'snapshot'; agents: AgentView[]; logs: LogEntry[]; targets: WatchTarget[] }
|
|
| { type: 'agent'; agent: AgentView }
|
|
| { type: 'agent.removed'; agentId: string }
|
|
| { type: 'log'; entry: LogEntry }
|
|
| { type: 'target'; target: WatchTarget }
|
|
| { type: 'target.removed'; targetId: string }
|
|
/** Transition vers le direct : c'est ce qui déclenche la notification. */
|
|
| { type: 'target.live'; target: WatchTarget };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function detectPlatform(raw: string): Platform {
|
|
if (raw === 'win32') return 'windows';
|
|
if (raw === 'linux') return 'linux';
|
|
if (raw === 'darwin') return 'darwin';
|
|
return 'unknown';
|
|
}
|
|
|
|
export function safeJsonParse<T>(raw: string): T | null {
|
|
try {
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
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 streamQuality = (input.streamQuality ?? {}) as Partial<StreamQualitySettings>;
|
|
|
|
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,
|
|
stopOnOffline: input.stopOnOffline !== false,
|
|
// Plafond à 24 h : au-delà, l'enregistrement mobiliserait une VM pour rien.
|
|
offlineStopDelayMs: clamp(input.offlineStopDelayMs, base.offlineStopDelayMs, 0, 86_400_000),
|
|
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),
|
|
},
|
|
streamQuality: {
|
|
enabled: streamQuality.enabled !== false,
|
|
delayMs: clamp(streamQuality.delayMs, base.streamQuality.delayMs, 0, 120_000),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extrait le pseudo d'un profil Stripchat à partir d'une URL complète, d'une URL
|
|
* sans schéma, ou d'un pseudo saisi seul.
|
|
*/
|
|
export function parseStripchatUsername(input: string): string | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
|
|
const looksLikeUrl = trimmed.includes('/') || trimmed.includes('.');
|
|
if (looksLikeUrl) {
|
|
try {
|
|
const url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
|
if (!/(^|\.)stripchat\.com$/i.test(url.hostname)) return null;
|
|
const first = url.pathname.split('/').filter(Boolean)[0];
|
|
return first && USERNAME_PATTERN.test(first) ? first : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return USERNAME_PATTERN.test(trimmed) ? trimmed : null;
|
|
}
|
|
|
|
const USERNAME_PATTERN = /^[A-Za-z0-9_.-]{2,64}$/;
|
|
|
|
export function stripchatProfileUrl(username: string): string {
|
|
return `https://fr.stripchat.com/${username}`;
|
|
}
|
|
|
|
/**
|
|
* Interroge le statut d'un modèle 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`, `virtualPrivate`, `idle`, `off`.
|
|
*
|
|
* Deux champs voisins prêtent à confusion et ne doivent pas servir ici :
|
|
* - `offlineStatus` est le message d'absence libre du modèle. Il reste
|
|
* renseigné pendant qu'il diffuse : il ne dit rien de l'état courant.
|
|
* - `isLive` / `isOnline` distinguent `idle` (connecté, ne diffuse pas) de
|
|
* `off` (déconnecté), ce que `status` exprime déjà.
|
|
*
|
|
* Mutualisé entre l'agent (pause automatique) et le serveur (veille) : une seule
|
|
* définition de l'endpoint et du chemin du champ à maintenir.
|
|
*/
|
|
export interface StripchatStatus {
|
|
raw: string;
|
|
state: StreamState;
|
|
/** Photo de profil, absente si le modèle n'en a pas. */
|
|
avatarUrl: string | null;
|
|
/**
|
|
* Début du statut courant. Quand le modèle est public, c'est l'heure de début
|
|
* du stream en cours — bien plus précis que notre propre première observation.
|
|
*/
|
|
statusChangedAt: number | null;
|
|
}
|
|
|
|
export async function fetchStripchatStatus(
|
|
username: string,
|
|
privateStatuses: string[],
|
|
timeoutMs = 8000,
|
|
): Promise<StripchatStatus> {
|
|
const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent(
|
|
username,
|
|
)}/cam`;
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'user-agent':
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
|
accept: 'application/json',
|
|
},
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
|
|
if (response.status === 404) {
|
|
return { raw: 'notFound', state: 'offline', avatarUrl: null, statusChangedAt: null };
|
|
}
|
|
if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`);
|
|
|
|
const payload = (await response.json()) as {
|
|
user?: { user?: { status?: string; avatarUrl?: string; statusChangedAt?: string } };
|
|
};
|
|
const profile = payload?.user?.user;
|
|
const raw = profile?.status;
|
|
if (typeof raw !== 'string') {
|
|
throw new Error('Réponse Stripchat inattendue : user.user.status absent');
|
|
}
|
|
|
|
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é. */
|
|
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';
|
|
// off / offline / deleted / notFound : le modèle n'est plus là du tout.
|
|
return 'offline';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Notifications Pushover
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Identifiants Pushover, réglage serveur unique (pas par agent) : les
|
|
* notifications concernent l'opérateur, pas une VM en particulier.
|
|
*
|
|
* Complète — sans le remplacer — le mécanisme de notification déjà en place
|
|
* (notification navigateur, `WatchTarget.notify`) : ce canal-ci atteint
|
|
* l'opérateur même dashboard fermé, ce que le navigateur ne peut pas faire.
|
|
*/
|
|
export interface PushoverSettings {
|
|
enabled: boolean;
|
|
/** Clé utilisateur ou de groupe Pushover (`Your User Key` sur pushover.net). */
|
|
userKey: string;
|
|
/** Jeton de l'application créée sur pushover.net/apps/build. */
|
|
appToken: string;
|
|
}
|
|
|
|
export const DEFAULT_PUSHOVER_SETTINGS: PushoverSettings = {
|
|
enabled: false,
|
|
userKey: '',
|
|
appToken: '',
|
|
};
|
|
|
|
export function normalizePushoverSettings(raw: unknown): PushoverSettings {
|
|
const input = (raw ?? {}) as Partial<PushoverSettings>;
|
|
return {
|
|
enabled: input.enabled === true,
|
|
userKey: typeof input.userKey === 'string' ? input.userKey.trim() : '',
|
|
appToken: typeof input.appToken === 'string' ? input.appToken.trim() : '',
|
|
};
|
|
}
|