Ctrl K
ComunidadGuías…

serve.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Interface locale pour visionner les clips Blink et les écarter ou les reprendre.

Pourquoi un petit serveur HTTP et pas un fichier HTML autonome : une page
ouverte en file:// ne peut ni déplacer un fichier ni écrire un registre. Elle
ne saurait produire qu'une liste à recopier à la main, c'est-à-dire la moitié
d'un outil. Il faut donc un processus local, et une fois qu'il existe, autant
qu'il serve aussi les vidéos.

Pourquoi pas une fenêtre Tk : Tk ne lit pas de vidéo. Il faudrait déléguer à un
lecteur externe, et la boucle « je regarde, je juge, je passe au suivant » se
casse à chaque clip. La balise <video> d'un navigateur donne gratuitement la
lecture, le défilement, la vitesse et le plein écran.

Rien d'autre que la bibliothèque standard n'est requis, et toutes les décisions
(exclure, réintégrer) sont déléguées à merge_daily.py : cette interface ne
réimplémente aucune règle, elle appelle les mêmes fonctions que la ligne de
commande.
"""

from __future__ import annotations  # Python 3.8 (build Windows 7) : les annotations "X | None" ne s'évaluent qu'à l'écriture des chaînes, jamais à l'exécution.

import argparse
import asyncio
import concurrent.futures
import contextlib
import datetime as dt
import email.utils
import hashlib
import hmac
import html
import http.server
import ipaddress
import json
import mimetypes
import os
import queue
import re
import select
import socket
import subprocess
import tempfile
import threading
import time
import uuid
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import parse_qs, quote, unquote, urlparse
try:
    from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError:  # Python 3.8 (build Windows 7, voir build-win7.yml) : pas de zoneinfo en stdlib.
    from backports.zoneinfo import ZoneInfo, ZoneInfoNotFoundError

# Avant tout import de dépendance : c'est ici qu'un environnement isolé
# est préparé et le programme relancé dedans si nécessaire.
from nico579_commons import serveweb

import runtime

LIBELLES = {
    "fr": {
        "aide_desc":
            "Interface locale pour visionner les clips Blink, en écarter et en "
            "reprendre.",
        "aide_hub": "nom du Sync Module Blink ; tous les modules si omis",
        "aide_thumbs": "cache des vignettes ; jetable, refabriqué à la demande",
        "aide_trusted_host":
            "noms d'hôte, adresses ou sous-réseaux CIDR supplémentaires, "
            "séparés par des virgules, acceptés comme Host en plus de la boucle "
            "locale : pour un tunnel privé (Tailscale, WireGuard) ou un LAN de "
            "confiance lié directement à cette instance avec BLINK_BIND, sans "
            "reverse proxy devant. Réglable aussi depuis la page (Réglages)",
        "aide_open_browser": "ouvrir la page dans le navigateur au démarrage",
        "vignette_erreur": "[vignette] {identity} : {type}: {erreur}",
        "ecarter_lot_erreur": "Écarter (lot) : {erreur}",
        "erreur_generique": "Erreur : {erreur}",
        "echec_ecoute_port": "Impossible d'écouter sur le port {port} : {erreur}",
        "autre_instance_deja":
            "Un autre « blink2video serve » tourne sans doute déjà. Arrêtez-le, "
            "choisissez un autre port avec --port.",
        "interface_disponible": "Interface disponible sur {url}   (Ctrl+C pour arrêter)",
        "attention_blink_bind":
            "Attention : BLINK_BIND={bind} - interface aussi joignable "
            "depuis le reste du réseau sur le port {port}, sans "
            "authentification. À réserver à un réseau de confiance.",
        "attention_trusted_host":
            "Attention : hôte de confiance réglé sur {hote} - interface "
            "aussi joignable sous ce nom, sans authentification. À réserver "
            "à un tunnel privé (Tailscale, WireGuard) lié à cette adresse "
            "avec BLINK_BIND.",
        "interruption_arret": "\nArrêt.",
        "snapshot_commande_refusee": "Blink a refusé la commande de photo.",
        "snapshot_non_confirme": "Blink n'a pas confirmé la photo.",
        "snapshot_image_indisponible":
            "La caméra a pris la photo, mais Blink ne l'a pas encore publiée. "
            "Réessayez dans un instant.",
        "webhook_camera_manquante": "Paramètre « camera » manquant.",
        "webhook_jeton_invalide": "Jeton de webhook invalide ou manquant.",
        "webhook_camera_inconnue": "Caméra inconnue : {camera}.",
        "webhook_systeme_inconnu": "Système inconnu : {systeme}.",
        "webhook_nom_ambigu": "Plusieurs éléments portent ce nom : {nom}. Renommez-en un dans l'application Blink.",
        "webhook_cible": "Indiquez exactement une cible : camera ou system.",
        "webhook_armed_invalide": "Paramètre armed attendu : true ou false.",
        "webhook_camera_hors_ligne": "La caméra « {camera} » est hors ligne : armement non tenté.",
        "module_occupe": "Le module est déjà occupé (direct ou actualisation).",
        "module_occupe_par": "Le module est déjà occupé : {quoi}{cible}, depuis {depuis} s.",
        "occupation_reveil": "réveil",
        "occupation_photo": "photo",
        "occupation_direct": "direct",
        "occupation_actualisation": "actualisation",
        "occupation_suppression": "suppression manuelle",
        "video_identite_invalide": "Identité de vidéo invalide.",
        "video_chemin_inaccessible": "Chemin de vidéo inaccessible.",
        "video_hors_directs": "Vidéo hors du dossier des directs.",
        "selection_videos_invalide": "Sélection de vidéos invalide.",
        "video_chemin_modifie": "Chemin de vidéo modifié pendant la sélection.",
        "avcc_introuvable": "avcC introuvable dans le segment d'initialisation MP4 (profil illisible).",
        "flux_annule_avant_fin": "flux annulé avant confirmation de fin",
        "erreur_inconnue": "erreur inconnue",
        "exception_boucle": "exception non rattrapée sur BLINK.loop, {detail}",
        "armement_refuse": "Blink a refusé la commande d'armement de la caméra.",
        "armement_non_confirme": "Blink n'a pas confirmé l'armement de la caméra.",
        "session_occupee": "La session Blink est occupée.",
        "session_absente_expiree": "Session Blink absente ou expirée. Reconnectez-vous depuis le bouton Actualiser.",
        "camera_id_duplique": "Identifiant de caméra dupliqué dans le compte Blink.",
        "camera_nom_ambigu": "Nom de caméra ambigu : {identite}. Utilisez son identifiant stable.",
        "camera_inconnue_id": "Caméra inconnue : {identite}",
        "systeme_nom_ambigu": "Nom de système ambigu : {identite}",
        "systeme_inconnu_id": "Système inconnu : {identite}",
        "sync_introuvable": "Sync Module introuvable (réseau {reseau}, module {module}).",
        "sync_ambigu": "Sync Module ambigu (réseau {reseau}, module {module}).",
        "identifiants_refuses": "Identifiants ou code refusés.",
        "aucune_connexion": "Aucune connexion en cours.",
        "delai_depasse": "Délai dépassé.",
        "powershell_echec": "PowerShell a échoué",
        "hote_confiance_invalide": "Hôte de confiance invalide : « {entree} ». Un ou plusieurs noms d'hôte, adresses IP ou sous-réseaux CIDR (192.168.1.0/24), séparés par des virgules.",
        "sous_reseau_invalide": "Sous-réseau invalide : « {entree} ». Format attendu : 192.168.1.0/24.",
        "reglage_cadences_invalides": "Les cadences doivent être des nombres de minutes d'au moins 1, et le port un nombre entre 1 et 65535.",
        "reglage_police_invalide": "La taille de police doit être un nombre entre 8 et 500, ou vide pour la taille automatique.",
        "reglage_opacite_invalide": "L'opacité du bandeau doit être un nombre entre 0.0 et 1.0.",
        "reglage_url_notif_invalide": "URL de notification invalide : « {url} ». Une adresse http:// ou https:// complète, ou vide pour désactiver.",
        "reglage_entete_trop_long": "Le titre de l'en-tête tient en {titre} caractères au plus, l'icône en {icone}.",
        "reglage_arret_direct_invalide": "L'arrêt automatique du direct doit être un nombre de secondes entre 0 (désactivé) et {maximum} (le plafond de tout direct).",
        "journal_acces_refuse": "\n--- {date} accès refusé ---\nraison: {raison}\nHost reçu: {host}\nOrigin reçu: {origin}\nIP cliente: {ip}\ntrusted_host configuré: {trusted}\n",
        "arret_impossible": "Impossible de lancer l'arrêt : {erreur}",
        "reveil_refuse": "Blink a refusé la commande de réveil.",
        "reveil_non_confirme": "Blink n'a pas confirmé le réveil de la caméra.",
        "camera_aucune_image": "La caméra n'a envoyé aucune image.",
        "camera_sans_reponse": "La caméra n'a pas répondu (Blink signale « {detail} »). Hors de portée du module, endormie, ou déjà occupée par une autre session.",
        "echec_liberation_verrou": "échec de libération du verrou disque, {erreur}",
        "echec_fermeture_blink": "échec de fermeture Blink, {erreur}",
        "tentative_annulee": "Tentative annulée.",
        "aucune_adresse_flux": "Blink n'a fourni aucune adresse de flux. La caméra est indisponible ou le module est occupé.",
        "relais_arrete_avant_image": "Le relais Blink s'est arrêté avant la première image.",
        "webrtc_negocie": "direct WebRTC négocié",
        "echec_webrtc": "échec (webrtc), {message}",
        "aucune_adresse_flux_camera": "Blink n'a fourni aucune adresse de flux pour « {camera} ». La caméra est probablement hors de portée du module de synchronisation, éteinte, ou sa batterie est vide.",
        "format_non_pris_en_charge": "Cette caméra diffuse dans un format non pris en charge ({erreur}).",
        "enregistrement_impossible": "enregistrement impossible, {erreur}",
        "reglages_initiaux_requis": "Validez d'abord les réglages initiaux.",
        "actualisation_en_cours": "Une actualisation est déjà en cours.",
        "un_calcul": "Un calcul",
        "calcul_en_cours": "{quoi} est déjà en cours. Attendez la fin de la barre, puis réessayez.",
        "module_occupe_detail": "Module occupé : {erreur}.",
        "etape_telechargement": "Téléchargement",
        "etape_fusion": "Fusion",
        "session_absente_login": "Session Blink absente ({fichier}). Lancez « blink2video login » dans un terminal pour vous connecter.",
        "reconstruction_seule": "Reconstruction des vidéos seule.",
        "aucun_traitement_fusion_off": "Aucun traitement lancé : la fusion est désactivée.",
        "phase_silencieuse": "{phase} : arrêtée, silencieuse depuis plus de {secondes} s",
        "phase_echec": "{phase} : échec (code {code})",
        "reglages_deja_en_cours": "Une modification des réglages est déjà en cours : {erreur}",
        "reglages_echec": "Impossible d'enregistrer les réglages : {erreur}",
        "camera_nom_manquant": "Nom de caméra manquant.",
        "camera_inconnue": "Caméra inconnue.",
        "suppression_auto_echec": "Impossible d'enregistrer la suppression automatique : {erreur}",
        "direct_inactif": "Cette session de direct n'est plus active.",
        "fermeture_demandee": "session {session}, fermeture demandée",
        "aucune_version": "Aucune version plus récente.",
        "conclusion_maj_occupee": "La conclusion de la mise à jour précédente est encore occupée. Réessayez.",
        "autostart_echec": "Échec de la modification du démarrage automatique.",
        "session_rendue_de_force": "session rendue de force",
        "dossier_stockage_inaccessible": "Dossier de stockage inaccessible : {erreur}",
        "fuseau_inconnu": "Fuseau horaire inconnu : « {fuseau} ».",
        "protocole_inconnu": "Protocole de direct inconnu : « {protocole} ».",
        "horloge_inconnue": "Format d'heure inconnu : « {horloge} » (24h ou 12h).",
        "couleur_invalide": "Couleur d'horodatage invalide : « {couleur} ».",
        "module_type": "module « {type} »",
        "journal_armement": "{horodatage} {portee} « {nom} » armed={armed} depuis {client}",
        "offre_sdp_invalide": "Offre SDP ou identifiant de session invalide.",
        "format_direct_non_pris_en_charge": "Format de direct non pris en charge ({erreur}).",
        "echec_fermeture_enregistrement": "échec de fermeture de l'enregistrement, {erreur}",
        "echec_ffmpeg": "échec de {etape} ffmpeg, {erreur}",
        "echec_fermeture": "échec de fermeture, {erreur}",
        "diagnostic_indisponible": "diagnostic d'échec indisponible, {erreur}",
        "echec_direct": "échec, {erreur}",
        "plage_dates_invalide": "Plage de dates invalide.",
        "identifiants_requis": "Adresse e-mail et mot de passe requis.",
        "code_requis": "Code requis.",
        "session_requise": "Identifiant de session requis.",
        "conservation_invalide": "Les durées de conservation doivent être des nombres de jours entre 0 (garder toujours) et {maximum}.",
        "conservation_ordre": "La conservation des {niveau} ne peut pas être plus courte que celle des {source}, ni réglée quand celle des {source} vaut 0 (garder toujours) : elles en seraient refaites à chaque passage.",
        "libelle_conservation_clips": "clips bruts",
        "libelle_conservation_jour": "vidéos journalières",
        "libelle_conservation_semaine": "vidéos hebdomadaires",
        "libelle_conservation_mois": "vidéos mensuelles",
        "audio_enabled_invalide": "camera_audio_enabled : booléen attendu",
        "audio_track_invalide": "camera_audio_track : canal 0 ou 1 attendu",
    },
    "en": {
        "aide_desc": "Local interface to watch Blink clips, discard some and bring them back.",
        "aide_hub": "Blink Sync Module name; all modules if omitted",
        "aide_thumbs": "thumbnail cache; disposable, rebuilt on demand",
        "aide_trusted_host":
            "extra hostnames, addresses or CIDR subnets, comma-separated, "
            "accepted as Host besides loopback: for a private tunnel "
            "(Tailscale, WireGuard) or a trusted LAN bound directly to this "
            "instance with BLINK_BIND, no reverse proxy in front. Also "
            "settable from the page (Settings)",
        "aide_open_browser": "open the page in the browser at startup",
        "vignette_erreur": "[thumbnail] {identity}: {type}: {erreur}",
        "ecarter_lot_erreur": "Exclude (batch): {erreur}",
        "erreur_generique": "Error: {erreur}",
        "echec_ecoute_port": "Could not listen on port {port}: {erreur}",
        "autre_instance_deja":
            "Another « blink2video serve » is probably already running. Stop it, "
            "or choose another port with --port.",
        "interface_disponible": "Interface available at {url}   (Ctrl+C to stop)",
        "attention_blink_bind":
            "Warning: BLINK_BIND={bind} - the interface is also reachable "
            "from the rest of the network on port {port}, with no "
            "authentication. Only do this on a trusted network.",
        "attention_trusted_host":
            "Warning: trusted host set to {hote} - the interface is also "
            "reachable under that name, with no authentication. Only do "
            "this for a private tunnel (Tailscale, WireGuard) bound to that "
            "address with BLINK_BIND.",
        "interruption_arret": "\nStopping.",
        "snapshot_commande_refusee": "Blink refused the picture command.",
        "snapshot_non_confirme": "Blink did not confirm the picture.",
        "snapshot_image_indisponible":
            "The camera took the picture, but Blink has not published it yet. "
            "Try again in a moment.",
        "webhook_camera_manquante": "Missing «camera» parameter.",
        "webhook_jeton_invalide": "Invalid or missing webhook token.",
        "webhook_camera_inconnue": "Unknown camera: {camera}.",
        "webhook_systeme_inconnu": "Unknown system: {systeme}.",
        "webhook_nom_ambigu": "Several items have this name: {nom}. Rename one of them in the Blink app.",
        "webhook_cible": "Give exactly one target: camera or system.",
        "webhook_armed_invalide": "Parameter armed must be true or false.",
        "webhook_camera_hors_ligne": "Camera \u201c{camera}\u201d is offline: arming not attempted.",
        "module_occupe": "The module is already busy (live view or refresh).",
        "module_occupe_par": "The module is already busy: {quoi}{cible}, for {depuis} s.",
        "occupation_reveil": "wake-up",
        "occupation_photo": "snapshot",
        "occupation_direct": "live view",
        "occupation_actualisation": "refresh",
        "occupation_suppression": "manual deletion",
        "video_identite_invalide": "Invalid video identity.",
        "video_chemin_inaccessible": "Video path not accessible.",
        "video_hors_directs": "Video outside the live recordings folder.",
        "selection_videos_invalide": "Invalid video selection.",
        "video_chemin_modifie": "Video path changed during the selection.",
        "avcc_introuvable": "avcC not found in the MP4 initialisation segment (unreadable profile).",
        "flux_annule_avant_fin": "stream cancelled before its end was confirmed",
        "erreur_inconnue": "unknown error",
        "exception_boucle": "uncaught exception on BLINK.loop, {detail}",
        "armement_refuse": "Blink refused the command to arm the camera.",
        "armement_non_confirme": "Blink did not confirm that the camera was armed.",
        "session_occupee": "The Blink session is busy.",
        "session_absente_expiree": "No Blink session, or it has expired. Sign in again from the Refresh button.",
        "camera_id_duplique": "Duplicate camera identifier in the Blink account.",
        "camera_nom_ambigu": "Ambiguous camera name: {identite}. Use its stable identifier.",
        "camera_inconnue_id": "Unknown camera: {identite}",
        "systeme_nom_ambigu": "Ambiguous system name: {identite}",
        "systeme_inconnu_id": "Unknown system: {identite}",
        "sync_introuvable": "Sync Module not found (network {reseau}, module {module}).",
        "sync_ambigu": "Ambiguous Sync Module (network {reseau}, module {module}).",
        "identifiants_refuses": "Credentials or code refused.",
        "aucune_connexion": "No sign-in in progress.",
        "delai_depasse": "Timed out.",
        "powershell_echec": "PowerShell failed",
        "hote_confiance_invalide": "Invalid trusted host: “{entree}”. One or more host names, IP addresses or CIDR subnets (192.168.1.0/24), separated by commas.",
        "sous_reseau_invalide": "Invalid subnet: “{entree}”. Expected format: 192.168.1.0/24.",
        "reglage_cadences_invalides": "Intervals must be a number of minutes of at least 1, and the port a number between 1 and 65535.",
        "reglage_police_invalide": "The font size must be a number between 8 and 500, or empty for automatic size.",
        "reglage_opacite_invalide": "The banner opacity must be a number between 0.0 and 1.0.",
        "reglage_url_notif_invalide": "Invalid notification URL: “{url}”. A full http:// or https:// address, or empty to turn it off.",
        "reglage_entete_trop_long": "The header title holds at most {titre} characters, the icon {icone}.",
        "reglage_arret_direct_invalide": "Live view auto-stop must be a number of seconds between 0 (off) and {maximum} (the ceiling of any live view).",
        "journal_acces_refuse": "\n--- {date} access refused ---\nreason: {raison}\nHost received: {host}\nOrigin received: {origin}\nClient IP: {ip}\ntrusted_host set to: {trusted}\n",
        "arret_impossible": "Could not start the shutdown: {erreur}",
        "reveil_refuse": "Blink refused the wake-up command.",
        "reveil_non_confirme": "Blink did not confirm that the camera woke up.",
        "camera_aucune_image": "The camera sent no picture.",
        "camera_sans_reponse": "The camera did not answer (Blink reports “{detail}”). Out of the module's range, asleep, or already busy with another session.",
        "echec_liberation_verrou": "could not release the disk lock, {erreur}",
        "echec_fermeture_blink": "could not close Blink, {erreur}",
        "tentative_annulee": "Attempt cancelled.",
        "aucune_adresse_flux": "Blink gave no stream address. The camera is unavailable or the module is busy.",
        "relais_arrete_avant_image": "The Blink relay stopped before the first picture.",
        "webrtc_negocie": "WebRTC live view negotiated",
        "echec_webrtc": "failed (webrtc), {message}",
        "aucune_adresse_flux_camera": "Blink gave no stream address for “{camera}”. The camera is probably out of the Sync Module's range, switched off, or its battery is empty.",
        "format_non_pris_en_charge": "This camera streams in an unsupported format ({erreur}).",
        "enregistrement_impossible": "recording failed, {erreur}",
        "reglages_initiaux_requis": "Confirm the initial settings first.",
        "actualisation_en_cours": "A refresh is already running.",
        "un_calcul": "A computation",
        "calcul_en_cours": "{quoi} is already running. Wait until the bar finishes, then try again.",
        "module_occupe_detail": "Module busy: {erreur}.",
        "etape_telechargement": "Download",
        "etape_fusion": "Merge",
        "session_absente_login": "No Blink session ({fichier}). Run “blink2video login” in a terminal to sign in.",
        "reconstruction_seule": "Rebuilding the videos only.",
        "aucun_traitement_fusion_off": "Nothing started: merging is turned off.",
        "phase_silencieuse": "{phase}: stopped, silent for more than {secondes} s",
        "phase_echec": "{phase}: failed (code {code})",
        "reglages_deja_en_cours": "Settings are already being changed: {erreur}",
        "reglages_echec": "Could not save the settings: {erreur}",
        "camera_nom_manquant": "Camera name missing.",
        "camera_inconnue": "Unknown camera.",
        "suppression_auto_echec": "Could not save automatic deletion: {erreur}",
        "direct_inactif": "This live view session is no longer active.",
        "fermeture_demandee": "session {session}, close requested",
        "aucune_version": "No newer version.",
        "conclusion_maj_occupee": "The previous update is still being finalised. Try again.",
        "autostart_echec": "Could not change automatic startup.",
        "session_rendue_de_force": "session released by force",
        "dossier_stockage_inaccessible": "Storage folder not accessible: {erreur}",
        "fuseau_inconnu": "Unknown time zone: “{fuseau}”.",
        "protocole_inconnu": "Unknown live view protocol: “{protocole}”.",
        "horloge_inconnue": "Unknown time format: “{horloge}” (24h or 12h).",
        "couleur_invalide": "Invalid timestamp colour: “{couleur}”.",
        "module_type": "module “{type}”",
        "journal_armement": "{horodatage} {portee} “{nom}” armed={armed} from {client}",
        "offre_sdp_invalide": "Invalid SDP offer or session identifier.",
        "format_direct_non_pris_en_charge": "Unsupported live view format ({erreur}).",
        "echec_fermeture_enregistrement": "could not close the recording, {erreur}",
        "echec_ffmpeg": "ffmpeg {etape} failed, {erreur}",
        "echec_fermeture": "could not close, {erreur}",
        "diagnostic_indisponible": "failure diagnosis unavailable, {erreur}",
        "echec_direct": "failed, {erreur}",
        "plage_dates_invalide": "Invalid date range.",
        "identifiants_requis": "Email address and password required.",
        "code_requis": "Code required.",
        "session_requise": "Session identifier required.",
        "conservation_invalide": "Retention periods must be a number of days between 0 (keep forever) and {maximum}.",
        "conservation_ordre": "Retention for {niveau} can't be shorter than for {source}, nor set while {source} are kept forever (0): they would be rebuilt from them on every pass.",
        "libelle_conservation_clips": "raw clips",
        "libelle_conservation_jour": "daily videos",
        "libelle_conservation_semaine": "weekly videos",
        "libelle_conservation_mois": "monthly videos",
        "audio_enabled_invalide": "camera_audio_enabled: boolean expected",
        "audio_track_invalide": "camera_audio_track: channel 0 or 1 expected",
    },
}


def msg(cle: str, **valeurs) -> str:
    return runtime.traduire(LIBELLES, cle, **valeurs)


runtime.bootstrap()

import autostart
import blink_auth
import blink_engine
import blink_models
import blink_registre
import blink_webrtc
import blink_audio
import maj
import merge_daily as md
import watch


BASE_DIR = runtime.app_dir()

# Un identifiant de clip est un chemin relatif « caméra/mois/fichier.mp4 ».
# Tout ce qui arrive du navigateur est confronté au registre avant d'ouvrir
# quoi que ce soit : aucun chemin fabriqué à la main n'est servi.
IDENTITY = re.compile(r"^[\w.\- ]+(/[\w.\- ]+)*\.mp4$")
# Photos (issue GitHub #9) : un seul niveau (caméra/fichier.jpg), jamais
# imbriquées comme les clips - pas de raison de l'être.
IDENTITY_SNAPSHOT = re.compile(r"^[\w.\- ]+/[\w.\- ]+\.jpg$")

# Avancement annoncé par blink2video.py et merge_daily.py, et titres de phase émis
# par blink_engine.py (« === STOCKAGE LOCAL === », « === CLOUD DE L'ABONNEMENT === »).
PROGRESS = re.compile(r"\[(\d+)/(\d+)\]")
# Ligne d'avancement à l'intérieur d'un clip : « [3/24] 45% », rien d'autre.
INNER = re.compile(r"^\s*\[\d+/\d+\]\s+(\d+)%\s*$")
HEADING = re.compile(r"^=== (.+?) ===$")


def _titre_section(cle: str, langue: str, **valeurs) -> str:
    """Titre de section de blink_engine dans une langue donnée, sans les
    « === » ni le saut de ligne : la langue de l'enfant download n'est pas
    forcément celle du moment où la ligne est lue ici."""
    # Pas de removeprefix() : Python 3.8 pour la version Windows 7.
    texte = blink_engine.LIBELLES[langue][cle].format(**valeurs).strip()
    return texte[len("=== "):-len(" ===")]


_TITRES_CLOUD = {_titre_section("cloud_section_titre", langue)
                 for langue in ("fr", "en")}


def _hub_section_usb(titre: str):
    """Nom du hub d'un titre « stockage local » de blink_engine, dans l'une
    ou l'autre langue ; None si ce n'en est pas un."""
    for langue in ("fr", "en"):
        prefixe = _titre_section("stockage_local_titre", langue, nom="")
        if titre.startswith(prefixe):
            return titre[len(prefixe):].strip()
    return None

# Limite le nombre d'extractions de vignettes simultanées (voir send_thumb) :
# le navigateur les réclame toutes d'un coup à chaque changement de filtre, un
# nombre fixe évite de saturer la machine pour autant de ffmpeg à la fois.
# Fixé à 2 à l'origine, beaucoup trop bas pour une machine multi-cœurs
# actuelle : passer d'un préréglage large (mois, déjà en cache) à un plus
# étroit mais couvrant des clips jamais vus (aujourd'hui, cette semaine)
# mettait chaque vignette manquante en attente derrière seulement deux
# extractions à la fois, le spinner natif du lecteur tournant sur chacune le
# temps de son tour (constaté en réel, 2026-08-27). Une extraction reste bien
# plus légère qu'un encodage complet (une seule image, pas de vidéo entière) :
# le nombre de cœurs est un plafond raisonnable, pas un risque comparable.
THUMB_SLOTS = threading.Semaphore(min(8, os.cpu_count() or 4))

# Un verrou par miniature, pas un seul global qui sérialiserait toutes les
# vignettes de toutes les caméras entre elles : sans lui, deux requêtes
# concurrentes pour LA MÊME miniature (deux onglets sur la même page)
# partagent le même fichier temporaire clip.tmp.jpg, et l'une peut le
# renommer vers la destination finale avant que l'autre n'ait fini de s'en
# servir (FileNotFoundError, constaté en réel).
_VERROUS_MINIATURE: dict = {}
_VERROU_CREATION_MINIATURE = threading.Lock()


def _verrou_miniature(cle: str) -> threading.Lock:
    with _VERROU_CREATION_MINIATURE:
        return _VERROUS_MINIATURE.setdefault(cle, threading.Lock())


# Le Sync Module ne traite qu'une commande à la fois et refuse les suivantes
# avec « System is busy ». Inutile de le lui demander : mesuré, son état publié
# est identique au repos et pendant un direct (busy reste vide, status reste
# « online »), et le lui demander coûte treize secondes, plus cher que de tenter
# la commande. On tient donc le compte localement, puisque c'est nous qui
# l'occupons : ce jeton est pris par un direct comme par une actualisation, ce
# qui les empêche de se marcher dessus.
#
# Il ne couvre évidemment pas un autre client, l'application mobile par
# exemple : là seul le refus renseigne, d'où la reprise avec attente croissante
# côté blink2video.py.
MODULE_SLOT = threading.Semaphore(1)
# Diagnostic seul : dit qui tient MODULE_SLOT et depuis quand, pour qu'un 409
# distingue « occupé, ça va se libérer » d'« occupé depuis 20 minutes, ça sent
# la fuite » sans avoir à redémarrer le serveur pour le savoir.
MODULE_SLOT_INFO: dict = {}
# /api/attente-module (plus bas) : combien de temps un client peut attendre
# une confirmation que MODULE_SLOT est libre avant d'être informé que ça
# prend plus longtemps que prévu. Un peu au-delà du plafond interne de
# _stop_stream (asyncio.wait_for(feed, timeout=20)) : pas de raison de
# couper avant que le nettoyage serveur lui-même abandonne.
ATTENTE_MODULE_MAX_SECONDS = 25

# hub_lock("direct") (send_live_mse/send_offer_webrtc, plus bas) échouait
# jusqu'ici instantanément (attente=0 implicite) s'il tombait sur le
# téléchargement de fond (clips USB/cloud) déjà en train de parler au Sync
# Module - alors que celui-ci ne le retient souvent que quelques secondes.
# Un échec instantané coûtait alors tout un cycle de reprise côté page
# (échec, 3s d'attente, negociation WebRTC entière refaite), plutôt qu'une
# simple attente ici, sur la même requête - exactement le cas que
# runtime.verrou() documente pour son paramètre attente (constaté en réel,
# 2026-09-04 : bascules de caméra ressenties comme "instables").
ATTENTE_HUB_MAX_SECONDS = 20

# La photo confirmée peut mettre encore un peu de temps à paraître chez Blink.
# MODULE_SLOT reste pris pendant cette attente, partagée avec les directs et
# les téléchargements : ne pas prolonger le sondage au-delà de ce plafond.
ATTENTE_MEDIA_SNAPSHOT_SECONDS = 18
INTERVALLE_MEDIA_SNAPSHOT_SECONDS = 2

# Bascule pilotée par le bouton d'enregistrement de la page (JS,
# /api/direct-enregistrement plus bas) : un seul direct actif à la fois
# (MODULE_SLOT), donc un seul drapeau global suffit, lu par send_live_mse et
# blink_webrtc._PisteH264 pour savoir s'il faut écrire sur le disque
# maintenant. Effacé au début de chaque nouveau direct (send_live_mse,
# send_offer_webrtc) : ouvrir un direct ne doit jamais hériter en silence de
# l'état laissé par le précédent.
ENREGISTREMENT_DIRECT_ACTIF = threading.Event()

# La session existe dès l'admission HTTP, avant le réveil et la création du
# pc. L'identifiant aléatoire vient de l'onglet et change à chaque tentative.
# Le verrou protège les transitions entre les threads HTTP et BLINK.loop.
DIRECT_WEBRTC_SESSION: dict = {}
DIRECT_WEBRTC_SESSION_LOCK = threading.Lock()
# Même rôle pour MSE, en plus simple : send_live_mse() tourne en code
# synchrone dans son propre thread HTTP, pas sur une boucle asyncio - pas de
# "loop"/"interrompre" à retenir, juste l'Event que sa boucle d'envoi
# vérifie elle-même à chaque tour (voir _demander_arret_direct). Sans ça, un
# navigateur fermé pendant un silence de la caméra n'était détecté qu'à la
# prochaine écriture - jamais si la caméra restait muette, le module pouvait
# donc rester occupé jusqu'à LIVE_MAX_SECONDS (300 s, bug réel).
DIRECT_MSE_SESSION: dict = {}
DIRECT_ARRETS_RECENTS: dict = {}
DIRECT_SESSION_ID = re.compile(r"[A-Za-z0-9_-]{16,64}\Z")
DIRECT_ARRET_TTL_SECONDS = 180
BLINK_OPEN_MAX_SECONDS = 45
WEBRTC_START_MAX_SECONDS = (
    BLINK_OPEN_MAX_SECONDS + blink_webrtc.PREMIERE_IMAGE_MAX_SECONDS
    + blink_webrtc.NEGOCIATION_MAX_SECONDS + 5
)


def _purger_arrets_direct() -> None:
    """Appelé sous DIRECT_WEBRTC_SESSION_LOCK ; mémoire bornée."""
    maintenant = time.monotonic()
    for identifiant, expiration in list(DIRECT_ARRETS_RECENTS.items()):
        if expiration <= maintenant:
            DIRECT_ARRETS_RECENTS.pop(identifiant, None)
    while len(DIRECT_ARRETS_RECENTS) > 256:
        DIRECT_ARRETS_RECENTS.pop(next(iter(DIRECT_ARRETS_RECENTS)))


def _demander_arret_direct(session_id: str) -> bool:
    """Annule la tentative WebRTC ou MSE désignée, sans attendre BLINK.lock.

    Une annulation WebRTC peut arriver avant son offre sur une autre
    connexion HTTP ; la conserver brièvement (DIRECT_ARRETS_RECENTS)
    empêche cette offre tardive de réveiller la caméra. MSE n'a pas cette
    fenêtre : sa requête GET est la tentative elle-même, il suffit de
    positionner son Event - la boucle d'envoi (send_live_mse) le vérifie
    elle-même à chaque tour, pas de "loop"/"interrompre" à réveiller
    puisqu'elle ne tourne pas sur asyncio."""
    if not DIRECT_SESSION_ID.fullmatch(session_id):
        return False
    with DIRECT_WEBRTC_SESSION_LOCK:
        DIRECT_ARRETS_RECENTS[session_id] = time.monotonic() + DIRECT_ARRET_TTL_SECONDS
        _purger_arrets_direct()
        webrtc_holder = DIRECT_WEBRTC_SESSION.get("session")
        if webrtc_holder is not None and webrtc_holder["session_id"] != session_id:
            webrtc_holder = None
        mse_holder = DIRECT_MSE_SESSION.get("session")
        if mse_holder is not None and mse_holder["session_id"] != session_id:
            mse_holder = None
        if webrtc_holder is None and mse_holder is None:
            return False
        deja_demande = webrtc_holder is not None and webrtc_holder["arret"].is_set()
        if webrtc_holder is not None:
            webrtc_holder["arret"].set()
        if mse_holder is not None:
            mse_holder["arret"].set()
        loop = webrtc_holder.get("loop") if webrtc_holder is not None else None
    if loop is not None and not deja_demande:
        loop.call_soon_threadsafe(webrtc_holder["interrompre"])
    return True

# Reglage de la page web (webrtc par defaut) depuis le 2026-09-03 - a
# remplace la variable d'environnement BLINK_DIRECT_WEBRTC, experimentale,
# une fois WebRTC valide en usage reel (BACKLOG.md). Lu une fois au
# demarrage, comme tous les autres reglages (port, fuseau...) : un
# changement redemarre deja le serveur (/api/reglages cote JS). Retombe sur
# MSE si aiortc n'est pas installe, meme "webrtc" choisi.
_reglages_direct = runtime.lire_reglages()
# Le chemin audio conserve H.264 + AAC ensemble en MSE. Le protocole
# préféré reste enregistré pour les sessions sans audio.
WEBRTC_ACTIF = (_reglages_direct["live_protocol"] == "webrtc"
                and not _reglages_direct["camera_audio_enabled"]
                and blink_webrtc.DISPONIBLE)


def _slot_pris(quoi: str, name: str = "") -> None:
    MODULE_SLOT_INFO.clear()
    MODULE_SLOT_INFO.update({"quoi": quoi, "camera": name, "depuis": time.monotonic()})


def _slot_rendu() -> None:
    MODULE_SLOT_INFO.clear()


def _slot_occupe_message() -> str:
    quoi = MODULE_SLOT_INFO.get("quoi")
    if not quoi:
        return msg("module_occupe")
    depuis = time.monotonic() - MODULE_SLOT_INFO.get("depuis", time.monotonic())
    camera = MODULE_SLOT_INFO.get("camera") or ""
    cible = f" ({camera})" if camera else ""
    return msg("module_occupe_par", quoi=_libelle_occupation(quoi),
               cible=cible, depuis=f"{depuis:.0f}")


# Ce qui occupe le module : identifiant interne (comparé tel quel, cf.
# « direct MSE » dans do_POST) d'un côté, libellé traduit de l'autre.
_OCCUPATIONS = {
    "reveil": "occupation_reveil",
    "snapshot": "occupation_photo",
    "direct WebRTC": "occupation_direct",
    "direct MSE": "occupation_direct",
    "actualisation": "occupation_actualisation",
    "suppression manuelle": "occupation_suppression",
}


def _libelle_occupation(quoi: str) -> str:
    cle = _OCCUPATIONS.get(quoi)
    return msg(cle) if cle else quoi
# Plafond d'une session de direct : voir runtime.DIRECT_MAX_SECONDES, la
# source unique, que relit aussi la lecture des réglages.
LIVE_MAX_SECONDS = runtime.DIRECT_MAX_SECONDES
# Délai accordé à la première image. Une caméra sur batterie doit se réveiller,
# donc on est patient ; au-delà on considère qu'elle ne répondra pas.
LIVE_FIRST_FRAME_SECONDS = 40
# Plafond de chaque attente dans la boucle d'envoi MSE, pour que l'arrêt
# explicite demandé via DIRECT_MSE_SESSION (voir _demander_arret_direct)
# soit revérifié à cette cadence plutôt que seulement une fois LIVE_MAX_SECONDS
# écoulé - sans changer le comportement d'un flux actif (les blocs arrivent
# bien avant cette seconde de toute façon).
LIVE_MSE_ARRET_POLL_SECONDS = 1.0

# Aucune vignette n'est redemandée d'elle-même : elle est récupérée une fois,
# puis conservée jusqu'à ce qu'on clique sur Actualiser. Une image qui change
# seule sous les yeux, sans qu'on l'ait demandé, n'est pas un service rendu.


# md.safe_name directement : safe_file() était une troisième copie du même
# nettoyage, dérivée des deux autres (revue de code du 0eab463, bug #5).
safe_file = md.safe_name

# Où sauver une copie d'un direct (WebRTC ou MSE) pendant qu'il joue, à côté
# de Blink_Clips et consorts (runtime.DOSSIERS_SORTIES).
DOSSIER_DIRECT = runtime.dossier_sorties() / "Blink_Direct"

# Photos prises à la demande (bouton, ou webhook externe - issue GitHub #9),
# distinctes des clips de détection : une caméra désarmée le jour n'en génère
# aucun, mais peut quand même répondre à une prise de vue explicite.
DOSSIER_SNAPSHOTS = runtime.dossier_sorties() / "Blink_Snapshots"
WEBHOOK_SNAPSHOT_ROUTE = "/webhook/snapshot"
# Etat des cameras et armement pour les scripts (issue #40), meme secret que la
# photo : un seul a retenir, decision de Nico et de Markus le 2026-09-30.
WEBHOOK_STATUS_ROUTE = "/webhook/status"
WEBHOOK_ARM_ROUTE = "/webhook/arm"
# Un POST sur /webhook/arm n'a pas de corps utile (tout passe par l'URL) : on
# le lit pour ne pas desynchroniser une connexion persistante, jusqu'a cette
# taille, au-dela on ferme la connexion.
_CORPS_WEBHOOK_MAX = 65536


def _booleen_webhook(valeur):
    """true/false, 1/0, on/off, yes/no, oui/non (casse indifferente), None sinon."""
    v = (valeur or "").strip().casefold()
    if v in ("true", "1", "on", "yes", "oui"):
        return True
    if v in ("false", "0", "off", "no", "non"):
        return False
    return None


def _camera_pour_webhook(camera: dict, systeme) -> dict:
    """Ce qu'un script voit d'une camera : les champs que Blink donne, tels
    quels, et null quand une camera ne les rapporte pas (tous les modeles n'ont
    ni batterie ni tension ni wifi). Pas de numero de serie."""
    return {
        "name": camera.get("name"),
        "system": systeme,
        "armed": camera.get("armed"),
        "online": not camera.get("offline"),
        "status": camera.get("status") or None,
        "battery": camera.get("battery"),
        "battery_signal": camera.get("battery_signal"),
        "voltage": camera.get("voltage"),
        "temperature_c": camera.get("temperature"),
        "wifi": camera.get("wifi"),
        "firmware": camera.get("firmware"),
        "model": camera.get("model"),
        "age_seconds": camera.get("age_seconds"),
    }


def etat_webhook(etat: dict, camera: str = ""):
    """Etat des systemes et des cameras pour /webhook/status, depuis le meme
    system_state() que la page. Avec `camera`, seulement celle-la ; None si ce
    nom ne correspond a aucune camera."""
    systemes = []
    trouvee = False
    for systeme in etat.get("systems") or []:
        cameras = [
            _camera_pour_webhook(c, systeme.get("name"))
            for c in systeme.get("cameras") or []
            if not camera or str(c.get("name") or "").strip() == camera
        ]
        if camera and not cameras:
            continue
        trouvee = trouvee or bool(cameras)
        systemes.append({
            "name": systeme.get("name"),
            "armed": systeme.get("armed"),
            "module": systeme.get("module"),
            "firmware": systeme.get("module_firmware"),
            "cameras": cameras,
        })
    if camera and not trouvee:
        return None
    return {"systems": systemes}


def _chemin_enregistrement_direct(name: str) -> Path:
    """Chemin du fichier où sauver le direct d'une caméra en cours.

    Même convention que target_path() (blink_models.py) pour les clips de
    détection : caméra/mois en dossiers, puis
    horodatage_caméra_identifiant_empreinte.mp4 - demandé explicitement pour
    qu'un enregistrement du direct ne se distingue pas visuellement d'un clip
    de détection dans l'arborescence (2026-09-04). target_path() elle-même
    n'est pas réutilisée : elle est bâtie autour d'un clip.id fourni par
    l'API Blink, qu'un direct n'a pas. `identifiant` en tient lieu, sans
    prétendre venir de l'API : les secondes depuis l'epoch UTC, un entier
    d'une longueur proche de celle d'un identifiant Blink réel plutôt qu'un
    horodatage qui ferait doublon avec le premier segment du nom. `suffixe`
    reprend exactement le mécanisme de target_path() (empreinte JSON, SHA256,
    12 caractères) : une seule caméra ne peut de toute façon avoir qu'un
    direct ouvert à la fois (MODULE_SLOT), la collision n'est pas la
    motivation ici, seule la cohérence visuelle avec les clips l'est."""
    maintenant = dt.datetime.now(dt.timezone.utc)
    horodatage = maintenant.strftime("%Y-%m-%d_%H-%M-%SZ")
    camera = safe_file(name)
    identifiant = str(int(maintenant.timestamp()))
    empreinte = json.dumps(
        [camera, horodatage, identifiant], ensure_ascii=False, separators=(",", ":")
    )
    suffixe = hashlib.sha256(empreinte.encode("utf-8")).hexdigest()[:12]
    nom_fichier = f"{horodatage}_{camera}_{identifiant}_{suffixe}.mp4"
    return DOSSIER_DIRECT / camera / maintenant.strftime("%Y-%m") / nom_fichier


def read_entries(paths: dict) -> dict:
    return md.read_registry(paths["input"] / md.DOWNLOAD_STATE)


def nombre_clips_connus(entries: dict) -> int:
    """Clips du registre comptés comme total_known["clip"] de collect() :
    la page compare les deux pour annoncer les nouveaux clips (audit du
    26/09/2026, B03). Compter aussi une entrée illisible, que la galerie
    ignore, annoncerait un « nouveau » clip à chaque sondage et relancerait
    sans fin l'actualisation automatique. Un clip purgé (purged_at : effacé
    par la conservation ou à la main) n'est pas compté, comme dans collect()."""
    total = 0
    for entry in entries.values():
        try:
            entry["path"]
            md.parse_created_at(entry["created_at"])
        except (KeyError, TypeError, ValueError):
            continue
        if entry.get("purged_at"):
            continue
        total += 1
    return total


ETIQUETTES_SOURCE = {"usb": "local", "cloud": "cloud"}

# Un seul réassemblage à la fois : deux assemblages simultanés de la même
# journée écriraient le même fichier.
REASSEMBLAGE = threading.Lock()

# Écarter un clip ne touche que le registre. Lui donner son propre verrou plutôt
# que celui de l'actualisation : sinon un clic pendant un téléchargement attend
# la fin de ce téléchargement, et le bouton semble bloqué une minute entière.
REGISTRE = threading.Lock()


def provenances(entrees: dict) -> dict:
    """Où sont enregistrés les clips de chaque caméra, par caméra.

    Constaté plutôt que déduit : c'est la provenance des clips réellement
    rapatriés, pas une lecture des réglages du compte. Une caméra couverte par
    un abonnement dit « cloud », une autre « USB », et l'absence des deux se
    voit aussi, ce qui explique une caméra qui ne produit rien. Les entrées
    antérieures à cette distinction viennent forcément de la clé, seule source
    existante alors."""
    vues = {}
    for entree in entrees.values():
        camera = str(entree.get("camera") or "").strip()
        if camera:
            vues.setdefault(camera, set()).add(str(entree.get("source") or "usb"))
    return {camera: " + ".join(ETIQUETTES_SOURCE.get(s, s) for s in sorted(sources))
            for camera, sources in vues.items()}


def suppression_auto_choices(entrees: dict) -> list:
    """Caméras réglables, indexées par leur identité persistée et non leur nom.

    Usage interne uniquement (résolution nom -> clé(s) pour la route
    /api/suppression-auto) : la page n'affiche jamais ces clés, seulement le
    nom des caméras actuelles (état de watch.py)."""
    choices = {}
    for entry in entrees.values():
        if not isinstance(entry, dict) or not entry.get("camera"):
            continue
        key = blink_registre.camera_setting_key_from_entry(entry)
        choices.setdefault(key, {
            "key": key,
            "name": str(entry.get("camera") or "camera").strip() or "camera",
        })
    result = list(choices.values())
    return sorted(result, key=lambda item: (item["name"].casefold(), item["key"]))


def suppression_auto_keys() -> set:
    """Ignore les anciens noms ambigus jusqu'à leur migration explicite."""
    return {
        value for value in runtime.lire_suppression_auto()
        if value.startswith("camera-v2-")
    }


# (clé du fichier registre, identités) de la dernière lecture : voir
# known_identities(). Un tuple remplacé d'un bloc, jamais muté, suffit entre
# les threads du serveur.
_IDENTITES_CONNUES: tuple = (None, frozenset())


def known_identities(paths: dict) -> frozenset:
    """Chemins de clips que le registre reconnaît, sans rien mesurer.

    Séparé de collect() à dessein : la validation d'une requête média passe par
    ici et doit rester instantanée, alors que l'inventaire complet peut avoir à
    lancer ffmpeg.

    Mémorisé tant que le fichier du registre ne change pas : chaque vignette
    et chaque requête Range d'un lecteur vidéo passent par ici, et relire le
    JSON complet à chaque fois coûtait ~27 ms par requête pour 10 000 clips
    (5,4 Mo, mesuré sur une machine rapide ; bien davantage sous Windows 7),
    soit plusieurs secondes de CPU pour une seule page de vignettes (audit du
    2026-09-24). Le registre n'est jamais réécrit en place (remplacement
    atomique) : inode, taille ou date changent donc à chaque écriture."""
    global _IDENTITES_CONNUES
    registre = paths["input"] / md.DOWNLOAD_STATE
    try:
        etat = registre.stat()
        cle = (os.fspath(registre), etat.st_mtime_ns, etat.st_size, etat.st_ino)
    except OSError:
        cle = None
    memorise = _IDENTITES_CONNUES
    if cle is not None and memorise[0] == cle:
        return memorise[1]
    identites = frozenset(
        entry["path"]
        for entry in read_entries(paths).values()
        if isinstance(entry, dict) and entry.get("path")
    )
    if cle is not None:
        _IDENTITES_CONNUES = (cle, identites)
    return identites


# Durée mesurée des clips écartés, qui n'ont plus d'entrée dans le registre
# normalisé (voir collect) : sans ce cache, ffmpeg -i était relancé pour
# chacun d'eux à chaque ouverture de la page.
EXCLUDED_DURATIONS = "excluded_durations.json"
# Même principe que EXCLUDED_DURATIONS, pour les vidéos assemblées
# (collect_videos) : sans lui, chaque redémarrage du serveur relançait
# ffmpeg -i pour TOUTES les journalières/hebdomadaires/mensuelles existantes
# dès la première ouverture de l'onglet Clips - le cache mémoire de
# probe_duration (_DURATIONS plus bas) ne survit pas à un redémarrage
# (audit general demande par l'utilisateur, 2026-09-03).
ASSEMBLED_DURATIONS = "assembled_durations.json"
# Écarter/réintégrer un clip de détection écrit dans le registre de
# téléchargement (blink_registre.py) : un enregistrement du direct n'y a
# jamais d'entrée, "écarter" ne peut donc reposer sur le même mécanisme.
# Fichier à part, aussi simple qu'un ensemble d'identités - pas de statut par
# clip à fusionner avec autre chose, contrairement au registre.
DIRECT_EXCLUSION = "direct_exclusion.json"


def _identite_selection_valide(identity: str) -> bool:
    # fullmatch interdit notamment une fin de ligne après l'extension.
    # Les suffixes espaces/points sont normalisés par Windows : les refuser
    # empêche aussi les variantes « .. » après normalisation native.
    return bool(IDENTITY.fullmatch(identity)) and all(
        morceau not in (".", "..") and morceau.rstrip(" .") == morceau
        for morceau in identity.split("/"))


def _chemin_direct_confine(racine: Path, identity: str) -> Path:
    if not _identite_selection_valide(identity):
        raise ValueError(msg("video_identite_invalide"))
    try:
        racine = racine.resolve()
        chemin = (racine / identity).resolve()
    except (OSError, RuntimeError) as erreur:
        raise ValueError(msg("video_chemin_inaccessible")) from erreur
    if not runtime.est_relatif_a(chemin, racine):
        raise ValueError(msg("video_hors_directs"))
    return chemin


# Sérialise la lecture-modification-écriture de DIRECT_EXCLUSION entre les
# threads du serveur : voir Handler._appliquer_selection_directs().
_VERROU_EXCLUSION_DIRECTE = threading.Lock()


def _lire_exclusion_directe(paths: dict) -> set:
    # {"excluded": [...]}, pas une liste nue : md.load_json/save_json (déjà
    # utilisés pour ASSEMBLED_DURATIONS...) exigent un objet
    # JSON - une liste nue s'écrit sans broncher mais se relit silencieusement
    # comme vide (isinstance(..., dict) rejette la liste, load_json rend
    # alors son défaut), constaté en vérifiant ce mécanisme (2026-09-04).
    valeurs = md.load_json(paths["thumbs"] / DIRECT_EXCLUSION, {}).get("excluded")
    return {str(v) for v in valeurs} if isinstance(valeurs, list) else set()


def _ecrire_exclusion_directe(paths: dict, identites: set) -> None:
    md.save_json(paths["thumbs"] / DIRECT_EXCLUSION, {"excluded": sorted(identites)})


# Fenêtre par défaut de /api/clips : au-delà, le nombre de vignettes ne fait
# que croître de jour en jour sans jamais être consulté. L'historique complet
# reste disponible sur demande explicite (paramètre ?all=1), jamais perdu.
DEFAULT_WINDOW_DAYS = 30

# Préréglages du filtre de plage (bouton de découpage rapide de la page
# clips), en heures plutôt qu'en jours : « aujourd'hui » vise les 24 dernières
# heures glissantes, pas depuis minuit, pour rester utile à toute heure de la
# journée. « month » reprend DEFAULT_WINDOW_DAYS : même bouton que la fenêtre
# par défaut, pas une deuxième valeur à tenir synchronisée. « 2months » suit
# la limite de rétention du stockage cloud Blink aux États-Unis, au-delà de
# laquelle les clips y sont supprimés (signalé sur Reddit, 2026-08-27).
RANGE_PRESETS_HOURS = {
    "today": 24,
    "week": 7 * 24,
    "month": DEFAULT_WINDOW_DAYS * 24,
    "2months": 60 * 24,
}

# L'API ne renvoie que des noms de code internes d'Amazon, jamais de référence
# commerciale. Deux seulement sont établis : « owl », documenté comme étant le
# Blink Mini dans le code de blinkpy, et « catalina », identifié par le
# propriétaire du matériel comme un Blink Outdoor. Tout autre nom de code reste
# affiché tel quel, annoncé comme interne, plutôt que traduit au jugé.
CAMERA_MODELS = {"owl": "Blink Mini", "catalina": "Blink Outdoor"}
# Le module, lui, porte sa génération dans son type : sm2 = Sync Module 2.
MODULE_MODELS = {"sm": "Sync Module", "sm2": "Sync Module 2"}


@dataclass(frozen=True)
class _SelectionVideos:
    """Identités locales demandées pour chacune des trois actions."""

    exclure: list[str]
    inclure: list[str]
    supprimer: list[str]


@dataclass(frozen=True)
class _CibleSuppressionBlink:
    """Relie un fichier local à son entrée de registre et à son ID distant."""

    identite: str
    entree: dict
    id_distant: str


def _lire_selection_videos(payload: dict) -> _SelectionVideos:
    # Conserver les champs absents ou vides acceptés par l'API historique.
    listes = [payload.get(champ) or [] for champ in ("exclure", "inclure", "supprimer")]
    for identites in listes:
        if not isinstance(identites, list):
            raise ValueError(msg("selection_videos_invalide"))
        for identite in identites:
            if not isinstance(identite, str) or not _identite_selection_valide(identite):
                raise ValueError(msg("selection_videos_invalide"))
    return _SelectionVideos(*listes)


def _separer_clips_et_directs(selection: _SelectionVideos, racine_direct: Path | None
                            ) -> tuple[_SelectionVideos, _SelectionVideos, dict]:
    """Identifie les fichiers directs avant toute lecture du registre ou mutation."""
    chemins_directs = {}
    if racine_direct:
        for identite in selection.exclure + selection.inclure + selection.supprimer:
            chemin = _chemin_direct_confine(racine_direct, identite)
            if chemin.is_file():
                chemins_directs[identite] = chemin

    clips = _SelectionVideos([], [], [])
    directs = _SelectionVideos([], [], [])
    for demandes, liste_clips, liste_directs in (
            (selection.exclure, clips.exclure, directs.exclure),
            (selection.inclure, clips.inclure, directs.inclure),
            (selection.supprimer, clips.supprimer, directs.supprimer)):
        for identite in demandes:
            if identite in chemins_directs:
                liste_directs.append(identite)
            else:
                liste_clips.append(identite)
    return clips, directs, chemins_directs


def _trouver_entree_selection(entrees: dict, identite: str) -> dict | None:
    # En cas de doublon de chemin, conserver la première entrée du registre.
    return next((entree for entree in entrees.values()
                 if isinstance(entree, dict) and entree.get("path") == identite), None)


def _jours_selection_a_reconstruire(identites: list[str], entrees: dict,
                                  reglages: dict) -> set[tuple[str, str]]:
    """Un seul couple (identité caméra, jour local) par journée touchée."""
    jours = set()
    cles_collision = md._cles_camera_par_collision(entrees)
    fuseau_fusion = ZoneInfo(reglages["timezone"])
    for identite in identites:
        entree = _trouver_entree_selection(entrees, identite)
        camera = str((entree or {}).get("camera") or "").strip()
        try:
            jour = md.parse_created_at(
                str((entree or {}).get("created_at"))
            ).astimezone(fuseau_fusion).date().isoformat()
        except (TypeError, ValueError):
            jour = None
        if camera and jour:
            jours.add((md._camera_key(cles_collision, entree, camera), jour))
    return jours


def _preparer_suppressions_blink(identites: list[str], entrees: dict,
                               resultats: dict) -> list[_CibleSuppressionBlink]:
    """Résout les cibles connues et note les refus sans contacter Blink."""
    cibles = []
    for identite in identites:
        entree = _trouver_entree_selection(entrees, identite)
        if entree is None:
            resultats[identite] = "inconnu"
            continue
        # Convention blink_models.target_path ; remote_id sert aux anciens clips.
        correspondance = re.search(r"_(\d+)_[0-9a-f]{12}\.mp4$", identite)
        id_distant = correspondance.group(1) if correspondance else str(
            entree.get("remote_id") or "")
        if not id_distant:
            resultats[identite] = "identifiant_introuvable"
            continue
        cibles.append(_CibleSuppressionBlink(identite, entree, id_distant))
    return cibles


def camera_key(sync, name: str, camera) -> str:
    """Identifiant opaque et stable d'une caméra pour l'interface web."""
    attributes = getattr(camera, "attributes", None) or {}
    network_id = (
        getattr(camera, "network_id", None)
        or getattr(sync, "network_id", None)
        or ""
    )
    device_id = ""
    for value in (
        getattr(camera, "device_id", None),
        getattr(camera, "camera_id", None),
        attributes.get("device_id"),
        attributes.get("camera_id"),
        attributes.get("id"),
    ):
        if value not in (None, ""):
            device_id = str(value)
            break
    material = (
        ["device", str(network_id), device_id]
        if device_id
        else [
            "legacy", str(network_id), str(getattr(sync, "sync_id", "")),
            str(name).strip().casefold(),
        ]
    )
    digest = hashlib.sha256(
        json.dumps(material, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    ).hexdigest()
    return f"camera-{digest[:24]}"


def system_key(name: str, sync) -> str:
    material = [
        str(getattr(sync, "network_id", "")),
        str(getattr(sync, "sync_id", "")),
        str(name).strip().casefold(),
    ]
    digest = hashlib.sha256(
        json.dumps(material, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    ).hexdigest()
    return f"system-{digest[:24]}"


def model_name(kind: str | None) -> str | None:
    """Nom du modèle d'une caméra pour la ligne technique de sa carte : le
    nom du produit quand on le connaît, sinon le code que Blink donne au type
    (« hawk », « sedona », « lotus »), tel quel. Jamais une phrase : ce texte
    part vers une page qui s'affiche en français ou en anglais, et « type
    Blink « hawk » » restait français sous une page anglaise (issue #37)."""
    if not kind:
        return None
    return CAMERA_MODELS.get(kind) or kind


def horodatage_releve(moment: dt.datetime, fuseau, maintenant: dt.datetime | None = None):
    """(date ISO à la minute, heure du serveur ; est-ce aujourd'hui ?) d'un
    relevé de caméra. Ni mot ni format de date d'une langue : c'est la page
    qui compose « relevé à 14:30 » ou « measured on 09/28 at 14:30 » (issue
    #37 : « 28/09 à 14:30 » partait tel quel sous une page anglaise)."""
    local = moment.astimezone(fuseau)
    maintenant = dt.datetime.now(fuseau) if maintenant is None else maintenant.astimezone(fuseau)
    return local.isoformat(timespec="minutes"), maintenant.date() == local.date()


def load_excluded_durations(paths: dict) -> dict:
    cache = md.load_json(paths["thumbs"] / EXCLUDED_DURATIONS, {})
    return cache if isinstance(cache, dict) else {}


def load_assembled_durations(paths: dict) -> dict:
    cache = md.load_json(paths["thumbs"] / ASSEMBLED_DURATIONS, {})
    return cache if isinstance(cache, dict) else {}


def probe_duration_cached(ffmpeg: str, source: Path, identity: str, cache: dict) -> float:
    """Durée d'un clip écarté, mesurée une seule fois puis mise en cache.

    La clé retient taille et date de modification du fichier source : un clip
    remplacé (réparation d'un média absent, par exemple) est ainsi re-mesuré
    plutôt que de garder une durée périmée."""
    stat = source.stat()
    empreinte = [stat.st_size, stat.st_mtime]
    entree = cache.get(identity)
    if entree and entree.get("empreinte") == empreinte:
        return entree["duration"]
    duration = probe_duration(ffmpeg, source)
    cache[identity] = {"duration": duration, "empreinte": empreinte}
    return duration


def collect(paths: dict, timezone: ZoneInfo, ffmpeg: str = "",
            depuis: "dt.datetime | None" = None,
            jusqua: "dt.datetime | None" = None) -> dict:
    """Inventorie les clips connus du registre de téléchargement.

    Contrairement à merge_daily.load_groups, les clips écartés sont conservés :
    c'est précisément ce qu'on veut pouvoir revoir et reprendre.

    `depuis`/`jusqua` bornent la fenêtre renvoyée, chacun optionnel : le stock
    grossit chaque jour, et sans borne basse la page finirait par transmettre
    et dessiner un nombre de vignettes sans rapport avec ce qu'on regarde
    réellement. L'historique complet reste accessible explicitement (voir
    DEFAULT_WINDOW_DAYS). Les deux sont des horodatages complets, pas de
    simples dates : le filtre de plage personnalisée de la page cible une
    période à l'heure près, pour retrouver un incident précis sans défiler
    tout l'historique d'une caméra toujours armée (signalé sur Reddit,
    2026-08-27)."""
    entries = read_entries(paths)
    # Les durées déjà mesurées par merge_daily sont reprises telles quelles ;
    # seules celles des clips écartés, dont l'entrée a été balayée du registre
    # normalisé, restent à mesurer, via leur propre cache (voir
    # load_excluded_durations) pour ne pas relancer ffmpeg à chaque requête.
    probed = md.load_json(paths["normalized"] / md.NORMALIZED_STATE, {}).get("clips")
    probed = probed if isinstance(probed, dict) else {}
    duration_cache = None

    clips = []
    # Compté par genre : le récapitulatif du filtre ("X/Y clips") doit
    # rester propre à la vue affichée (Clip Détection ou Enregistrement
    # Direct), pas mélanger les deux depuis qu'ils partagent cette liste
    # (2026-09-04).
    total_clips = 0
    total_direct = 0
    for entry in entries.values():
        try:
            identity = entry["path"]
            created = md.parse_created_at(entry["created_at"])
            camera = str(entry.get("camera") or "camera").strip() or "camera"
        except (KeyError, TypeError, ValueError):
            continue
        if entry.get("purged_at"):
            # Effacé par la conservation ou à la main : le registre le garde
            # pour ne pas le retélécharger (#118, #120), la galerie ne doit
            # plus le montrer ni le compter (issue #120, MarkusKress : plus
            # de 4 600 entrées vides après la purge).
            continue

        local = created.astimezone(timezone)
        total_clips += 1
        if depuis is not None and local < depuis:
            continue
        if jusqua is not None and local > jusqua:
            continue
        excluded = bool(entry.get("excluded"))
        # On sert de préférence la version normalisée : c'est celle qui porte
        # l'horodatage incrusté, donc celle qui aide vraiment à juger.
        source = None
        for kind in ("normalized", "input", "excluded"):
            if (paths[kind] / identity).is_file():
                source = kind
                break

        probe = (probed.get(identity) or {}).get("probe") or {}
        seconds = probe.get("duration")
        # Un clip écarté n'a plus d'entrée dans le registre normalisé : on le
        # mesure alors une fois, depuis Blink_Excluded, puis on se souvient.
        if seconds is None and source and ffmpeg:
            if duration_cache is None:
                duration_cache = load_excluded_durations(paths)
            seconds = probe_duration_cached(
                ffmpeg, paths[source] / identity, identity, duration_cache)

        clips.append({
            "identity": identity,
            "kind": "clip",
            "camera": camera,
            "cameraKey": blink_registre.camera_setting_key_from_entry(entry),
            "day": local.date().isoformat(),
            "time": local.strftime("%H:%M:%S"),
            "excluded": excluded,
            "source": source,
            "origine": ETIQUETTES_SOURCE.get(str(entry.get("source") or "usb"),
                                             str(entry.get("source"))),
            "sourceDeleted": bool(entry.get("source_deleted")),
            "duration": float(seconds or 0.0),
        })

    if duration_cache is not None:
        md.save_json(paths["thumbs"] / EXCLUDED_DURATIONS, duration_cache)

    # Enregistrements du direct (bouton "Enregistrer" pendant Direct,
    # Blink_Direct) : même présentation que les clips de détection, demandé
    # explicitement (2026-09-04) plutôt qu'une vue à part avec ses propres
    # règles - fusionnés dans la même liste, "kind" est le seul champ qui
    # les distingue vraiment (jour/heure/durée/écarter/supprimer marchent
    # identiquement ensuite). "clip"/direct" à la place de source/origine
    # (bornées à la provenance cloud/USB d'un clip, sans le sens pour un
    # direct) - voir _chemin_enregistrement_direct pour la convention de
    # nommage dont "day"/"time" sont extraits ici.
    exclusion_directe = _lire_exclusion_directe(paths)
    duration_cache_direct = None
    racine_direct = paths.get("direct")
    if racine_direct is not None and racine_direct.is_dir():
        for camera_dir in sorted(
            p for p in racine_direct.iterdir()
            if p.is_dir() and not p.name.startswith(".")
        ):
            for video in sorted(camera_dir.rglob("*.mp4"), reverse=True):
                if not md.valid_mp4(video):
                    continue
                identity = f"{camera_dir.name}/{video.relative_to(camera_dir).as_posix()}"
                try:
                    created = dt.datetime.strptime(
                        video.stem[:20], "%Y-%m-%d_%H-%M-%SZ"
                    ).replace(tzinfo=dt.timezone.utc)
                except ValueError:
                    created = dt.datetime.fromtimestamp(
                        video.stat().st_mtime, dt.timezone.utc
                    )
                local = created.astimezone(timezone)
                total_direct += 1
                if depuis is not None and local < depuis:
                    continue
                if jusqua is not None and local > jusqua:
                    continue
                if duration_cache_direct is None:
                    duration_cache_direct = load_assembled_durations(paths)
                clips.append({
                    "identity": identity,
                    "kind": "direct",
                    "camera": camera_dir.name,
                    "cameraKey": safe_file(camera_dir.name),
                    "day": local.date().isoformat(),
                    "time": local.strftime("%H:%M:%S"),
                    "excluded": identity in exclusion_directe,
                    "source": "direct",
                    "origine": "Direct",
                    "sourceDeleted": False,
                    "duration": probe_duration_cached(
                        ffmpeg, video, f"direct/{identity}", duration_cache_direct
                    ),
                })
    if duration_cache_direct is not None:
        md.save_json(paths["thumbs"] / ASSEMBLED_DURATIONS, duration_cache_direct)

    # Du plus récent au plus ancien : c'est ce qu'on vient regarder. Un seul
    # tri global est nécessaire ; trier ensuite par caméra ferait passer une
    # caméra plus ancienne devant les clips récents d'une autre caméra.
    clips.sort(key=lambda clip: (clip["day"], clip["time"]), reverse=True)
    return {
        "clips": clips,
        "cameras": sorted({clip["camera"] for clip in clips}),
        "passages": runtime.passages(),
        "sources": provenances(entries),
        # Permet à la page de dire « X clips sur Y connus » et de proposer
        # explicitement de charger le reste : quelle plage précise est active
        # (préréglage ou personnalisée) est déjà su côté page, elle seule l'a
        # demandée, inutile de la lui redécrire ici.
        "filtered": depuis is not None or jusqua is not None,
        # Par genre (clip/direct), pas un seul total : sinon le récapitulatif
        # de Clip Détection compterait aussi les enregistrements du direct,
        # et réciproquement, depuis qu'ils partagent "clips" (2026-09-04).
        "total_known": {"clip": total_clips, "direct": total_direct},
        # La galerie ne propose pas la case Supprimer pour une caméra déjà en
        # suppression automatique (issue GitHub #1) : redondant, le clip sera
        # de toute façon retiré de sa source au prochain téléchargement réussi.
        "suppressionAuto": sorted(suppression_auto_keys()),
    }


def collect_videos(paths: dict, ffmpeg: str) -> dict:
    """Inventorie les vidéos assemblées par période (daily/weekly/monthly).

    Les enregistrements du direct (Blink_Direct) n'en font plus partie
    depuis qu'ils partagent la présentation des clips de détection
    (2026-09-04) : voir collect(), qui les lit désormais - même parcours
    récursif root/caméra/**/*.mp4, mais fusionnés dans "clips" plutôt
    qu'exposés à part ici.

    On lit le disque plutôt qu'un registre : ces fichiers sont le produit
    visible du pipeline (ou du bouton d'enregistrement), et les lister tels
    qu'ils existent évite d'afficher une entrée pour une vidéo effacée à la
    main.

    Durée mise en cache sur disque (probe_duration_cached, même mécanisme
    que pour les clips écartés) plutôt que le simple probe_duration en
    mémoire seule utilisé jusque-là : sans ça, un redémarrage relançait
    ffmpeg -i pour chaque vidéo existante dès la première ouverture de
    l'onglet (audit général demandé par l'utilisateur, 2026-09-03)."""
    result = {}
    duration_cache = None
    for kind in ("daily", "weekly", "monthly"):
        items = []
        root = paths[kind]
        if root.is_dir():
            for camera_dir in sorted(
                p for p in root.iterdir() if p.is_dir() and not p.name.startswith(".")
            ):
                # rglob, pas glob : daily/weekly/monthly posent leurs fichiers
                # à plat sous camera_dir, mais direct les range en plus par
                # mois (camera_dir/AAAA-MM/*.mp4, même convention que
                # target_path() pour les clips de détection - voir
                # _chemin_enregistrement_direct). Un seul parcours récursif
                # couvre les deux formes sans distinguer kind ici.
                for video in sorted(camera_dir.rglob("*.mp4"), reverse=True):
                    if not md.valid_mp4(video):
                        continue
                    if duration_cache is None:
                        duration_cache = load_assembled_durations(paths)
                    relatif = video.relative_to(camera_dir).as_posix()
                    # kind dans l'identité : daily/weekly/monthly peuvent
                    # chacun contenir un fichier du même nom pour la même
                    # caméra, sans rapport entre eux.
                    identity = f"{kind}/{camera_dir.name}/{relatif}"
                    items.append({
                        "kind": kind,
                        "path": f"{camera_dir.name}/{relatif}",
                        "camera": camera_dir.name,
                        "label": video.stem.replace(f"_{camera_dir.name}", ""),
                        "bytes": video.stat().st_size,
                        "duration": probe_duration_cached(
                            ffmpeg, video, identity, duration_cache),
                    })
        result[kind] = items
    if duration_cache is not None:
        md.save_json(paths["thumbs"] / ASSEMBLED_DURATIONS, duration_cache)
    return result


_DURATIONS: dict = {}


def probe_facts(ffmpeg: str, path: Path) -> tuple:
    """Durée et définition d'une vidéo, mémorisées tant qu'elle ne change pas.

    Chaque mesure coûte un lancement de ffmpeg ; sans ce cache, ouvrir l'onglet
    des mensuelles relancerait l'analyse à chaque affichage."""
    key = (str(path), path.stat().st_mtime_ns)
    if key not in _DURATIONS:
        try:
            duration, width, height, _, _ = md.probe_clip_info(ffmpeg, path)
            _DURATIONS[key] = (duration, width, height)
        except RuntimeError:
            _DURATIONS[key] = (0.0, 0, 0)
    return _DURATIONS[key]


def probe_duration(ffmpeg: str, path: Path) -> float:
    return probe_facts(ffmpeg, path)[0]


class LecteurTube:
    """Lit un tube bloquant en continu dans un fil unique, pour donner à
    chaque lecture un vrai délai sans jamais créer de second lecteur
    concurrent sur le même tube.

    `pipe.read(n)` est bloquant et ne se laisse pas interrompre. Le lancer
    dans un fil à *chaque* appel (comme le faisait l'ancien
    `read_with_deadline`, réutilisé en boucle par le direct) laisse un fil
    orphelin dès qu'un appel dépasse son délai : `pipe.read()` continue
    d'attendre en arrière-plan, et l'appel suivant en lance un second,
    concurrent du premier sur le même tube. Le prochain octet qui arrive
    peut alors atterrir chez le fil orphelin, invisible pour l'appelant
    (bug réel, voir AUDIT-2026-08-13.md section 28.22 pour `/live-mse`, et
    28.26 pour la boucle d'envoi principale des deux directs — un délai
    global vérifié seulement *entre* deux lectures ne borne rien si une
    lecture individuelle peut, elle, bloquer indéfiniment)."""

    def __init__(self, pipe, taille_bloc: int = 16384):
        self._file: queue.Queue = queue.Queue()
        self._fin = False

        def lire_en_continu():
            while True:
                morceau = pipe.read(taille_bloc)
                self._file.put(morceau)
                if not morceau:
                    return

        threading.Thread(target=lire_en_continu, daemon=True).start()

    def lire(self, delai: float):
        """Un bloc de données ; ``b""`` si le tube est à sa vraie fin (EOF) ;
        ``None`` si rien n'est arrivé avant `delai` (le flux est
        probablement juste lent — le fil, lui, continue d'attendre, un
        appel suivant peut encore recevoir quelque chose)."""
        if self._fin:
            return b""
        try:
            morceau = self._file.get(timeout=max(0.01, delai))
        except queue.Empty:
            return None
        if not morceau:
            self._fin = True
        return morceau


class DetecteurDebutFragment:
    """Repère le début d'un fragment fMP4 ("moof") dans un flux continu,
    pour ne jamais démarrer un enregistrement au milieu d'un moof/mdat entamé.

    Les blocs que LecteurTube.lire() fait remonter (16 Ko) ne s'alignent
    presque jamais sur les frontières entre fragments moof+mdat de ffmpeg.
    trouver_debut_fragment() localise l'offset exact du premier moof débutant
    dans le bloc courant, ce qui permet à l'enregistrement de démarrer
    instantanément et proprement dès la prochaine image-clé (-movflags
    frag_keyframe), sans risque de tronquer un mdat antérieur."""

    def __init__(self):
        self._restant_boite = 0
        self._genre_courant = b""
        self._entete = b""
        self._entete_debute_dans_ce_bloc = False
        self._offset_debut_entete = 0
        self.dernier_offset_boite_complete: int | None = None
        self.dernier_offset_fragment_complet: int | None = None

    def trouver_debut_fragment(self, chunk: bytes) -> int | None:
        """Retourne l'offset dans `chunk` où débute un fragment 'moof',
        ou None si aucun nouveau fragment ne commence dans ce bloc.
        Met à jour le suivi des boîtes pour l'intégralité du bloc."""
        position = 0
        n = len(chunk)
        premier_moof_offset = None
        self.dernier_offset_boite_complete = None
        self.dernier_offset_fragment_complet = None

        while position < n:
            if self._restant_boite > 0:
                pris = min(self._restant_boite, n - position)
                position += pris
                self._restant_boite -= pris
                if self._restant_boite == 0:
                    self.dernier_offset_boite_complete = position
                    if self._genre_courant == b"mdat":
                        self.dernier_offset_fragment_complet = position
                continue

            # Début d'une boîte
            if not self._entete:
                self._offset_debut_entete = position
                self._entete_debute_dans_ce_bloc = True

            manque = 8 - len(self._entete)
            pris = min(manque, n - position)
            self._entete += chunk[position:position + pris]
            position += pris

            if len(self._entete) < 8:
                # L'en-tête de 8 octets est à cheval sur la fin de ce bloc
                self._entete_debute_dans_ce_bloc = False
                break

            taille = int.from_bytes(self._entete[:4], "big")
            genre = self._entete[4:8]
            est_debut_complet = self._entete_debute_dans_ce_bloc
            offset_boite = self._offset_debut_entete
            self._entete = b""
            self._entete_debute_dans_ce_bloc = False
            self._genre_courant = genre

            if genre == b"moof" and premier_moof_offset is None and est_debut_complet:
                premier_moof_offset = offset_boite

            if taille < 8:
                self._restant_boite = 0  # boîte dégénérée : abandonne le suivi
                break
            self._restant_boite = taille - 8
            if self._restant_boite == 0:
                self.dernier_offset_boite_complete = position
                if genre == b"mdat":
                    self.dernier_offset_fragment_complet = position

        return premier_moof_offset

    def bloc_ouvre_un_fragment(self, chunk: bytes) -> bool:
        """Vrai si un nouveau fragment 'moof' débute dans ce bloc."""
        return self.trouver_debut_fragment(chunk) is not None


def _extraire_init_segment(segment: bytes) -> bytes:
    """Isole ftyp + moov d'un bloc initial, sans fragments postérieurs tronqués."""
    pos = 0
    n = len(segment)
    while pos + 8 <= n:
        taille = int.from_bytes(segment[pos:pos + 4], "big")
        genre = segment[pos + 4:pos + 8]
        if taille < 8 or genre not in (b"ftyp", b"moov"):
            break
        pos += taille
    return segment[:pos] if pos > 0 else segment


_MOOV_CONTAINERS = {b"moov", b"trak", b"mdia", b"minf", b"stbl"}


def h264_mime_codec_from_moov(segment: bytes) -> str:
    """Cherche avcC dans un segment d'initialisation MP4 (ftyp+moov) pour en
    tirer la chaîne de codec MSE exacte (avc1.PPCCLL : profil, contraintes,
    niveau). Deviner cette chaîne plante addSourceBuffer dès que la caméra
    encode dans un profil ou un niveau différent de celui supposé ; la lire
    dans le flux réel est le seul moyen fiable.

    Ne suit que le chemin utile : moov > trak > mdia > minf > stbl > stsd >
    avc1 > avcC. avcC porte déjà profil/contraintes/niveau en clair
    (AVCDecoderConfigurationRecord), pas besoin de redescendre jusqu'au
    SPS qu'il contient lui-même."""
    def walk(buf: bytes) -> bytes | None:
        i, n = 0, len(buf)
        while i + 8 <= n:
            size = int.from_bytes(buf[i:i + 4], "big")
            kind = buf[i + 4:i + 8]
            if size < 8 or i + size > n:
                break
            payload = buf[i + 8:i + size]
            if kind == b"avcC":
                return payload
            if kind in _MOOV_CONTAINERS:
                found = walk(payload)
            elif kind == b"stsd":
                found = walk(payload[8:])  # version+flags+entry_count
            elif kind == b"avc1":
                # SampleEntry (8) + champs fixes VisualSampleEntry (70) avant
                # les boîtes filles (avcC, ...).
                found = walk(payload[78:])
            else:
                found = None
            if found is not None:
                return found
            i += size
        return None

    avcc = walk(segment)
    if avcc is None or len(avcc) < 4:
        raise RuntimeError(
            msg("avcc_introuvable"))
    profile_idc, constraint, level_idc = avcc[1], avcc[2], avcc[3]
    return f"avc1.{profile_idc:02x}{constraint:02x}{level_idc:02x}"


def read_mp4_init_segment(lecteur: LecteurTube, seconds: float) -> bytes:
    """Accumule la sortie d'ffmpeg jusqu'à pouvoir y lire un avcC complet, ou
    renonce au bout de `seconds`. Un seul bloc de 4096 octets (comme pour le
    MJPEG) ne suffit pas toujours : le moov peut déborder du premier bloc.

    Prend un `LecteurTube` déjà créé, pas un tube brut : le même doit
    ensuite servir à la boucle d'envoi principale de l'appelant. En créer
    un second sur le même tube réintroduirait le bug des lecteurs
    concurrents que `LecteurTube` existe justement pour éviter."""
    deadline = time.monotonic() + seconds
    buf = bytearray()
    while time.monotonic() < deadline and len(buf) < 65536:
        morceau = lecteur.lire(deadline - time.monotonic())
        if not morceau:
            break  # b"" (EOF) ou None (délai global épuisé) : rien de plus à tenter
        buf += morceau
        try:
            h264_mime_codec_from_moov(bytes(buf))
            break  # trouvé : pas la peine de lire plus avant d'envoyer les en-têtes
        except RuntimeError:
            continue
    return bytes(buf)


# Dernier échec de direct, relu par la page : une balise <img> ne peut pas lire
# le corps d'une réponse en erreur, elle ne voit qu'un échec de chargement.
LAST_LIVE_ERROR: dict = {}
LAST_LIVE_ERROR_LOCK = threading.Lock()


def _memoriser_erreur_direct(camera: str, message: str, status: int) -> None:
    """Publie d'un seul bloc l'échec que le navigateur viendra relire."""
    with LAST_LIVE_ERROR_LOCK:
        LAST_LIVE_ERROR.clear()
        LAST_LIVE_ERROR.update({
            "camera": camera, "message": message, "status": status,
        })


def _effacer_erreur_direct() -> None:
    """Une nouvelle tentative acceptée rend tout ancien échec caduc."""
    with LAST_LIVE_ERROR_LOCK:
        LAST_LIVE_ERROR.clear()


def _derniere_erreur_direct() -> dict:
    """Copie cohérente pour le thread HTTP qui sert /api/live-error."""
    with LAST_LIVE_ERROR_LOCK:
        return dict(LAST_LIVE_ERROR)


def _texte_stderr_ffmpeg(errors: list) -> str:
    """Normalise stderr, que subprocess livre en bytes dans ce pipeline.

    _drainer_stderr alimente `errors` ligne par ligne (diagnostic temporaire),
    plus un seul bloc comme avant : on rejoint tout, ça couvre les deux
    formes."""
    raw = b"".join(e for e in errors if isinstance(e, bytes))
    return raw.decode("utf-8", "replace").strip()[:300]


def _journal_direct(name: str, message: str) -> None:
    """Un journal indisponible ne doit jamais interrompre un direct.

    Commun a MSE et WebRTC (renomme le 2026-09-03 : le prefixe "direct-mse"
    laissait croire a une bascule vers MSE meme sur une ligne concernant
    WebRTC - source de confusion reelle, cf. BACKLOG.md). Le protocole en
    cause reste lisible dans le texte de chaque message ("webrtc"/"MSE"
    explicite), jamais dans ce prefixe generique. Ecrit aussi dans
    direct.log (runtime.ajouter_ligne) : le lancement normal tourne sous
    pythonw (autostart), donc sans console ou lire un print()."""
    horodatage = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
    ligne = f"[direct] {horodatage} {name} : {message}"
    try:
        print(ligne, flush=True)
    except Exception:
        pass
    runtime.ajouter_ligne("direct.log", ligne)


async def _stop_stream(stream, feed) -> str:
    """Ferme les sockets, attend le polling, et assure son secours « done ».

    init_livestream a déjà créé une commande, même si start échoue ou si
    l'authentification du relais échoue avant le polling de blinkpy. Ces cas
    doivent eux aussi envoyer done. Cette coroutine est protégée par la tâche
    de nettoyage de la session contre les annulations du demandeur HTTP.
    """
    erreurs = []
    try:
        stream.stop()
    except Exception as error:
        erreurs.append(f"stop : {type(error).__name__}: {error}")
    if feed is not None:
        try:
            # wait() ne peut pas rester bloqué dans le finally d'une tâche
            # annulée, contrairement à wait_for(feed, ...).
            terminees, _ = await asyncio.wait({feed}, timeout=20)
            if feed in terminees:
                await feed
                if not erreurs:
                    return "session rendue normalement"
            else:
                feed.cancel()
                # Laisse au finally du polling une chance d'envoyer done.
                await asyncio.wait({feed}, timeout=5)
                if feed.done():
                    try:
                        feed.result()
                    except (Exception, asyncio.CancelledError):
                        pass
                else:
                    feed.add_done_callback(_observer_fin_flux)
        except asyncio.CancelledError:
            # Une feed déjà annulée ne doit pas annuler le nettoyage qui
            # l'attend. Une annulation du nettoyeur lui-même se propage.
            if not feed.cancelled():
                raise
            erreurs.append(msg("flux_annule_avant_fin"))
        except Exception as error:
            erreurs.append(f"flux : {type(error).__name__}: {error}")

    try:
        from blinkpy import api

        await asyncio.wait_for(
            api.request_command_done(
                stream.camera.sync.blink, stream.camera.network_id, stream.command_id
            ), timeout=10,
        )
        return msg("session_rendue_de_force") + (" | " + " ; ".join(erreurs) if erreurs else "")
    except Exception as error:
        return f"session peut-etre restee ouverte : {type(error).__name__}"


def _observer_fin_flux(tache) -> None:
    """Récupère les exceptions d'une fermeture qui finit après son budget."""
    try:
        tache.result()
    except (Exception, asyncio.CancelledError):
        pass


def _erreur_boucle_asyncio(loop, contexte: dict) -> None:
    """Filet pour toute exception non rattrapée dans une tâche/callback de
    BLINK.loop (RTCPeerConnection, connectionstatechange...).

    Sans lui, le comportement par défaut d'asyncio (logging.error) part vers
    un stderr qui n'existe pas sous pythonw (lancement normal, autostart) :
    aucune trace nulle part. Vécu en réel (2026-09-04) : une session WebRTC
    bloquée, MODULE_SLOT jamais rendu, sans le moindre indice dans direct.log
    ni serve_erreurs.log (celui-ci ne couvre que les requêtes HTTP, pas les
    callbacks aiortc sur cette boucle)."""
    exception = contexte.get("exception")
    detail = f"{type(exception).__name__}: {exception}" if exception else contexte.get(
        "message", msg("erreur_inconnue")
    )
    _journal_direct("asyncio", msg("exception_boucle", detail=detail))


# Attente de la confirmation de Blink apres un armement de camera (voir
# _confirmer_armement_camera) ; BLINK.call() borne l'ensemble a 60 s.
DELAI_CONFIRMATION_ARMEMENT = 45


async def _confirmer_armement_camera(blink, camera, reponse, armed: bool) -> None:
    """Attend que Blink ait appliqué l'armement d'une caméra, puis le retient.

    blinkpy poste la commande (camera.async_arm) et rend sa réponse sans la
    suivre, et ne met pas à jour camera.motion_enabled, l'attribut que
    describe_camera() affiche : depuis l'allègement de system_state() (le
    2026-09-03, il ne relit plus que l'écran d'accueil et l'armement du hub),
    cet attribut gardait sa valeur de la connexion. Armer une caméra depuis la
    page laissait donc son bouton rouge, alors que celui du hub, relu à chaque
    fois, passait au vert (constaté en réel le 2026-10-05, caméra « Salon »), et
    le webhook d'état annonçait un armement périmé.

    On suit donc la commande, comme reveiller_camera(), et on pose la valeur
    confirmée. Un refus ou une absence de confirmation devient une erreur lisible
    au lieu d'un échec silencieux."""
    from blinkpy import api

    if not isinstance(reponse, dict) or not reponse.get("id"):
        raise RuntimeError(msg("armement_refuse"))
    commande = dict(reponse)
    commande.setdefault("network_id", camera.network_id)
    try:
        confirme = await asyncio.wait_for(
            api.wait_for_command(blink, commande), DELAI_CONFIRMATION_ARMEMENT)
    except asyncio.TimeoutError:
        confirme = False
    if not confirme:
        raise RuntimeError(msg("armement_non_confirme"))
    camera.motion_enabled = bool(armed)


class BlinkSession:
    """Session Blink partagée, vivant sur sa propre boucle asyncio.

    Armer une caméra ou ouvrir un direct suppose une session authentifiée. La
    rouvrir à chaque clic coûterait deux secondes et un aller-retour inutile
    chez Amazon : on la garde donc ouverte, et on lui parle depuis les fils du
    serveur HTTP via `call`."""

    def __init__(self):
        self.lock = threading.Lock()
        self.loop = None
        self.session = None
        self.blink = None

    def _ensure_loop(self):
        if self.loop is None:
            self.loop = asyncio.new_event_loop()
            self.loop.set_exception_handler(_erreur_boucle_asyncio)
            threading.Thread(
                target=lambda: (asyncio.set_event_loop(self.loop),
                                self.loop.run_forever()),
                daemon=True,
            ).start()
        return self.loop

    def call(self, coroutine_factory, timeout: float = 60.0):
        """Exécute `coroutine_factory(blink)` sur la boucle, en se connectant
        si besoin. Lève RuntimeError si aucune session valable n'existe."""
        echeance = time.monotonic() + timeout
        if not self.lock.acquire(timeout=max(0.0, timeout)):
            raise concurrent.futures.TimeoutError(msg("session_occupee"))
        try:
            loop = self._ensure_loop()
            finished = threading.Event()

            async def run():
                try:
                    if self.blink is None:
                        self.session = blink_auth.session_http()
                        self.blink = await blink_auth.connect_saved(self.session)
                    if self.blink is None:
                        raise RuntimeError(msg("session_absente_expiree"))
                    return await coroutine_factory(self.blink)
                finally:
                    finished.set()

            future = asyncio.run_coroutine_threadsafe(run(), loop)
            try:
                return future.result(max(0.0, echeance - time.monotonic()))
            except concurrent.futures.TimeoutError:
                # result(timeout) ne borne que l'attente du thread appelant :
                # sans annulation, la coroutine continue sur la boucle Blink et
                # peut armer une caméra ou supprimer un clip après que l'API a
                # déjà annoncé un échec. La conserver permet au moins d'arrêter
                # tout ce qui n'a pas encore été envoyé au service distant.
                future.cancel()
                try:
                    future.result(timeout=5)
                except (concurrent.futures.CancelledError,
                        concurrent.futures.TimeoutError):
                    pass
                # Le Future concurrent passe à « cancelled » dès que la
                # demande est transmise à la boucle, avant que le finally de
                # la coroutine ait nécessairement rendu ses ressources. Une
                # barrière distincte évite de libérer self.lock trop tôt.
                finished.wait(timeout=5)
                raise
        finally:
            self.lock.release()

    def forget(self):
        """Oublie la session courante : la prochaine demande se reconnectera."""
        with self.lock:
            self.blink = None
            if self.session is not None and self.loop is not None:
                asyncio.run_coroutine_threadsafe(self.session.close(), self.loop)
            self.session = None

    def find_camera(self, blink, identity: str):
        by_key = []
        by_name = []
        for sync in blink.sync.values():
            for camera_name, camera in (getattr(sync, "cameras", None) or {}).items():
                if camera_key(sync, camera_name, camera) == identity:
                    by_key.append((sync, camera))
                if camera_name.strip() == identity.strip():
                    by_name.append((sync, camera))
        if len(by_key) == 1:
            return by_key[0]
        if len(by_key) > 1:
            raise RuntimeError(msg("camera_id_duplique"))
        if len(by_name) == 1:
            return by_name[0]
        if len(by_name) > 1:
            raise RuntimeError(msg("camera_nom_ambigu", identite=identity))
        raise RuntimeError(msg("camera_inconnue_id", identite=identity))

    def find_system(self, blink, identity: str):
        by_name = []
        for sync_name, sync in blink.sync.items():
            if system_key(sync_name, sync) == identity:
                return sync
            if sync_name.strip() == identity.strip():
                by_name.append(sync)
        if len(by_name) == 1:
            return by_name[0]
        if len(by_name) > 1:
            raise RuntimeError(msg("systeme_nom_ambigu", identite=identity))
        raise RuntimeError(msg("systeme_inconnu_id", identite=identity))

    def find_sync_module(self, blink, entry: dict):
        """Résout le module d'un clip par ses IDs persistés.

        Le support XR construit le vrai Sync Module depuis le homescreen : il
        peut donc être absent de ``blink.sync``, qui contient encore, selon les
        comptes, une Mini ou une Doorbell portant l'ID du périphérique. Le nom
        de caméra n'est conservé qu'en repli pour les anciennes entrées du
        registre dépourvues d'identifiants.
        """
        network_id = str(entry.get("network_id") or "")
        sync_id = str(entry.get("sync_id") or "")
        modules = [sync for _name, sync in blink_models.select_sync_modules(blink, None)]
        if network_id or sync_id:
            matches = [
                sync for sync in modules
                if (not network_id
                    or str(getattr(sync, "network_id", "")) == network_id)
                and (not sync_id
                     or str(getattr(sync, "sync_id", "")) == sync_id)
            ]
            if len(matches) == 1:
                return matches[0]
            if not matches:
                raise RuntimeError(msg("sync_introuvable", reseau=network_id or "?",
                                       module=sync_id or "?"))
            raise RuntimeError(msg("sync_ambigu", reseau=network_id or "?",
                                   module=sync_id or "?"))

        camera = str(entry.get("camera") or "").strip()
        sync, _camera = self.find_camera(blink, camera)
        return sync


class LoginFlow:
    """Conduit une connexion Blink dont les réponses arrivent du navigateur.

    La difficulté : les identifiants et le code de vérification arrivent dans
    deux requêtes HTTP distinctes, mais doivent parler à la même session Blink,
    ouverte entre les deux. On garde donc une boucle asyncio vivante dans un
    fil, et la tâche de connexion s'y suspend en attendant le code au lieu de
    le lire au clavier.

    Le mot de passe ne quitte jamais la mémoire de ce processus : blink2video.py
    n'écrit que les jetons de session, jamais le mot de passe."""

    def __init__(self):
        self.loop = None
        self.future = None
        self.queue = None
        self._ferme = None
        self.asked = threading.Event()
        # Singleton sur Handler (ThreadingHTTPServer, un thread par requête) :
        # deux /api/login qui se chevauchent (page rafraîchie par impatience
        # pendant qu'un login précédent attend encore son code, jusqu'à 120 s)
        # mutaient loop/future/queue sans synchronisation, avec un close()
        # pouvant arrêter la boucle qu'un autre thread vient de créer.
        self._verrou = threading.Lock()

    def start(self, username: str, password: str) -> dict:
        with self._verrou:
            self._arreter_session_courante()
            self.loop = asyncio.new_event_loop()
            threading.Thread(target=self._serve, daemon=True).start()
            self.asked.clear()
            self._ferme = threading.Event()
            self.future = asyncio.run_coroutine_threadsafe(
                self._login(username, password, self._ferme), self.loop
            )
            future = self.future
        return self.wait(future)

    def _serve(self) -> None:
        asyncio.set_event_loop(self.loop)
        self.loop.run_forever()

    async def _login(self, username: str, password: str, ferme: threading.Event) -> dict:
        self.queue = asyncio.Queue()

        async def ask_code(attempt: int) -> str:
            self.asked.set()
            return await self.queue.get()

        try:
            try:
                async with blink_auth.session_http_temporaire() as session:
                    blink = await blink_auth.login(session, username, password, ask_code)
            except Exception as error:  # remonter la cause plutôt que planter le serveur
                return {"status": "error", "message": f"{type(error).__name__}: {error}"}
            if blink is None:
                return {"status": "error", "message": msg("identifiants_refuses")}
            return {"status": "ok"}
        finally:
            # Signale la fin RÉELLE du nettoyage (fermeture de la session HTTP
            # comprise), indépendamment de l'état du concurrent.futures.Future
            # renvoyé par run_coroutine_threadsafe : ce dernier passe à
            # "cancelled" de façon synchrone dès l'appel .cancel(), avant même
            # que la boucle ait rendu la main à la tâche annulée pour dérouler
            # ce bloc (constaté empiriquement : cancelled() devient True en
            # 0 ms, ce finally s'exécute une fraction de seconde plus tard).
            # _arreter_session_courante() attend cet événement plutôt que
            # future.result() pour savoir quand couper la boucle sans
            # trancher ce nettoyage à mi-chemin.
            ferme.set()

    def submit_code(self, code: str) -> dict:
        with self._verrou:
            if self.loop is None or self.queue is None:
                return {"status": "error", "message": msg("aucune_connexion")}
            self.asked.clear()
            loop, queue, future = self.loop, self.queue, self.future
        loop.call_soon_threadsafe(queue.put_nowait, code)
        return self.wait(future)

    def wait(self, future, timeout: float = 120.0) -> dict:
        """Rend la main dès qu'un code est réclamé ou que la connexion aboutit.

        ``future`` est celui capturé par l'appelant sous verrou, jamais relu
        depuis ``self`` : si une autre session de login a entre-temps remplacé
        celle-ci, on continue d'attendre la nôtre sans se faire piéger par un
        attribut partagé qui a changé sous nos pieds."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if future.done():
                result = future.result()
                self._fermer_si_courante(future)
                return result
            if self.asked.is_set():
                return {"status": "2fa"}
            time.sleep(0.1)
        self._fermer_si_courante(future)
        return {"status": "error", "message": msg("delai_depasse")}

    def close(self) -> None:
        with self._verrou:
            self._arreter_session_courante()

    def _fermer_si_courante(self, future) -> None:
        """N'arrête la boucle que si ``future`` est toujours la session active,
        pas celle qu'un login concurrent plus récent a déjà remplacée."""
        with self._verrou:
            if self.future is future:
                self._arreter_session_courante()

    def _arreter_session_courante(self) -> None:
        if self.future is not None and not self.future.done():
            ferme = self._ferme
            self.future.cancel()
            # future.done()/.cancelled() passe à True de façon SYNCHRONE dès
            # cet appel, bien avant que la tâche asyncio sous-jacente n'ait pu
            # dérouler son annulation (elle ne reprend qu'au prochain tour de
            # la boucle, dans l'autre thread) : attendre future.result() ici
            # ne garantit donc rien sur l'état réel de la coroutine (constaté
            # empiriquement : cancelled() vaut True en 0 ms, alors que le
            # ``finally`` de _login, qui ferme la session HTTP, ne s'exécute
            # qu'une fraction de seconde plus tard). ``ferme`` est le seul
            # signal fiable de cette fin réelle ; sans l'attendre, loop.stop()
            # coupait ce nettoyage à mi-chemin, laissant la session HTTP
            # ouverte et la tâche jamais vraiment terminée (constaté en réel :
            # recommencer une connexion pendant qu'une précédente attend son
            # code de vérification).
            if ferme is not None:
                ferme.wait(timeout=5)
            try:
                self.future.result(timeout=0)
            except (Exception, asyncio.CancelledError):
                pass
        if self.loop is not None:
            self.loop.call_soon_threadsafe(self.loop.stop)
        self.loop, self.future, self.queue, self._ferme = None, None, None, None


BLINK = BlinkSession()


def _choisir_dossier_windows(initial: str) -> str:
    """Sélecteur de dossier natif via PowerShell/WinForms, chaîne vide si
    annulé.

    tkinter dépend de Tcl/Tk, pas forcément embarqué correctement dans un
    bundle PyInstaller (constaté en conditions réelles sur l'édition
    Windows 7 : « Sélecteur de dossier indisponible »). System.Windows.Forms
    fait partie de .NET Framework, présent par défaut depuis Windows Vista
    (donc Windows 7 aussi), et ne demande rien à empaqueter. Un propriétaire
    invisible et TopMost sert le même rôle que le -topmost de tkinter : sans
    lui, la boîte peut s'ouvrir derrière le navigateur.

    Le propriétaire doit être réellement affiché (Show(), Opacity=0, hors
    écran) plutôt que juste construit avec WindowState='Minimized' sans
    jamais l'afficher : cette seconde forme, essayée d'abord, faisait
    entrer ShowDialog() dans un blocage silencieux (aucune erreur, aucune
    boîte visible, le processus PowerShell restait vivant indéfiniment -
    constaté en conditions réelles). -STA est déjà le mode par défaut de
    powershell.exe (vérifié : GetApartmentState() répond STA sans l'option),
    gardé explicite pour ne pas en dépendre silencieusement."""
    script = (
        "Add-Type -AssemblyName System.Windows.Forms; "
        "$proprietaire = New-Object System.Windows.Forms.Form; "
        "$proprietaire.ShowInTaskbar = $false; "
        "$proprietaire.Opacity = 0; "
        "$proprietaire.StartPosition = 'Manual'; "
        "$proprietaire.Location = New-Object System.Drawing.Point(-2000, -2000); "
        "$proprietaire.Size = New-Object System.Drawing.Size(1, 1); "
        "$proprietaire.Show(); "
        "$proprietaire.TopMost = $true; "
        "$dialogue = New-Object System.Windows.Forms.FolderBrowserDialog; "
        f"$dialogue.SelectedPath = '{initial.replace(chr(39), chr(39) * 2)}'; "
        "if ($dialogue.ShowDialog($proprietaire) -eq "
        "[System.Windows.Forms.DialogResult]::OK) "
        "{ [Console]::Out.Write($dialogue.SelectedPath) }"
    )
    resultat = runtime.lancer(
        ["powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script],
        stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        text=True, errors="replace", check=False,
    )
    if resultat.returncode != 0:
        raise RuntimeError((resultat.stderr or "").strip() or msg("powershell_echec"))
    return resultat.stdout.strip()


class _ReglagesInvalides(ValueError):
    """Refus de formulaire destiné à une réponse HTTP 400."""


# Ce qu'un nom d'hôte (y compris NetBIOS, d'où « _ »), une IPv4/IPv6 ou un
# sous-réseau CIDR peut contenir, et rien d'autre : chaque entrée finit aussi
# dans l'en-tête CSP (frame-ancestors, voir end_headers), où un « ; » ouvrirait
# une directive de plus.
_ENTREE_CONFIANCE_RE = re.compile(r"^[A-Za-z0-9._\-:\[\]/]+$")


def normaliser_hotes_confiance(valeur: str) -> str:
    """Valide trusted_host et le rend sous forme canonique (« a,b »), ou lève
    ValueError avec un message affichable. Appliqué à l'enregistrement depuis
    la page de réglages, jamais au démarrage : un réglage accepté par une
    version antérieure et refusé par celle-ci empêcherait sinon le serveur de
    démarrer après mise à jour. À l'usage, hote_autorise() et end_headers()
    ignorent de toute façon les entrées invalides (refus par défaut)."""
    entrees = serveweb.entrees_confiance(valeur)
    for entree in entrees:
        # Un « - » initial n'appartient à aucun nom d'hôte ni adresse, et
        # argparse lirait « --trusted-host -x » comme une option sans valeur :
        # serve refuserait alors de démarrer au lancement suivant.
        if not _ENTREE_CONFIANCE_RE.match(entree) or entree.startswith("-"):
            raise ValueError(msg("hote_confiance_invalide", entree=entree))
        if "/" in entree:
            try:
                ipaddress.ip_network(entree, strict=False)
            except ValueError as erreur:
                raise ValueError(msg("sous_reseau_invalide", entree=entree)) from erreur
    return ",".join(entrees)


def _verifier_dossier_reglages(dossier: str) -> None:
    """Éprouve l'écriture sans changer le dossier de stockage actif.

    Le dossier déjà choisi mais absent n'est pas éprouvé : la page renvoie ce
    chemin à chaque enregistrement, et l'éprouver le recréerait (issue #134)."""
    if not dossier:
        return
    try:
        candidat = Path(dossier).expanduser()
        if candidat.resolve() == runtime.dossier_sorties_manquant():
            return
        candidat.mkdir(parents=True, exist_ok=True)
        # Création exclusive : ni fichier existant écrasé, ni collision entre
        # deux requêtes. Le contexte ferme et supprime uniquement sa sonde,
        # y compris si l'écriture ou le vidage du tampon échoue.
        with tempfile.NamedTemporaryFile(
                mode="w", encoding="utf-8", prefix=".blink_ecriture_test-",
                dir=candidat) as sonde:
            sonde.write("blink2video")
            sonde.flush()
    except OSError as erreur:
        raise _ReglagesInvalides(msg("dossier_stockage_inaccessible", erreur=erreur)) from erreur


def _preparer_reglages_web(payload: dict) -> tuple[str, dict]:
    """Retourne le dossier demandé et les réglages normalisés, sans les enregistrer.

    La sonde du dossier est un effet réel : conserver l'ordre des contrôles,
    qui détermine aussi la première erreur présentée à l'utilisateur.
    """
    try:
        usb_minutes = int(payload.get("usb_minutes"))
        cloud_minutes = int(payload.get("cloud_minutes"))
        port = int(payload.get("port"))
        if usb_minutes < 1 or cloud_minutes < 1:
            raise ValueError
        if not 1 <= port <= 65535:
            raise ValueError
    except (TypeError, ValueError) as erreur:
        raise _ReglagesInvalides(msg("reglage_cadences_invalides")) from erreur

    dossier = str(payload.get("storage_dir", "")).strip()
    _verifier_dossier_reglages(dossier)

    timezone = str(payload.get("timezone", "")).strip()
    try:
        ZoneInfo(timezone)
    except (ZoneInfoNotFoundError, ValueError) as erreur:
        raise _ReglagesInvalides(msg("fuseau_inconnu", fuseau=timezone)) from erreur

    reglages = {
        "usb_minutes": usb_minutes, "cloud_minutes": cloud_minutes, "port": port,
        "timestamp": bool(payload.get("timestamp", False)), "timezone": timezone,
        "merge_jour": bool(payload.get("merge_jour", True)),
        "merge_semaine": bool(payload.get("merge_semaine", True)),
        "merge_mois": bool(payload.get("merge_mois", True)),
        "download_auto": bool(payload.get("download_auto", True)),
    }
    protocole = str(payload.get("live_protocol", "webrtc"))
    if protocole not in runtime.PROTOCOLES_LIVE_VALIDES:
        raise _ReglagesInvalides(msg("protocole_inconnu", protocole=protocole))
    reglages["live_protocol"] = protocole

    font_size = payload.get("font_size")
    if isinstance(font_size, str):
        font_size = font_size.strip() or None
    if font_size is not None:
        try:
            font_size = int(font_size)
            if not 8 <= font_size <= 500:
                raise ValueError
        except (TypeError, ValueError) as erreur:
            raise _ReglagesInvalides(msg("reglage_police_invalide")) from erreur
    reglages["font_size"] = font_size

    font_color = str(payload.get("font_color") or "white").strip() or "white"
    if not md.COULEUR_FFMPEG_RE.match(font_color):
        raise _ReglagesInvalides(msg("couleur_invalide", couleur=font_color))
    reglages["font_color"] = font_color

    try:
        box_opacity = float(payload.get("box_opacity", 0.55))
        if not 0.0 <= box_opacity <= 1.0:
            raise ValueError
    except (TypeError, ValueError) as erreur:
        raise _ReglagesInvalides(msg("reglage_opacite_invalide")) from erreur
    reglages["box_opacity"] = box_opacity

    # Absent (page restée ouverte depuis une version sans ce réglage) : garder
    # le format enregistré plutôt que de revenir en silence au 24 h.
    clock = payload.get("clock", runtime.lire_reglages()["clock"])
    if clock not in runtime.HORLOGES:
        raise _ReglagesInvalides(msg("horloge_inconnue", horloge=clock))
    reglages["clock"] = clock

    try:
        reglages["trusted_host"] = normaliser_hotes_confiance(
            str(payload.get("trusted_host") or ""))
    except ValueError as erreur:
        raise _ReglagesInvalides(str(erreur)) from erreur

    webhook_notif_url = str(payload.get("webhook_notif_url") or "").strip()
    if webhook_notif_url:
        try:
            schema_valide = urlparse(webhook_notif_url).scheme in ("http", "https")
        except ValueError:
            # urlparse lève sur certaines formes manifestement invalides
            # (IPv6 mal fermé, ex. « http://[abc ») plutôt que de rendre un
            # schéma vide comme pour le reste des URL mal formées - un cas
            # trouvé en se demandant si cette même levée touchait aussi
            # hote_autorise() (Origin), qui appelle urlparse de la même
            # façon sans plus de protection qu'ici avant ce correctif.
            schema_valide = False
        if not schema_valide:
            raise _ReglagesInvalides(msg("reglage_url_notif_invalide",
                                         url=webhook_notif_url))
    reglages["webhook_notif_url"] = webhook_notif_url

    # En-tête (issue #40). Absent (page restée ouverte depuis une version sans
    # ce réglage) : garder celui enregistré, comme l'horloge.
    enregistres = runtime.lire_reglages()
    for champ, maximum in runtime.ENTETE_MAX.items():
        texte = runtime.texte_entete(payload.get(champ, enregistres[champ]))
        if len(texte) > maximum:
            raise _ReglagesInvalides(msg("reglage_entete_trop_long",
                                         titre=runtime.ENTETE_MAX["header_title"],
                                         icone=runtime.ENTETE_MAX["header_icon"]))
        reglages[champ] = texte

    # Plafonné à LIVE_MAX_SECONDS : le serveur coupe de toute façon chaque
    # direct à ce délai, une valeur au-delà serait inopérante (bornée à 24 h
    # en 0.13.0, corrigé en constatant ce plafond pour l'issue #15).
    try:
        live_auto_stop_seconds = int(payload.get("live_auto_stop_seconds", 0) or 0)
        if not 0 <= live_auto_stop_seconds <= LIVE_MAX_SECONDS:
            raise ValueError
    except (TypeError, ValueError) as erreur:
        raise _ReglagesInvalides(msg("reglage_arret_direct_invalide",
                                     maximum=LIVE_MAX_SECONDS)) from erreur
    reglages["live_auto_stop_seconds"] = live_auto_stop_seconds

    current = runtime.lire_reglages()
    audio_enabled = payload.get("camera_audio_enabled", current["camera_audio_enabled"])
    if not isinstance(audio_enabled, bool):
        raise _ReglagesInvalides(msg("audio_enabled_invalide"))
    audio_track = payload.get("camera_audio_track", current["camera_audio_track"])
    if isinstance(audio_track, bool) or not isinstance(audio_track, int) or audio_track not in (0, 1):
        raise _ReglagesInvalides(msg("audio_track_invalide"))
    reglages.update(camera_audio_enabled=audio_enabled, camera_audio_track=audio_track)

    # Conservation par catégorie (issue #120), en jours, 0 = garder toujours.
    for champ in runtime.CONSERVATION:
        try:
            jours = int(payload.get(champ, 0) or 0)
            if not 0 <= jours <= runtime.CONSERVATION_MAX_JOURS:
                raise ValueError
        except (TypeError, ValueError) as erreur:
            raise _ReglagesInvalides(msg(
                "conservation_invalide",
                maximum=runtime.CONSERVATION_MAX_JOURS)) from erreur
        reglages[champ] = jours
    ordre = runtime.ordre_conservation_invalide(reglages)
    if ordre:
        niveau, source = ordre
        raise _ReglagesInvalides(msg("conservation_ordre", niveau=msg("libelle_" + niveau),
                                     source=msg("libelle_" + source)))

    return dossier, reglages


class Handler(serveweb.Handler):
    # HTTP/1.1 pour garder la connexion ouverte : un navigateur qui se déplace
    # dans une vidéo enchaîne les requêtes Range, une par saut. En HTTP/1.0 il
    # rouvrirait une connexion à chaque fois.
    protocol_version = "HTTP/1.1"
    paths: dict = {}
    timezone: ZoneInfo = ZoneInfo("Europe/Paris")
    hub: str | None = None
    ffmpeg: str = ""
    trusted_host: str = ""
    # Serveur temporaire du tout premier démarrage : les réglages sont
    # enregistrés sans lancer lui-même un restart. Le parent ``start`` attend
    # leur marqueur, arrête ce serveur, puis seulement alors crée les workers.
    initial_setup: bool = False
    lock = threading.Lock()
    login_flow = LoginFlow()

    def log_message(self, fmt, *args):
        pass  # le journal d'accès n'apprend rien ici

    # ------------------------------------------------------------ protection

    # La garde d'hôte (Host, Origin, trusted_host en liste ou CIDR, proxy local)
    # est celle de nico579_commons.serveweb, la même pour les quatre
    # applications ; ne reste ici que ce qui est propre à blink2video.
    variable_proxy_local = "BLINK_TRUSTED_LOOPBACK_PROXY"

    def _journaliser_acces_refuse(self, raison: str) -> None:
        """Trace un 403 de hote_autorise() dans serve_erreurs.log (même
        fichier que handle_error, même tolérance à l'échec d'écriture).

        Sans ceci, un trusted_host mal renseigné (casse, port oublié dans le
        champ, MagicDNS Tailscale au lieu de l'IP...) ne laisse aucune trace
        exploitable : juste un 403 générique côté navigateur. Issue #10,
        BLINK_BIND + trusted_host donnant toujours 403 sans qu'on ait pu
        savoir laquelle des trois conditions (Host, boucle locale/tunnel,
        Origin) avait échoué."""
        try:
            with (runtime.app_dir() / "serve_erreurs.log").open(
                    "a", encoding="utf-8") as journal:
                journal.write(msg(
                    "journal_acces_refuse",
                    date=dt.datetime.now().isoformat(), raison=raison,
                    host=repr(self.headers.get("Host")),
                    origin=repr(self.headers.get("Origin")),
                    ip=getattr(self, "client_address", ("?", 0))[0],
                    trusted=repr(self.trusted_host)))
        except OSError:
            pass

    def jeton_valide(self) -> bool:
        """Le jeton de process (TOKEN) doit accompagner toute requête qui
        change quelque chose.

        Distinct de `hote_autorise()` : Host se falsifie difficilement mais
        se contourne par re-liaison DNS (un domaine public pointé sur
        127.0.0.1) ; un en-tête personnalisé, lui, ne peut être posé que par
        du code qui a lu la page servie ici, ce qu'une origine étrangère ne
        peut pas faire (politique de même origine du navigateur)."""
        if self.headers.get("X-Blink-Token") == TOKEN:
            return True
        # EventSource, <video> et <img> ne permettent pas d'ajouter un en-tête
        # personnalisé. Leur URL porte donc le même secret ; no-referrer et la
        # politique same-origin ci-dessous empêchent sa fuite vers un tiers.
        valeurs = parse_qs(urlparse(self.path).query).get("token") or []
        return any(value == TOKEN for value in valeurs)

    def end_headers(self) -> None:
        # 'none' par défaut : même une page de ce site ne doit pas pouvoir
        # s'afficher dans un <iframe>, dernier rempart contre le
        # détournement de clic (cliquer sur un bouton qu'on croit ailleurs).
        # Assoupli aux entrées de trusted_host (issue #12 : embarquer la page
        # dans un tableau de bord domotique type ioBroker) - seulement les
        # hôtes/IP exacts, jamais un sous-réseau CIDR : frame-ancestors n'a
        # pas de syntaxe pour un sous-réseau, et l'accepter en silence
        # laisserait croire à une protection qui n'opère pas. Refiltré ici
        # même si les réglages sont validés à l'écriture : un fichier de
        # réglages modifié à la main n'y passe pas, et un « ; » ajouterait
        # une directive à cet en-tête.
        # « :* » obligatoire : sans port, une source CSP ne vaut que pour le
        # port par défaut (80), et un tableau de bord servi sur 8081/8082
        # (ioBroker) était refusé, vérifié dans Chrome. Tout port de l'hôte,
        # comme hote_autorise() qui ignore déjà le port. Pas d'entrée IPv6 :
        # la syntaxe CSP n'a pas de forme pour une adresse IPv6 littérale.
        exacts = [entree for entree in serveweb.entrees_confiance(self.trusted_host)
                  if "/" not in entree and ":" not in entree
                  and _ENTREE_CONFIANCE_RE.match(entree)]
        frame_ancestors = ("'self' " + " ".join(f"{entree}:*" for entree in exacts)
                           if exacts else "'none'")
        self.send_header(
            "Content-Security-Policy",
            "default-src 'self'; "
            f"script-src 'nonce-{SCRIPT_NONCE}'; "
            "style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
            "media-src 'self' blob:; connect-src 'self'; object-src 'none'; "
            f"base-uri 'none'; form-action 'self'; frame-ancestors {frame_ancestors}",
        )
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Referrer-Policy", "no-referrer")
        self.send_header("Cross-Origin-Resource-Policy", "same-origin")
        super().end_headers()

    # ------------------------------------------------------------------ envoi

    def repondre_puis_redemarrer(self, commande_restart: list) -> None:
        """Détache une commande capable d'arrêter CE processus, puis confirme
        au navigateur qu'elle a bien été créée. Factorise /api/reglages et
        /api/stop, qui partagent exactement ce besoin.

        wfile.write()/flush() ne garantissent que la remise à l'OS, pas la
        livraison réelle jusqu'au navigateur : un taskkill trop rapproché
        peut aborter la connexion avant que la pile réseau ait fini de
        transmettre, la fenêtre étant plus large sur une machine chargée ou
        lente (rapporté par un utilisateur sous Windows 7/Python 3.8 :
        JSON.parse en échec côté page, disparaissant une fois la
        surveillance caméra arrêtée - moins de contention). Une courte
        pause après le flush laisse le temps à la pile réseau de vraiment
        vider son tampon avant la mise à mort."""
        # Vérifier que le relais a réellement pu être créé AVANT d'annoncer
        # l'acceptation. Le finaliseur attend ensuite brièvement avant l'arrêt,
        # ce qui laisse à cette réponse le temps de parvenir au navigateur sans
        # devoir espérer qu'un sleep exécuté après le flush soit suffisant.
        commande = [*commande_restart, "--delai", "0.75"]
        try:
            runtime.demarrer(
                runtime.self_command(*commande), cwd=str(runtime.app_dir()),
                stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                stderr=subprocess.STDOUT, start_new_session=(os.name != "nt"))
        except OSError as erreur:
            self.send_json({"error": msg("arret_impossible", erreur=erreur)}, 500)
            return
        self.send_json({"ok": True, "accepted": True})

    def send_media(self, path: Path) -> None:
        """Sert un fichier en gérant les requêtes Range.

        Sans Range, un navigateur ne peut pas se déplacer dans la vidéo : il
        n'a d'autre choix que de tout télécharger depuis le début. C'est la
        seule raison pour laquelle ce serveur n'est pas trois lignes."""
        size = path.stat().st_size
        start, end = 0, size - 1
        partial = False
        header = self.headers.get("Range", "")
        match = re.match(r"bytes=(\d*)-(\d*)", header)
        if match:
            first, last = match.group(1), match.group(2)
            if first:
                # Ne PAS clamper start ici : un start hors fichier (bytes=20-
                # sur 10 octets) doit être rejeté (416), pas silencieusement
                # ramené au dernier octet valide - la RFC 7233 ne l'autorise
                # que pour la borne de FIN, jamais pour la borne de début.
                start = int(first)
                end = min(int(last), size - 1) if last else size - 1
            elif last:  # suffixe : les N derniers octets
                n = int(last)
                start = max(size - n, 0) if n > 0 else size
                end = size - 1
            else:  # "bytes=-", ni début ni fin : rien à servir
                start = size
                end = size - 1
            if start >= size or start > end:
                self.send_response(416)
                self.send_header("Content-Range", f"bytes */{size}")
                self.end_headers()
                return
            partial = True

        length = end - start + 1
        self.send_response(206 if partial else 200)
        self.send_header(
            "Content-Type", mimetypes.guess_type(path.name)[0] or "video/mp4"
        )
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("Content-Length", str(length))
        if partial:
            self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
        self.end_headers()

        with path.open("rb") as source:
            source.seek(start)
            remaining = length
            while remaining > 0:
                chunk = source.read(min(256 * 1024, remaining))
                if not chunk:
                    break
                try:
                    self.wfile.write(chunk)
                except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
                    return  # l'utilisateur a changé de clip, c'est normal
                remaining -= len(chunk)

    def _client_parti(self) -> bool:
        """Vrai si le navigateur a déjà fermé sa connexion (requête annulée).

        Une page qui défile vite libère ses lecteurs, et le navigateur annule
        les vignettes qu'il avait demandées (359 requêtes sans statut dans le
        test à froid de Joël, PR #59) ; le serveur ne le sait pas, fabriquait
        quand même chacune par ffmpeg, et ces requêtes gardaient leur place
        dans la file des extractions. Un octet lisible qui est la requête
        suivante d'une connexion persistante n'est pas un départ : seul un
        flux fermé (lecture vide) l'est."""
        try:
            lisibles, _, _ = select.select([self.connection], [], [], 0)
            if not lisibles:
                return False
            return self.connection.recv(1, socket.MSG_PEEK) == b""
        except (OSError, ValueError, TypeError, AttributeError):
            return True

    def send_thumb(self, route: str, source: Path) -> None:
        """Sert la miniature d'un clip, en la fabriquant à la première demande.

        Elle est prise un peu après le début, jamais sur la première image :
        une caméra qui vient de se déclencher livre souvent une ou deux images
        noires ou surexposées, le temps que l'exposition s'ajuste. Et on
        l'extrait de la version normalisée quand elle existe, pour que
        l'horodatage incrusté apparaisse dans la vignette."""
        thumb = (self.paths["thumbs"] / route).with_suffix(".jpg")

        def fraiche() -> bool:
            return (
                thumb.is_file()
                and thumb.stat().st_size > 0
                and thumb.stat().st_mtime >= source.stat().st_mtime
            )

        if not fraiche():
            # Verrou par miniature : voir _verrou_miniature(). Re-vérifie
            # fraiche() une fois le verrou obtenu - le thread qui l'a
            # attendu a pu la trouver déjà construite par celui qui vient
            # de le relâcher, sans quoi une deuxième extraction inutile
            # écraserait le fichier temporaire du premier en plein envoi.
            with _verrou_miniature(str(thumb)):
                if not fraiche():
                    runtime.creer_sous_sorties(thumb.parent)
                    pending = thumb.with_suffix(".tmp.jpg")
                    # Deux extractions à la fois au plus : le navigateur réclame
                    # toutes les vignettes de la page d'un coup, et autant de
                    # ffmpeg simultanés saturerait la machine pour rien.
                    with THUMB_SLOTS:
                        # Après l'attente du créneau, avant le travail : le
                        # client a pu partir pendant la file (voir _client_parti).
                        if self._client_parti():
                            self.close_connection = True
                            return
                        md.extraire_vignette(self.ffmpeg, source, pending)
                    if not pending.is_file() or pending.stat().st_size == 0:
                        pending.unlink(missing_ok=True)
                        self.send_error(404)
                        return
                    pending.replace(thumb)

        # Chaque changement de filtre reconstruit toute la grille (voir
        # renderClips côté page), donc chaque <video poster=...> déjà connue
        # redemande la même image : sans validation, le navigateur la
        # retéléchargeait entièrement à chaque fois, un flash noir le temps
        # que ça revienne pour toutes les vignettes à la fois (constaté en
        # réel, 2026-08-27). La vignette elle-même ne change qu'au prochain
        # re-téléchargement du clip source (voir `fresh` ci-dessus) : une
        # revalidation conditionnelle est donc sûre, pas juste plus rapide.
        derniere_modif = email.utils.formatdate(thumb.stat().st_mtime, usegmt=True)
        depuis = self.headers.get("If-Modified-Since")
        if depuis:
            try:
                pas_change = email.utils.parsedate_to_datetime(depuis) \
                    >= email.utils.parsedate_to_datetime(derniere_modif)
            except (TypeError, ValueError):
                pas_change = False
            if pas_change:
                self.send_response(304)
                self.send_header("Cache-Control", "no-cache")
                self.end_headers()
                return

        body = thumb.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", "image/jpeg")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Last-Modified", derniere_modif)
        self.end_headers()
        self.wfile.write(body)

    def describe_camera(self, camera_name: str, camera, raw: dict) -> dict:
        """Décrit une caméra, en datant ses mesures.

        Une caméra injoignable continue de renvoyer sa dernière température
        connue. La présenter sans date reviendrait à annoncer comme actuelle
        une valeur qui peut avoir des semaines."""
        candidates = raw.get(camera_name.strip(), [])
        if isinstance(candidates, dict):
            candidates = [candidates]
        attributes = getattr(camera, "attributes", None) or {}
        network_id = str(getattr(camera, "network_id", "") or "")
        device_id = str(
            getattr(camera, "device_id", None)
            or getattr(camera, "camera_id", None)
            or attributes.get("device_id")
            or attributes.get("camera_id")
            or attributes.get("id")
            or ""
        )
        info = next(
            (item for item in candidates
             if device_id and str(item.get("id") or item.get("device_id")
                                  or item.get("camera_id") or "") == device_id),
            None,
        )
        if info is None:
            info = next(
                (item for item in candidates
                 if network_id and str(item.get("network_id")
                                       or item.get("network") or "") == network_id),
                candidates[0] if candidates else {},
            )
        status = str(info.get("status") or "").strip()
        measured = info.get("updated_at")
        age = None
        measured_today = False
        if measured:
            try:
                moment = md.parse_created_at(str(measured))
                age = (dt.datetime.now(dt.timezone.utc) - moment).total_seconds()
                # Un relevé du jour se lit mieux à l'heure seule ; au-delà la
                # date devient nécessaire pour juger de sa fraîcheur. La page
                # compose le texte, dans sa langue (issue #37).
                measured, measured_today = horodatage_releve(moment, self.timezone)
            except (ValueError, TypeError):
                measured, age = None, None

        # Les « signals » de l'écran d'accueil sont les jauges que montre
        # l'application Blink. On les transmet telles quelles, sans inventer
        # d'échelle : rien dans l'API ne documente leur maximum, et chez cette
        # installation une caméra qui fonctionne très bien affiche lfr 1.
        signals = info.get("signals") or {}

        # Ne pas utiliser camera.arm : pour un mini d'intérieur, blinkpy
        # renvoie l'armement du module de synchronisation à la place de la
        # détection propre à la caméra (BlinkCameraMini.arm rend sync.arm).
        # Système armé plus détection coupée s'affichait donc « active ».
        # motion_enabled est juste pour tous les modèles, et le champ
        # `enabled` de l'écran d'accueil sert de recoupement.
        enabled = camera.motion_enabled
        if enabled is None:
            enabled = info.get("enabled")

        # Temperature brute de l'ecran d'accueil (Fahrenheit, comme Blink la
        # rapporte) : meme conversion que camera.temperature_c (blinkpy,
        # camera.py) pour un resultat identique, sans dependre de cette
        # propriete elle-meme.
        temp_brute = signals.get("temp")
        try:
            temperature = round((temp_brute - 32) / 9.0 * 5.0, 1)
        except TypeError:
            temperature = None

        return {
            "name": camera_name.strip(),
            "armed": bool(enabled),
            # battery/temperature/wifi/firmware/kind : lus depuis l'ecran
            # d'accueil (info/signals), pas depuis l'objet camera de
            # blinkpy (attributes) - celui-ci n'est mis a jour que par
            # sync_module.refresh(), plus appele depuis system_state()
            # depuis l'allegement du 2026-09-03 (cf. BACKLOG.md) : y lire
            # ces champs les figeait silencieusement a la valeur du
            # demarrage du serveur, jamais rafraichis ensuite. Verifie sur
            # un ecran d'accueil reel avant ce correctif (pas suppose) :
            # tous presents (battery, fw_version, type, signals.wifi/temp).
            # voltage seul n'a pas d'equivalent dans l'ecran d'accueil,
            # laisse tel quel : jamais affiche cote page (verifie),
            # perimer apres le demarrage n'a aucun effet visible.
            "battery": info.get("battery"),
            "battery_signal": signals.get("battery"),
            "voltage": attributes.get("battery_voltage"),
            "temperature": temperature,
            "wifi": signals.get("wifi"),
            "lfr": signals.get("lfr"),
            "firmware": info.get("fw_version"),
            "kind": info.get("type"),
            "model": model_name(info.get("type")),
            "serial": info.get("serial") or attributes.get("serial"),
            "status": status,
            "offline": status == "offline",
            "measured_at": measured,
            "measured_today": measured_today,
            "age_seconds": age,
        }

    def system_state(self) -> dict:
        """État d'armement du système et des caméras.

        L'armement Blink a deux niveaux, et les confondre mène à des surprises :
        le module de synchronisation commande l'ensemble, chaque caméra a en
        plus son propre interrupteur. Une caméra armée dans un système désarmé
        ne détecte rien, d'où l'affichage des deux."""
        # Lu une fois pour toutes les caméras : c'est un fichier, pas une
        # question posée à Blink.
        venues = provenances(read_entries(self.paths))

        def read(blink):
            async def run(_blink=blink):
                # _blink.refresh(force=True) enchainait aussi, par caméra,
                # get_camera_info() + update() (sync_module.py refresh()),
                # plus update_local_storage_manifest()/check_new_videos() -
                # rien de tout cela n'est lu plus bas : cet affichage ne
                # vient que de get_homescreen() (nom/batterie/temperature/
                # statut) et de network_info par module (armement). Verifie
                # dans le code source de blinkpy avant de couper (pas
                # suppose) : sync.cameras est peuple une seule fois, a la
                # connexion initiale (update_cameras(), appele par start(),
                # jamais par refresh()) - ses identifiants (device_id/
                # network_id, utilises plus bas pour rapprocher chaque
                # caméra de son entree dans l'ecran d'accueil) sont donc
                # deja stables ici, pas besoin de les rafraichir a chaque
                # appel. Mesure en reel (2026-09-03) : la sequence complete
                # (1 + 1 par module + 1 par camera, en serie) faisait durer
                # "Interrogation du systeme Blink..." bien plus que
                # necessaire pour cette page precise.
                await _blink.get_homescreen()
                for sync in _blink.sync.values():
                    await sync.get_network_info()
                # Les attributs de blinkpy ne disent pas *quand* une mesure a
                # été prise. L'écran d'accueil, lui, porte un `status` et un
                # `updated_at` par appareil : sans eux, la température d'une
                # caméra hors de portée s'affiche comme si elle était actuelle
                # alors qu'elle peut dater de plusieurs semaines.
                raw = {}
                home = getattr(_blink, "homescreen", None) or {}
                for group in ("cameras", "owls", "doorbells"):
                    for item in home.get(group) or []:
                        raw.setdefault(str(item.get("name") or "").strip(), []).append(item)

                modules = {str(m.get("network_id")): m
                           for m in (home.get("sync_modules") or [])}
                systems = []
                for name, sync in _blink.sync.items():
                    module = modules.get(str(sync.network_id)) or {}
                    systems.append({
                        "name": name.strip(),
                        "key": system_key(name, sync),
                        "armed": bool(sync.arm),
                        "module": MODULE_MODELS.get(module.get("type"))
                                  or (msg("module_type", type=module.get("type"))
                                      if module.get("type") else None),
                        "module_firmware": module.get("fw_version"),
                        "module_serial": module.get("serial"),
                        "cameras": [
                            dict(self.describe_camera(camera_name, camera, raw),
                                 key=camera_key(sync, camera_name, camera),
                                 clips_source=venues.get(camera_name.strip()))
                            for camera_name, camera in sync.cameras.items()
                        ],
                    })
                return {"systems": systems, "passages": runtime.passages()}
            return run()

        state = BLINK.call(read, timeout=60)
        state["webrtc"] = WEBRTC_ACTIF
        state["camera_audio_enabled"] = bool(runtime.lire_reglages().get("camera_audio_enabled", False))
        # Relu a chaque appel, contrairement a WEBRTC_ACTIF (fige a l'import
        # du process) : un changement depuis les Reglages redemarre deja le
        # serveur, mais watchLive() lit ce champ pendant un direct deja en
        # cours, bien apres ce redemarrage-la.
        state["live_auto_stop_seconds"] = runtime.lire_reglages()["live_auto_stop_seconds"]
        return state

    def set_armed(self, scope: str, identity: str, armed: bool) -> None:
        def apply(blink):
            async def run(_blink=blink):
                if scope == "system":
                    sync = BLINK.find_system(_blink, identity)
                    await sync.async_arm(armed)
                    return
                _, camera = BLINK.find_camera(_blink, identity)
                reponse = await camera.async_arm(armed)
                await _confirmer_armement_camera(_blink, camera, reponse, armed)
            return run()

        BLINK.call(apply, timeout=60)

    def reveiller_camera(self, identity: str) -> None:
        """Reveille une camera pour de bon, pas une simple relecture du cache.

        `system_state()`/`/api/system` relit deja le compte a chaque fois,
        mais c'est une lecture passive : les deux GET que blinkpy y fait
        (config, capteurs) renvoient ce que Blink a deja en archive cote
        serveur, jamais une valeur plus fraiche qu'un appareil endormi n'a
        pas encore renvoyee. `snap_picture()` poste une vraie commande, que
        Blink relaie a la camera physique et attend qu'elle confirme avant
        de repondre (jusqu'a 120 s dans le pire cas) - le seul mecanisme qui
        force reellement un reveil, au prix reel d'une photo prise et d'un
        peu de batterie a chaque clic. Voir AUDIT 28.51 (suite) pour la
        recherche qui a mene ici."""
        def demander(blink):
            async def run(_blink=blink):
                from blinkpy import api

                _, camera = BLINK.find_camera(_blink, identity)
                reponse = await camera.snap_picture()
                if not isinstance(reponse, dict) or not reponse.get("id"):
                    raise RuntimeError(msg("reveil_refuse"))
                # blinkpy attend deja la commande, mais ignore le resultat
                # de wait_for_command() et renvoie la reponse initiale meme
                # lors d'un refus ou d'un delai depasse. Un seul GET confirme
                # son issue sans declencher une seconde photo.
                statut = await api.request_command_status(
                    _blink, reponse.get("network_id") or camera.network_id,
                    reponse["id"],
                )
                if (not isinstance(statut, dict)
                        or statut.get("status_code") != 908
                        or statut.get("complete") is not True):
                    raise RuntimeError(msg("reveil_non_confirme"))
            return run()

        if not MODULE_SLOT.acquire(blocking=False):
            raise blink_engine.BusyError(_slot_occupe_message())
        try:
            _slot_pris("reveil", identity)
            # Le verrou memoire protege les directs de ce serveur ; celui
            # sur disque protege aussi les telechargements dans un autre
            # processus. Les deux restent tenus jusqu'a la confirmation.
            with blink_engine.hub_lock("reveil", attente=ATTENTE_HUB_MAX_SECONDS):
                BLINK.call(demander, timeout=130)
        finally:
            try:
                _slot_rendu()
            finally:
                MODULE_SLOT.release()

    def declencher_snapshot(self, identity: str) -> Path:
        """Prend une photo à la demande et la garde, contrairement à
        reveiller_camera() qui ne fait que confirmer le réveil (issue GitHub
        #9 : bouton manuel et webhook partagent cette même méthode).

        La confirmation de commande ne met pas à jour la vignette dans blinkpy.
        Il faut relire l'état de la caméra et vérifier que l'image a changé ;
        sinon get_media() peut renvoyer l'ancienne photo."""
        resultat = {}

        def demander(blink):
            async def run(_blink=blink):
                from blinkpy import api

                sync, camera = BLINK.find_camera(_blink, identity)
                ancienne_url = camera.thumbnail
                ancien_corps = getattr(camera, "_cached_image", None)
                reponse = await camera.snap_picture()
                if not isinstance(reponse, dict) or not reponse.get("id"):
                    raise RuntimeError(msg("snapshot_commande_refusee"))
                statut = await api.request_command_status(
                    _blink, reponse.get("network_id") or camera.network_id,
                    reponse["id"],
                )
                if (not isinstance(statut, dict)
                        or statut.get("status_code") != 908
                        or statut.get("complete") is not True):
                    raise RuntimeError(msg("snapshot_non_confirme"))
                dernier_essai = (ATTENTE_MEDIA_SNAPSHOT_SECONDS
                                 // INTERVALLE_MEDIA_SNAPSHOT_SECONDS)
                async def attendre_media():
                    for tentative in range(dernier_essai + 1):
                        await self._actualiser_media_camera(_blink, sync, camera)
                        media = await camera.get_media()
                        if media is not None and media.status == 200:
                            corps = await media.read()
                        else:
                            corps = b""
                        image_changee = (camera.thumbnail != ancienne_url or
                                         (ancien_corps is not None and corps != ancien_corps))
                        if corps and image_changee:
                            return corps
                        if tentative < dernier_essai:
                            await asyncio.sleep(INTERVALLE_MEDIA_SNAPSHOT_SECONDS)
                    raise RuntimeError(msg("snapshot_image_indisponible"))

                try:
                    corps = await asyncio.wait_for(
                        attendre_media(), timeout=ATTENTE_MEDIA_SNAPSHOT_SECONDS)
                except asyncio.TimeoutError:
                    raise RuntimeError(msg("snapshot_image_indisponible"))
                resultat["camera"] = camera_key(sync, camera.name, camera)
                resultat["nom"] = camera.name
                resultat["corps"] = corps
            return run()

        if not MODULE_SLOT.acquire(blocking=False):
            raise blink_engine.BusyError(_slot_occupe_message())
        try:
            _slot_pris("snapshot", identity)
            with blink_engine.hub_lock("snapshot", attente=ATTENTE_HUB_MAX_SECONDS):
                BLINK.call(demander, timeout=130)
        finally:
            try:
                _slot_rendu()
            finally:
                MODULE_SLOT.release()

        dossier = self.paths["snapshots"] / safe_file(resultat["nom"])
        runtime.creer_sous_sorties(dossier)
        horodatage = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d_%H-%M-%SZ")
        # Empreinte du contenu, pas un compteur : deux déclenchements dans la
        # même seconde (double clic, webhook rapproché) écrasaient sinon
        # silencieusement l'un des deux clichés (constaté en test).
        empreinte = hashlib.sha256(resultat["corps"]).hexdigest()[:8]
        cible = dossier / f"{horodatage}_{empreinte}.jpg"
        cible.write_bytes(resultat["corps"])
        runtime.notifier_nouveau_media(resultat["nom"], cible, "snapshot")
        # La vignette du Direct (send_camera_thumb) vient d'un cache distinct.
        # Une photo prise à la demande a déjà l'image sous la main ; elle évite d'afficher
        # une vue plus vieille que ce qu'on vient tout juste de capturer
        # (demandé sur l'issue GitHub #10). resultat["camera"], pas
        # `identity` : le webhook reçoit le nom affiché (voir le gabarit
        # d'URL des réglages), mais la tuile du Direct lit sa vignette sous
        # la clé stable (c.key côté JS) - les deux ne coïncident que par
        # hasard pour le bouton manuel, qui envoie déjà cette clé. Un test
        # en conditions réelles (webhook, caméra "Salon") a montré le
        # mauvais fichier écrit avant ce correctif.
        self._ecrire_vignette_camera(resultat["camera"], resultat["corps"])
        return cible

    def _ecrire_vignette_camera(self, identity: str, corps: bytes) -> None:
        """Même remplacement atomique que send_camera_thumb, réutilisable
        pour republier une image déjà en main sans repasser par Blink."""
        cache = self.paths["thumbs"] / "cameras" / f"{safe_file(identity)}.jpg"
        runtime.creer_sous_sorties(cache.parent)
        attente = cache.with_suffix(".tmp.jpg")
        attente.write_bytes(corps)
        attente.replace(cache)

    async def _actualiser_media_camera(self, blink, sync, camera) -> None:
        """Relit l'URL de la photo dans l'état courant de Blink."""
        await blink.get_homescreen()
        info = await sync.get_camera_info(
            camera.camera_id, unique_info=sync.get_unique_info(camera.name))
        if not isinstance(info, dict) or not info.get("thumbnail"):
            raise RuntimeError(msg("snapshot_image_indisponible"))
        await camera.update_images(info)

    def lister_snapshots(self) -> list:
        """Plus récent d'abord ; pas de registre séparé, le nom de fichier
        (horodatage UTC) suffit déjà à trier et le contenu du dossier à
        énumérer, comme pour les vidéos assemblées."""
        racine = self.paths["snapshots"]
        if not racine.is_dir():
            return []
        entrees = []
        for dossier_camera in racine.iterdir():
            if not dossier_camera.is_dir():
                continue
            for fichier in dossier_camera.glob("*.jpg"):
                if not fichier.is_file():
                    continue
                entrees.append({
                    "camera": dossier_camera.name,
                    "fichier": f"{dossier_camera.name}/{fichier.name}",
                    "horodatage": fichier.stem,
                })
        entrees.sort(key=lambda entree: entree["horodatage"], reverse=True)
        return entrees

    def gerer_webhook_snapshot(self) -> None:
        """Déclenche une prise de vue depuis l'extérieur (issue GitHub #9),
        authentifié par un secret dédié plutôt que par le jeton de session ou
        l'origine : un système tiers (smarthome) n'a ni l'un ni l'autre, et
        ne peut d'ailleurs pas poser d'en-tête personnalisé sur une simple
        URL de webhook - le secret voyage donc en paramètre, comme le jeton
        de session le fait déjà pour <video>/<img> (voir jeton_valide())."""
        requete = parse_qs(urlparse(self.path).query)
        fourni = (requete.get("token") or [""])[0]
        attendu = runtime.lire_jeton_webhook()
        if not attendu or not hmac.compare_digest(fourni, attendu):
            self.send_error(403)
            return
        camera = (requete.get("camera") or [""])[0].strip()
        if not camera:
            self.send_json({"error": msg("webhook_camera_manquante")}, 400)
            return
        try:
            chemin = self.declencher_snapshot(camera)
            # "chemin", pas "fichier" : meme cle et meme forme (chemin
            # absolu complet) que le webhook sortant (notifier_nouveau_media),
            # pour qu'un meme script cote domotique lise les deux de la
            # meme facon (issue #16 : la reponse ne donnait avant que le
            # chemin relatif sous Pictures/, sous une cle differente).
            self.send_json({"ok": True, "chemin": str(chemin)})
        except blink_engine.BusyError as erreur:
            self.send_json({"error": str(erreur)}, 409)
        except Exception as erreur:
            self.send_json({"error": f"{type(erreur).__name__}: {erreur}"}, 503)

    def _secret_webhook_valide(self, requete: dict) -> bool:
        """Meme secret que le webhook de photo, compare en temps constant (en
        octets : un jeton non ASCII ne doit pas lever TypeError)."""
        fourni = (requete.get("token") or [""])[0].encode("utf-8")
        attendu = runtime.lire_jeton_webhook().encode("utf-8")
        return bool(attendu) and hmac.compare_digest(fourni, attendu)

    def _etat_pour_webhook(self):
        """system_state() ou (None, message) : jamais une exception."""
        try:
            etat = self.system_state()
        except Exception as erreur:
            return None, f"{type(erreur).__name__}: {erreur}"
        if isinstance(etat, dict) and etat.get("error"):
            return None, str(etat["error"])
        return etat, ""

    def gerer_webhook_status(self) -> None:
        """Etat des cameras en JSON pour un script (issue #40) : en ligne,
        armee, batterie, temperature, wifi, firmware, modele. Meme authentification
        que gerer_webhook_snapshot(), avant les gardes-fous du navigateur."""
        requete = parse_qs(urlparse(self.path).query)
        if not self._secret_webhook_valide(requete):
            self.send_error(403)
            return
        camera = (requete.get("camera") or [""])[0].strip()
        etat, erreur = self._etat_pour_webhook()
        if etat is None:
            self.send_json({"error": erreur}, 503)
            return
        reponse = etat_webhook(etat, camera)
        if reponse is None:
            self.send_json({"error": msg("webhook_camera_inconnue", camera=camera)}, 404)
            return
        self.send_json(reponse)

    def gerer_webhook_arm(self) -> None:
        """Arme ou desarme une camera ou un systeme depuis un script (issue
        #40) : /webhook/arm?camera=<nom>|system=<nom>&armed=true|false&token=...

        Une camera hors ligne n'est pas tentee : la reponse rend son etat et une
        erreur nette plutot que d'attendre le delai d'une commande qui ne
        reviendra pas. Chaque changement est journalise (armement-webhook.log)
        avec l'adresse de l'appelant : desarmer des cameras a distance est plus
        sensible qu'une photo."""
        if self.command == "POST":
            try:
                longueur = int(self.headers.get("Content-Length") or 0)
            except ValueError:
                longueur = 0
            if longueur > _CORPS_WEBHOOK_MAX:
                self.close_connection = True
            elif longueur > 0:
                self.rfile.read(longueur)
        requete = parse_qs(urlparse(self.path).query)
        if not self._secret_webhook_valide(requete):
            self.send_error(403)
            return
        # Plusieurs caméras d'un coup en répétant le paramètre (camera=A&camera=B,
        # issue #40) ; les doublons ne sont armés qu'une fois.
        cameras = list(dict.fromkeys(
            nom.strip() for nom in requete.get("camera") or [] if nom.strip()))
        systeme = (requete.get("system") or [""])[0].strip()
        if bool(cameras) == bool(systeme):
            self.send_json({"error": msg("webhook_cible")}, 400)
            return
        voulu = _booleen_webhook((requete.get("armed") or [""])[0])
        if voulu is None:
            self.send_json({"error": msg("webhook_armed_invalide")}, 400)
            return
        etat, erreur = self._etat_pour_webhook()
        if etat is None:
            self.send_json({"error": erreur}, 503)
            return
        if systeme:
            corps, code = self._armer_une_cible(etat, "system", systeme, voulu)
            self.send_json(corps, code)
            return
        if len(cameras) == 1:
            corps, code = self._armer_une_cible(etat, "camera", cameras[0], voulu)
            self.send_json(corps, code)
            return
        # Une réponse par caméra : une caméra hors ligne ou inconnue n'empêche pas
        # les autres. « ok » global n'est vrai que si toutes ont réussi.
        resultats = []
        for nom in cameras:
            corps, code = self._armer_une_cible(etat, "camera", nom, voulu)
            resultat = {"name": nom, "status_code": code}
            resultat.update(corps)
            resultat["ok"] = code == 200
            resultats.append(resultat)
        self.send_json({"ok": all(r["ok"] for r in resultats), "scope": "cameras",
                        "requested": voulu, "cameras": resultats})

    def _armer_une_cible(self, etat: dict, portee: str, nom: str, voulu: bool):
        """Arme ou désarme une caméra ou un système nommé ; rend (réponse, code HTTP).

        `changed` dit si l'état connu de blink2video avant l'appel était différent
        de la demande ; `applied`, si l'état relu après l'appel correspond à la
        demande (pour une caméra, blink2video suit la commande jusqu'à ce que Blink
        la confirme)."""
        if portee == "camera":
            candidats = [(s, c) for s in etat.get("systems") or [] for c in s.get("cameras") or []
                         if str(c.get("name") or "").strip() == nom]
        else:
            candidats = [(s, None) for s in etat.get("systems") or []
                         if str(s.get("name") or "").strip() == nom]
        if not candidats:
            cle = "webhook_camera_inconnue" if portee == "camera" else "webhook_systeme_inconnu"
            return {"error": msg(cle, camera=nom, systeme=nom)}, 404
        if len(candidats) > 1:
            return {"error": msg("webhook_nom_ambigu", nom=nom)}, 409
        systeme_trouve, camera_trouvee = candidats[0]
        if camera_trouvee is not None and camera_trouvee.get("offline"):
            return {
                "error": msg("webhook_camera_hors_ligne", camera=nom),
                "camera": _camera_pour_webhook(camera_trouvee, systeme_trouve.get("name")),
            }, 409
        cible = camera_trouvee or systeme_trouve
        avant = cible.get("armed")
        try:
            self.set_armed(portee, cible["key"], voulu)
        except RuntimeError as erreur_blink:
            return {"error": str(erreur_blink)}, 503
        except Exception as erreur_blink:
            return {"error": f"{type(erreur_blink).__name__}: {erreur_blink}"}, 503
        horodatage = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        client = self.client_address[0] if getattr(self, "client_address", None) else "?"
        runtime.ajouter_ligne(
            "armement-webhook.log",
            msg("journal_armement", horodatage=horodatage, portee=portee, nom=nom,
                armed=str(voulu).lower(), client=client))
        reponse = {"ok": True, "scope": portee, "requested": voulu,
                   "changed": None if avant is None else bool(avant) != voulu}
        apres, _erreur = self._etat_pour_webhook()
        if apres is not None:
            if portee == "camera":
                lu = etat_webhook(apres, nom)
                if lu:
                    reponse["camera"] = lu["systems"][0]["cameras"][0]
                    reponse["applied"] = reponse["camera"]["armed"] == voulu
            else:
                courant = next((s for s in apres.get("systems") or []
                                if str(s.get("name") or "").strip() == nom), None)
                if courant is not None:
                    reponse["system"] = {"name": courant.get("name"), "armed": courant.get("armed")}
                    reponse["applied"] = courant.get("armed") == voulu
        return reponse, 200

    def send_camera_thumb(self, identity: str, refresh: bool = False) -> None:
        """Sert la dernière vignette connue d'une caméra.

        Elle remplace le cadre noir avant qu'on lance un direct : on voit
        d'emblée ce que regarde la caméra, y compris pour celles qui sont hors
        de portée et dont le direct échouera. C'est l'image que Blink garde de
        son côté, pas une capture neuve : la demander réveillerait la caméra et
        userait sa batterie à chaque affichage de la page.

        Une demande explicite de rafraîchissement relit l'image conservée par
        Blink. En cas d'échec, la dernière image locale reste disponible."""
        cached = (self.paths["thumbs"] / "cameras" / f"{safe_file(identity)}.jpg")
        body = b""
        if refresh or not (cached.is_file() and cached.stat().st_size > 0):
            def fetch(blink):
                async def run(_blink=blink):
                    sync, camera = BLINK.find_camera(_blink, identity)
                    await self._actualiser_media_camera(_blink, sync, camera)
                    response = await camera.get_media()
                    if response is None or response.status != 200:
                        return b""
                    return await response.read()
                return run()
            try:
                body = BLINK.call(fetch, timeout=45)
            except Exception as error:
                print(msg("vignette_erreur", identity=identity,
                          type=type(error).__name__, erreur=error), flush=True)
                body = b""
            if not body and not cached.is_file():
                self.send_error(404)
                return
            if body:
                # Dossier des vidéos absent : l'image reçue s'affiche quand
                # même, seul son cache attendra le retour du disque.
                with contextlib.suppress(runtime.DossierSortiesAbsent):
                    self._ecrire_vignette_camera(identity, body)

        body = body or cached.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", "image/jpeg")
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
            pass

    def live_failure_reason(self, stream) -> str:
        """Demande à Blink pourquoi aucune image n'arrive.

        La réponse de suivi de commande porte un motif lisible (« Live view
        failed » quand la caméra est hors de portée ou endormie). blinkpy le
        journalise mais ne le remonte pas : on interroge donc nous-mêmes."""
        if stream is None:
            return msg("camera_aucune_image")
        def ask(blink):
            async def run(_blink=blink):
                from blinkpy import api
                return await api.request_command_status(
                    _blink, stream.camera.network_id, stream.command_id
                )
            return run()
        try:
            status = BLINK.call(ask, timeout=30) or {}
        except Exception:
            return msg("camera_aucune_image")

        message = str(status.get("status_msg") or "").strip()
        commands = status.get("commands") or []
        condition = ""
        for command in commands:
            if command.get("id") == stream.command_id:
                condition = str(command.get("state_condition") or "")
        if message.lower() == "live view failed" or condition == "error":
            return msg("camera_sans_reponse", detail=message or condition)
        return message or msg("camera_aucune_image")

    def send_attente_module(self) -> None:
        """Attend que MODULE_SLOT soit réellement libre, sans le retenir.

        Ne déclenche rien : watchLive() (serve_app.js) a déjà demandé
        l'arrêt de la session active (stopWatch(), pc.close()/fetch abort)
        avant cet appel - la lui redemander ici serait redondant. Le rôle
        de cette route est seulement de dire QUAND ce nettoyage, déjà en
        cours, arrive à son terme, plutôt que de laisser le client deviner
        un délai (10s à l'aveugle, remplacé par ceci le 2026-09-03 -
        BACKLOG.md : mesuré 8,8s puis 1,65s sur deux bascules réelles,
        bien trop variable pour un délai fixe). Ce qui prend réellement du
        temps échappe à ce serveur : blinkpy (LiveStreamAPI.poll(),
        livestream.py) n'envoie la commande "done" à Blink qu'une fois vu
        la fin de la connexion TCP vers le relais Blink lui-même - déjà
        attendu en entier par _stop_stream avant que MODULE_SLOT soit
        rendu, donc rien à accélérer ici, seulement à rendre visible."""
        libre = MODULE_SLOT.acquire(blocking=True, timeout=ATTENTE_MODULE_MAX_SECONDS)
        if libre:
            MODULE_SLOT.release()
            self.send_json({"libre": True})
        else:
            self.send_json({"libre": False, "error": _slot_occupe_message()})

    def send_offer_webrtc(self, name: str, payload: dict) -> None:
        """Une tentative possède son réveil, son pc et un nettoyage unique."""
        offer_sdp = str(payload.get("sdp") or "")
        offer_type = str(payload.get("type") or "")
        session_id = str(payload.get("session_id") or "")
        essai = payload.get("essai")
        if not offer_sdp or offer_type != "offer" or not DIRECT_SESSION_ID.fullmatch(session_id):
            self.send_json({"error": msg("offre_sdp_invalide")}, 400)
            return

        if not MODULE_SLOT.acquire(blocking=False):
            message = _slot_occupe_message()
            _memoriser_erreur_direct(name, message, 409)
            self.send_json({"error": message}, 409)
            return

        holder: dict = {"session_id": session_id, "arret": threading.Event(),
                        "rendu": False, "nettoyage": None}

        def journal(message):
            _journal_direct(name, f"session {session_id[:8]}, tentative {essai} : {message}")

        def _rendre_verrous() -> None:
            # Ne rend jamais deux fois le sémaphore, même si HTTP et un
            # callback de pc réclament tous les deux le nettoyage.
            with DIRECT_WEBRTC_SESSION_LOCK:
                if holder["rendu"]:
                    return
                holder["rendu"] = True
            verrou = holder.get("lock")
            try:
                if verrou is not None and holder.get("lock_entered"):
                    verrou.__exit__(None, None, None)
            except Exception as error:
                journal(msg("echec_liberation_verrou", erreur=f"{type(error).__name__}: {error}"))
            finally:
                with DIRECT_WEBRTC_SESSION_LOCK:
                    if DIRECT_WEBRTC_SESSION.get("session") is holder:
                        DIRECT_WEBRTC_SESSION.clear()
                    ENREGISTREMENT_DIRECT_ACTIF.clear()
                    try:
                        _slot_rendu()
                    finally:
                        MODULE_SLOT.release()

        async def _nettoyer() -> None:
            try:
                stream = holder.get("stream")
                if stream is not None:
                    journal(await _stop_stream(stream, holder.get("feed")))
            except Exception as error:
                journal(msg("echec_fermeture_blink", erreur=f"{type(error).__name__}: {error}"))
            finally:
                _rendre_verrous()

        async def _fermer() -> None:
            # Tous les appelants attendent la même tâche. Leur annulation
            # ne coupe ni done ni la libération des verrous.
            if holder["nettoyage"] is None:
                holder["nettoyage"] = asyncio.ensure_future(_nettoyer())
            await asyncio.shield(holder["nettoyage"])

        def _interrompre() -> None:
            # Exécuté sur BLINK.loop, sans passer par BLINK.call : celui-ci
            # peut justement attendre le réveil que l'utilisateur annule.
            if holder["nettoyage"] is not None or holder["rendu"]:
                return
            pc = holder.get("pc")
            if pc is not None:
                asyncio.ensure_future(blink_webrtc.fermer_connexion(pc))
            else:
                task = holder.get("demarrage")
                if task is not None:
                    task.cancel()

        holder["interrompre"] = _interrompre
        with DIRECT_WEBRTC_SESSION_LOCK:
            _purger_arrets_direct()
            annulee = session_id in DIRECT_ARRETS_RECENTS
            if not annulee:
                DIRECT_WEBRTC_SESSION["session"] = holder
        if annulee:
            MODULE_SLOT.release()
            self.send_json({"error": msg("tentative_annulee")}, 409)
            return

        def _attendre_nettoyage_http():
            loop = holder.get("loop")
            if loop is None:
                _rendre_verrous()
            else:
                # Pas de reconnexion/authentification requise pour fermer.
                future = asyncio.run_coroutine_threadsafe(_fermer(), loop)
                future.result(timeout=45)

        try:
            _slot_pris("direct WebRTC", name)
            journal("direct WebRTC commence")
            ENREGISTREMENT_DIRECT_ACTIF.clear()
            _effacer_erreur_direct()
            holder["lock"] = blink_engine.hub_lock("direct", attente=ATTENTE_HUB_MAX_SECONDS)
            holder["lock"].__enter__()
            holder["lock_entered"] = True
            if holder["arret"].is_set():
                raise concurrent.futures.CancelledError()

            def start(blink):
                async def run(_blink=blink):
                    holder["loop"] = asyncio.get_running_loop()
                    holder["demarrage"] = asyncio.current_task()
                    try:
                        if holder["arret"].is_set():
                            raise asyncio.CancelledError()
                        _, camera = BLINK.find_camera(_blink, name)

                        async def ouvrir():
                            t_flux = time.monotonic()
                            try:
                                stream = await camera.init_livestream()
                            except KeyError as error:
                                raise RuntimeError(msg("aucune_adresse_flux")) from error
                            except NotImplementedError as error:
                                raise RuntimeError(msg("format_direct_non_pris_en_charge", erreur=error)) from error
                            holder["stream"] = stream
                            journal(f"init_livestream en {time.monotonic() - t_flux:.1f}s")
                            t_start = time.monotonic()
                            await stream.start()
                            journal(f"stream.start() en {time.monotonic() - t_start:.1f}s")
                            return stream

                        stream = await asyncio.wait_for(ouvrir(), timeout=BLINK_OPEN_MAX_SECONDS)
                        holder["feed"] = asyncio.ensure_future(stream.feed())
                        negociation = asyncio.ensure_future(blink_webrtc.negocier(
                            stream.url, offer_sdp, offer_type, _fermer,
                            ffmpeg=self.ffmpeg,
                            chemin_enregistrement=lambda: _chemin_enregistrement_direct(camera.name),
                            enregistrement_actif=ENREGISTREMENT_DIRECT_ACTIF.is_set,
                            journal=journal,
                        ))
                        try:
                            terminees, _ = await asyncio.wait(
                                {negociation, holder["feed"]},
                                return_when=asyncio.FIRST_COMPLETED,
                            )
                            if negociation not in terminees and holder["nettoyage"] is None:
                                # feed.auth peut échouer avant son finally
                                # amont, en laissant le TCP local ouvert. Ne
                                # pas attendre 40 s un SPS qui n'arrivera pas.
                                raise RuntimeError(msg("relais_arrete_avant_image"))
                            # Une négociation en échec ferme elle-même feed
                            # via on_close avant de remonter son exception.
                            # Attendre ce nettoyage conserve la vraie cause.
                            pc, answer_sdp, answer_type = await negociation
                        finally:
                            if not negociation.done():
                                negociation.cancel()
                                await asyncio.gather(negociation, return_exceptions=True)
                        holder["pc"] = pc
                        if holder["arret"].is_set() or holder["rendu"]:
                            await blink_webrtc.fermer_connexion(pc)
                            raise asyncio.CancelledError()
                        return answer_sdp, answer_type
                    except BaseException:
                        await _fermer()
                        raise
                    finally:
                        holder["demarrage"] = None
                return run()

            answer_sdp, answer_type = BLINK.call(start, timeout=WEBRTC_START_MAX_SECONDS)
            journal(msg("webrtc_negocie"))
        except Exception as error:
            message = msg("tentative_annulee") if isinstance(error, concurrent.futures.CancelledError) else (
                str(error) if isinstance(error, RuntimeError) else f"{type(error).__name__}: {error}"
            )
            _memoriser_erreur_direct(name, message, 503)
            journal(msg("echec_webrtc", message=message))
            try:
                _attendre_nettoyage_http()
            except Exception as error:
                journal(f"nettoyage encore en cours, {type(error).__name__}: {error}")
            try:
                self.send_json({"error": message}, 503)
            except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
                pass
            return

        try:
            self.send_json({"sdp": answer_sdp, "type": answer_type, "session_id": session_id})
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
            _demander_arret_direct(session_id)

    def send_live_mse(self, name: str, session_id: str = "") -> None:
        """Diffuse le direct d'une caméra en fMP4 fragmenté, pour MediaSource.

        Face à /live (MJPEG) : au lieu de faire réencoder chaque image en
        JPEG par ffmpeg puis redécoder par un <img>, on ne fait que changer
        de conteneur (Annexe B -> boîtes MP4 fragmentées), sans toucher à
        l'image. Un sous-processus ffmpeg fait le remux, exactement comme
        /live : une première version passait par PyAV en lecture directe du
        flux Blink, plus simple pour lire le codec exact, mais nettement
        moins fiable sur ce flux précis (essais réels sur caméra) que le
        sous-processus ffmpeg qu'utilise /live avec succès depuis le début.
        Le prix : lire le codec exige de parcourir les boîtes du segment
        d'initialisation MP4 (moov > trak > mdia > minf > stbl > stsd >
        avc1 > avcC) plutôt que de le lire via une API.

        Déclenche aussi l'enregistrement du direct dans Blink_Direct (voir
        _chemin_enregistrement_direct) : les mêmes octets envoyés au
        navigateur sont dupliqués vers un fichier, best-effort - un échec
        n'interrompt jamais le direct affiché, voir _ecrire ci-dessous.

        `session_id` (optionnel, ancien client ou test dépourvu) : enregistré
        dans DIRECT_MSE_SESSION pour que /api/arreter-direct puisse
        interrompre l'attente ci-dessous sans dépendre de la détection
        passive d'une connexion fermée, qui ne survient qu'à la prochaine
        écriture - jamais si la caméra reste muette (bug réel : le module
        restait occupé jusqu'à LIVE_MAX_SECONDS après une fermeture d'onglet
        pendant un silence)."""
        if not DIRECT_SESSION_ID.fullmatch(session_id):
            # Dégradation gracieuse plutôt qu'un refus : un ancien client ou
            # un identifiant mal formé perd juste le bénéfice de l'arrêt
            # explicite, le direct lui-même n'a pas besoin de session_id.
            session_id = ""
        if not MODULE_SLOT.acquire(blocking=False):
            message = _slot_occupe_message()
            _memoriser_erreur_direct(name, message, 409)
            _journal_direct(name, f"refusee (MSE), {message}")
            # Le détail peut contenir un nom de caméra non latin-1 ou un saut
            # de ligne. Il reste disponible via /api/live-error ; la ligne de
            # statut HTTP, elle, doit toujours rester ASCII et bien formée.
            self.send_error(409, "Live stream busy")
            return

        holder: dict = {"session_id": session_id, "arret": threading.Event()}
        if session_id:
            with DIRECT_WEBRTC_SESSION_LOCK:
                DIRECT_MSE_SESSION["session"] = holder
        errors: list = []
        erreur_direct = None
        reponse_commencee = False
        journaux_nettoyage: list = []
        try:
            _slot_pris("direct MSE", name)
            ENREGISTREMENT_DIRECT_ACTIF.clear()
            # Une tentative réellement admise remplace l'ancien diagnostic.
            # En particulier, un 409 ne doit jamais faire relire au navigateur
            # le 503 d'une tentative précédente de la même caméra.
            _effacer_erreur_direct()
            holder["lock"] = blink_engine.hub_lock("direct", attente=ATTENTE_HUB_MAX_SECONDS)
            holder["lock"].__enter__()
            holder["lock_entered"] = True

            def start(blink):
                async def run(_blink=blink):
                    sync, camera = BLINK.find_camera(_blink, name)
                    # Chronométré comme send_offer_webrtc (même opacité :
                    # init_livestream()/stream.start() sont de vrais appels
                    # au cloud Blink, la part la plus lente et jusqu'ici la
                    # moins visible de tout ce chemin - voir sa note,
                    # 2026-09-04).
                    t_flux = time.monotonic()
                    try:
                        stream = await camera.init_livestream()
                    except KeyError as error:
                        raise RuntimeError(
                            msg("aucune_adresse_flux_camera", camera=name)) from error
                    except NotImplementedError as error:
                        raise RuntimeError(
                            msg("format_non_pris_en_charge", erreur=error)) from error
                    _journal_direct(
                        name, f"init_livestream en "
                              f"{time.monotonic() - t_flux:.1f}s"
                    )
                    # init_livestream() a déjà créé une commande distante. Si
                    # start() échoue, le finally doit tout de même retrouver ce
                    # flux et envoyer stop/done à Blink.
                    holder["stream"] = stream
                    # camera.name, pas `name` : `name` est ici l'identifiant
                    # opaque de camera_key() (stable à travers un renommage,
                    # voir sa docstring), pas le nom affiché. Passé par
                    # holder plutôt que par une fermeture directe, comme
                    # "stream"/"feed" : run() tourne sur BLINK.loop, ce nom
                    # doit survivre jusqu'à _ecrire (bien plus bas, hors de
                    # cette coroutine) pour nommer le fichier d'enregistrement.
                    holder["camera_name"] = camera.name
                    t_start = time.monotonic()
                    await stream.start()
                    _journal_direct(
                        name, f"stream.start() en "
                              f"{time.monotonic() - t_start:.1f}s"
                    )
                    holder["feed"] = asyncio.ensure_future(stream.feed())
                    return stream.url
                return run()

            url = BLINK.call(start, timeout=45)
            _journal_direct(name, f"flux Blink ouvert sur {url}")

            audio_settings = runtime.lire_reglages()
            audio_options = blink_audio.stream_options(audio_settings)
            process = runtime.demarrer(
                # loglevel "info" temporaire (diagnostic partage relais/ffmpeg
                # en cours, cf. direct.log) : à repasser à "error" une fois
                # l'écart Terrasse1/Salon expliqué.
                [self.ffmpeg, "-hide_banner", "-loglevel", "info",
                 "-fflags", "nobuffer", "-flags", "low_delay",
                 # Plus généreux qu'en MJPEG (500000/300000) : un moov figé
                 # (empty_moov) doit connaître les dimensions *avant*
                 # d'écrire son en-tête, alors que le décodage MJPEG les
                 # apprend au fil de l'eau sans jamais avoir à s'engager
                 # d'avance. Vu en vrai sur une caméra plus lente à livrer
                 # son SPS (jardin) : « dimensions not set / Could not
                 # write header » côté ffmpeg, jamais côté MJPEG avec les
                 # mêmes réglages sur la même caméra. Toujours largement
                 # sous LIVE_FIRST_FRAME_SECONDS (40 s) : une caméra qui
                 # répond vite n'attend pas plus longtemps pour autant, ce
                 # ne sont que des plafonds.
                 #
                 # Essai en cours (direct.log, 2026-09-02) : à 5000000, ce
                 # plafond est systématiquement consommé en quasi-totalité
                 # (~5-6 s) sur Salon ET Terrasse1, deux caméras sans rapport
                 # avec le cas lent d'origine (jardin) : pas juste utilisé
                 # quand nécessaire, toujours épuisé. Redescendu à 1500000
                 # (3x l'ancien seuil MJPEG, 1/3 de l'actuel) pour voir si
                 # jardin tient toujours sans « dimensions not set ». À
                 # remonter si jardin échoue avec cette valeur.
                 "-analyzeduration", "1500000", "-probesize", "1500000",
                 "-i", url,
                 # copy : remux sans réencodage, coût CPU quasi nul.
                 "-map", "0:v:0", "-c:v", "copy", *audio_options,
                 "-f", "mp4",
                 "-movflags", blink_audio.fragment_flags(audio_settings),
                 "-"],
                stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )
            holder["process"] = process

            def _drainer_stderr() -> None:
                # Une ligne à la fois (pas un seul read() bloquant jusqu'à la
                # fin) : diagnostic temporaire pour dater chaque étape interne
                # de ffmpeg (détection du flux, image-clé...) pendant qu'on
                # cherche pourquoi Terrasse1 (12 s) dépasse Salon (8 s) entre
                # le premier octet du relais et le segment initial. errors
                # reste alimentée pour _texte_stderr_ffmpeg (reason/diagnostic
                # de fin), inchangé par ailleurs.
                for ligne_brute in iter(process.stderr.readline, b""):
                    errors.append(ligne_brute)
                    texte = ligne_brute.decode("utf-8", "replace").rstrip()
                    if texte:
                        _journal_direct(name, f"ffmpeg : {texte}")

            holder["drain"] = threading.Thread(target=_drainer_stderr, daemon=True)
            holder["drain"].start()

            lecteur = LecteurTube(process.stdout)
            first = read_mp4_init_segment(lecteur, LIVE_FIRST_FRAME_SECONDS)
            _journal_direct(name, f"segment initial {len(first)} octets")
            if not first:
                reason = self.live_failure_reason(holder.get("stream"))
                # ffmpeg peut s'être arrêté avant tout octet exploitable (pas
                # forcément une caméra hors de portée) : son message, quand il
                # y en a un, en dit souvent plus que le statut Blink seul. Le
                # fil de lecture n'a que ça à faire depuis le lancement du
                # sous-processus : s'il n'a pas fini, ffmpeg tourne encore.
                drain = holder.get("drain")
                if drain is not None:
                    drain.join(timeout=2)
                trace = _texte_stderr_ffmpeg(errors)
                if trace:
                    reason = f"{reason} | ffmpeg : {trace}"
                raise RuntimeError(reason)
            codecs = [h264_mime_codec_from_moov(first)]
            audio_codec = blink_audio.aac_mime_codec_from_moov(first)
            if audio_codec:
                codecs.append(audio_codec)
            codec_str = ", ".join(codecs)
            # Pré-alimenté avec `first` (verdict ignoré : voir _ecrire, ce
            # premier bloc est de toute façon toujours écrit intégralement)
            # pour que son suivi des tailles de boîte reste synchronisé sur
            # tout le flux depuis son origine, `first` pouvant déjà contenir
            # plus que le seul segment d'initialisation (voir sa docstring).
            detecteur_fragments = DetecteurDebutFragment()
            detecteur_fragments.bloc_ouvre_un_fragment(first)

            # À partir de ce point, une seconde ligne de statut HTTP ne peut
            # plus être envoyée proprement, même si end_headers() échoue.
            reponse_commencee = True
            self.send_response(200)
            self.send_header("Content-Type", "video/mp4")
            self.send_header("Cache-Control", "no-store")
            self.send_header("X-Codec", codec_str)
            self.send_header("X-Audio", "1" if audio_codec else "0")
            self.send_header("Connection", "close")
            self.close_connection = True
            self.end_headers()
            _journal_direct(name, f"en-tetes envoyes, codec {codec_str}")

            def _ecrire(data: bytes) -> bool:
                # Même tolérance sur ce premier envoi que sur les suivants :
                # un onglet déjà fermé quand ce bloc part ne doit pas remonter
                # comme un échec (vu en vrai : ConnectionAbortedError sur ce
                # tout premier write, alors que c'est une fin normale).
                #
                # Piloté par le bouton (ENREGISTREMENT_DIRECT_ACTIF, voir
                # /api/direct-enregistrement) : trois états, jamais mélangés -
                # démarrage (ouvre un fichier neuf, horodaté à cet instant, et
                # y écrit d'abord `first` : sans le segment d'initialisation
                # MP4 en tête, le fichier ne serait pas lisible), arrêt
                # (referme, n'écrit plus rien de ce paquet-ci), régime établi
                # (écrit `data` normalement). Un échec ici (disque plein...)
                # arrête l'enregistrement seul, jamais le direct à l'écran.
                fichier_direct = holder.get("fichier_direct")
                desire = ENREGISTREMENT_DIRECT_ACTIF.is_set()
                # `data` doit alimenter le détecteur même quand desire est
                # encore faux ou qu'un fichier est déjà ouvert : c'est le
                # prix pour que son suivi des tailles de boîte reste
                # synchronisé sur tout le flux (voir sa docstring). Pas pour
                # `first`, déjà pré-alimenté avant la boucle.
                debut_fragment = (
                    None if data is first
                    else detecteur_fragments.trouver_debut_fragment(data)
                )
                if desire and fichier_direct is None and debut_fragment is not None:
                    # Un démarrage demandé en cours de direct (pas dès le
                    # premier bloc) attend ce fragment plutôt que d'écrire
                    # `data` tel quel : les blocs lus depuis ffmpeg (16 Ko,
                    # LecteurTube) ne s'alignent jamais sur les frontières
                    # moof/mdat, démarrer sur un bloc quelconque pouvait
                    # couper un mdat en deux - le fichier obtenu, amputé du
                    # moof de tête, n'était plus décodable (constaté avec de
                    # vraies données : ffmpeg y lisait des NAL units invalides).
                    #
                    # holder["camera_name"] (le vrai nom, posé par run() plus
                    # haut) plutôt que `name` : voir la note sur camera_key()
                    # à l'endroit où holder["camera_name"] est posé.
                    chemin = _chemin_enregistrement_direct(
                        holder.get("camera_name", name)
                    )
                    try:
                        runtime.creer_sous_sorties(chemin.parent)
                        fichier_direct = chemin.open("wb")
                        init_seg = _extraire_init_segment(first)
                        fichier_direct.write(init_seg)
                        holder["pos_valide"] = fichier_direct.tell()
                        fichier_direct.write(data[debut_fragment:])
                        if (detecteur_fragments.dernier_offset_fragment_complet is not None
                                and detecteur_fragments.dernier_offset_fragment_complet >= debut_fragment):
                            holder["pos_valide"] = len(init_seg) + (
                                detecteur_fragments.dernier_offset_fragment_complet - debut_fragment
                            )
                        holder["fichier_direct"] = fichier_direct
                    except OSError as error:
                        fichier_direct = None
                        _journal_direct(name, msg(
                            "enregistrement_impossible",
                            erreur=f"{type(error).__name__}: {error}"))
                elif not desire and fichier_direct is not None:
                    try:
                        pos = holder.get("pos_valide")
                        if pos is not None:
                            fichier_direct.seek(pos)
                            fichier_direct.truncate()
                        fichier_direct.close()
                    except OSError as error:
                        _journal_direct(name, msg(
                            "echec_fermeture_enregistrement",
                            erreur=f"{type(error).__name__}: {error}"))
                    holder["fichier_direct"] = None
                    holder.pop("pos_valide", None)
                elif fichier_direct is not None:
                    try:
                        fichier_direct.write(data)
                        if detecteur_fragments.dernier_offset_fragment_complet is not None:
                            holder["pos_valide"] = fichier_direct.tell() - (
                                len(data) - detecteur_fragments.dernier_offset_fragment_complet
                            )
                    except OSError as error:
                        holder["fichier_direct"] = None
                        _journal_direct(
                            name, f"enregistrement interrompu, "
                                  f"{type(error).__name__}: {error}"
                        )
                try:
                    self.wfile.write(data)
                    return True
                except (BrokenPipeError, ConnectionResetError,
                        ConnectionAbortedError):
                    return False

            if not _ecrire(first):
                holder["sent"] = 0
                return
            sent = len(first)
            # deadline n'était vérifiée qu'entre deux lectures : une lecture
            # individuelle bloquée (ffmpeg qui se tait sans jamais fermer
            # stdout) ne rendait jamais la main, et LIVE_MAX_SECONDS ne
            # bornait donc rien dans ce cas précis (vu en vrai : un direct
            # resté ouvert plus de 600 s, MODULE_SLOT jamais rendu). lire()
            # porte maintenant elle-même un délai réel sur chaque lecture.
            deadline = time.monotonic() + LIVE_MAX_SECONDS
            while time.monotonic() < deadline:
                if holder["arret"].is_set():
                    break  # /api/arreter-direct : ne pas attendre un bloc qui peut ne jamais venir
                chunk = lecteur.lire(
                    min(LIVE_MSE_ARRET_POLL_SECONDS, deadline - time.monotonic())
                )
                if chunk is None:
                    continue  # juste lent : le délai global n'est pas encore écoulé
                if not chunk:
                    break
                if not _ecrire(chunk):
                    break  # onglet fermé : c'est la fin normale d'un direct
                sent += len(chunk)
            holder["sent"] = sent
        except Exception as error:
            erreur_direct = str(error) if isinstance(error, RuntimeError) \
                else f"{type(error).__name__}: {error}"
        finally:
            if session_id:
                with DIRECT_WEBRTC_SESSION_LOCK:
                    if DIRECT_MSE_SESSION.get("session") is holder:
                        DIRECT_MSE_SESSION.clear()
            process = holder.get("process")
            if process is not None:
                try:
                    process.terminate()
                except Exception as error:
                    journaux_nettoyage.append(msg(
                        "echec_ffmpeg", etape="terminate", erreur=f"{type(error).__name__}: {error}"))
                try:
                    process.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    try:
                        process.kill()
                    except Exception as error:
                        journaux_nettoyage.append(msg(
                            "echec_ffmpeg", etape="kill", erreur=f"{type(error).__name__}: {error}"))
                except Exception as error:
                    journaux_nettoyage.append(msg(
                        "echec_ffmpeg", etape="wait", erreur=f"{type(error).__name__}: {error}"))
            fichier_direct = holder.get("fichier_direct")
            if fichier_direct is not None:
                try:
                    pos = holder.get("pos_valide")
                    if pos is not None:
                        fichier_direct.seek(pos)
                        fichier_direct.truncate()
                    fichier_direct.close()
                except OSError as error:
                    journaux_nettoyage.append(msg(
                        "echec_fermeture_enregistrement",
                        erreur=f"{type(error).__name__}: {error}"))
                holder["fichier_direct"] = None
                holder.pop("pos_valide", None)
            stream = holder.get("stream")
            if stream is not None:
                try:
                    verdict = BLINK.call(
                        lambda _b: _stop_stream(stream, holder.get("feed")),
                        timeout=45,
                    )
                except Exception as error:
                    verdict = msg("echec_fermeture", erreur=f"{type(error).__name__}: {error}")
                journaux_nettoyage.append(verdict)
            verrou = holder.get("lock")
            if verrou is not None and holder.get("lock_entered"):
                try:
                    verrou.__exit__(None, None, None)
                except Exception as error:
                    journaux_nettoyage.append(msg(
                        "echec_liberation_verrou",
                        erreur=f"{type(error).__name__}: {error}"))
            # Publier l'échec avant de rendre le jeton empêche une nouvelle
            # tentative d'effacer LAST_LIVE_ERROR puis de voir cet ancien
            # diagnostic réapparaître sous ses pieds. La réponse HTTP reste,
            # elle, différée jusqu'après la libération du jeton.
            if erreur_direct is not None:
                try:
                    _memoriser_erreur_direct(name, erreur_direct, 503)
                except Exception as error:
                    journaux_nettoyage.append(msg(
                        "diagnostic_indisponible", erreur=f"{type(error).__name__}: {error}"))
            # Ces deux libérations mémoire sont la dernière ceinture : aucune
            # erreur de fermeture de ffmpeg/Blink/verrou disque, ni aucun
            # diagnostic, ne doit pouvoir les sauter.
            try:
                _slot_rendu()
            finally:
                MODULE_SLOT.release()

            # Diagnostic seulement après avoir rendu toutes les ressources :
            # sa lecture attend un fil et manipulait auparavant bytes comme str,
            # ce qui pouvait lever ici et laisser le module occupé à jamais.
            sent = holder.get("sent")
            if sent is not None:
                try:
                    detail = ""
                    if not sent:
                        drain = holder.get("drain")
                        if drain is not None:
                            drain.join(timeout=3)
                        detail = " | ffmpeg : " + (
                            _texte_stderr_ffmpeg(errors) or "rien"
                        )
                    journaux_nettoyage.append(
                        f"termine, {sent} octets transmis{detail}"
                    )
                except Exception as error:
                    journaux_nettoyage.append(
                        f"diagnostic final indisponible, {type(error).__name__}: {error}"
                    )
            for journal in journaux_nettoyage:
                _journal_direct(name, journal)

        if erreur_direct is not None:
            # Le client ne voit ce 503 qu'une fois ffmpeg arrêté, la session
            # Blink fermée et les verrous rendus. Sa prochaine tentative ne
            # peut donc plus consommer ses reprises sur nos propres 409.
            _journal_direct(name, msg("echec_direct", erreur=erreur_direct))
            if not reponse_commencee:
                try:
                    self.send_error(503, "Live stream unavailable")
                except Exception:
                    pass

    def resolve_media(self, route: str) -> Path | None:
        """Traduit « clip/… » ou « daily/… » en fichier réel, ou rien.

        Deux vérifications indépendantes : la forme du chemin, puis le fait
        qu'il retombe bien sous la racine annoncée une fois résolu. La seconde
        est celle qui compte, elle neutralise les « .. » quelle que soit leur
        écriture."""
        kind, _, relative = route.partition("/")
        if not relative or not IDENTITY.match(relative):
            return None

        if kind == "clip":
            if relative not in known_identities(self.paths):
                return None
            roots = [self.paths[name] for name in ("normalized", "input", "excluded")]
        elif kind in ("daily", "weekly", "monthly", "direct"):
            roots = [self.paths[kind]]
        else:
            return None

        for root in roots:
            candidate = (root / relative).resolve()
            if runtime.est_relatif_a(candidate, root.resolve()) and candidate.is_file():
                return candidate
        return None

    def resolve_snapshot(self, relative: str) -> Path | None:
        """Même garde-fou que resolve_media() (chemin bien formé, puis retombe
        sous la racine une fois résolu), pour « caméra/fichier.jpg » : une
        seule racine, pas de identité à vérifier contre un registre - une
        photo prise à la demande n'en a pas."""
        if not relative or not IDENTITY_SNAPSHOT.match(relative):
            return None
        root = self.paths["snapshots"]
        candidate = (root / relative).resolve()
        if runtime.est_relatif_a(candidate, root.resolve()) and candidate.is_file():
            return candidate
        return None

    # ------------------------------------------------------------------ routes

    def do_GET(self):
        # try/except : self.path vient tel quel de la ligne de requête,
        # sous contrôle total du client. urlparse lève sur certaines formes
        # manifestement invalides (IPv6 mal fermé, ex. « //[abc ») - même
        # piège déjà audité et corrigé pour l'Origin dans hote_autorise() et
        # pour l'URL de webhook sortant. Sur les Python récents (3.9+, voir
        # gh-87389) un chemin "//..." est déjà normalisé avant d'atteindre
        # urlparse et ce cas ne se présente plus ; le build Windows 7
        # (Python 3.8.10 épinglé) n'a pas ce correctif stdlib, d'où le
        # filet ici plutôt que de compter dessus.
        try:
            route = urlparse(self.path).path
        except ValueError:
            self.send_error(400)
            return
        if route == WEBHOOK_SNAPSHOT_ROUTE:
            # Volontairement AVANT hote_autorise()/jeton_valide() : ce sont
            # les gardes-fous du navigateur (même origine, même machine),
            # sans objet pour un appel externe légitime (smarthome, issue
            # GitHub #9). gerer_webhook_snapshot() a son propre secret,
            # indépendant, jamais examiné par ces deux fonctions.
            self.gerer_webhook_snapshot()
            return
        if route == WEBHOOK_STATUS_ROUTE:
            self.gerer_webhook_status()
            return
        if route == WEBHOOK_ARM_ROUTE:
            self.gerer_webhook_arm()
            return
        if not self.hote_autorise():
            self.send_error(403)
            return
        if route not in ("/", "/favicon.ico") and not self.jeton_valide():
            self.send_error(403)
            return
        if route == "/":
            body = page_avec_entete(runtime.lire_reglages()).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            # La page est intégrée au script : elle change dès qu'on modifie
            # serve.py. Sans cette consigne, le navigateur ressert sa copie et
            # l'on croit une modification perdue alors qu'elle est bien là.
            self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return

        if route == "/favicon.ico":
            chemin = runtime.resource_dir() / "assets" / "blink2video.ico"
            if not chemin.is_file():
                self.send_error(404)
                return
            corps = chemin.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "image/x-icon")
            # Statique, ne change jamais en cours de route : autant laisser le
            # navigateur cesser de le redemander à chaque page.
            self.send_header("Cache-Control", "public, max-age=604800")
            self.send_header("Content-Length", str(len(corps)))
            self.end_headers()
            self.wfile.write(corps)
            return

        if route.startswith("/camthumb/"):
            # Pas de contrôle du nom ici : il coûterait un rafraîchissement
            # complet du compte à chaque vignette. C'est find_camera, plus bas,
            # qui refuse un nom inconnu, et safe_file qui assainit le nom de
            # fichier du cache.
            refresh = parse_qs(urlparse(self.path).query).get("refresh", [""])[0] == "1"
            self.send_camera_thumb(unquote(route[len("/camthumb/"):]), refresh=refresh)
            return

        if route.startswith("/live-mse/"):
            session_id = parse_qs(urlparse(self.path).query).get("session_id", [""])[0]
            self.send_live_mse(unquote(route[len("/live-mse/"):]), session_id)
            return

        if route == "/api/live-error":
            self.send_json(_derniere_erreur_direct())
            return

        if route == "/api/system":
            try:
                self.send_json(self.system_state())
            except RuntimeError as error:
                self.send_json({"error": str(error)}, 503)
            return

        if route == "/api/attente-module":
            self.send_attente_module()
            return

        if route == "/api/videos":
            self.send_json(collect_videos(self.paths, self.ffmpeg))
            return

        if route == "/api/status":
            self.send_json({
                "authenticated": (runtime.app_dir() / "blink_auth.json").is_file(),
                "initial_setup": self.initial_setup,
            })
            return

        if route == "/api/autostart":
            self.send_json({"actif": autostart.est_installe()})
            return

        if route == "/api/choisir-dossier":
            # Le navigateur ne peut pas rendre un chemin absolu (File System
            # Access API : juste un handle, par conception, pour la vie
            # privée du web). Serveur et page étant sur la même machine ici
            # (outil local, pas un service distant), une boîte de dialogue
            # native comble ce manque. PowerShell/WinForms sous Windows (voir
            # _choisir_dossier_windows) ; tkinter en repli ailleurs - importé
            # localement pour ne jamais peser sur les environnements sans
            # affichage (CI Linux headless), qui n'empruntent jamais cette
            # route.
            try:
                if os.name == "nt":
                    choisi = _choisir_dossier_windows(runtime.lire_dossier_stockage())
                else:
                    import tkinter
                    from tkinter import filedialog
                    racine = tkinter.Tk()
                    racine.withdraw()
                    racine.attributes("-topmost", True)
                    choisi = filedialog.askdirectory(initialdir=runtime.lire_dossier_stockage())
                    racine.destroy()
            except Exception as error:
                self.send_json({"error": str(error)}, 500)
                return
            self.send_json({"path": choisi or ""})
            return

        if route == "/api/reglages":
            self.send_json(
                {**runtime.lire_reglages(), "storage_dir": runtime.lire_dossier_stockage(),
                 "initial_setup": self.initial_setup,
                 "webhook_token": runtime.lire_jeton_webhook()})
            return

        if route == "/api/sourdine":
            # Les caméras connues viennent du registre des clips ET du
            # dernier état de watch.py (fichier local, aucun appel réseau à
            # Blink ici) : une caméra durablement hors de portée peut n'avoir
            # jamais produit de clip et resterait sinon absente de la liste,
            # donc impossible à mettre en sourdine depuis cette page (cas
            # vécu : « Portail »).
            etat = md.load_json(watch.WATCH_STATE, {})
            surveillees = etat.get("cameras") or {}
            noms_connus = {str(e.get("name") or nom).strip().casefold()
                           for nom, e in surveillees.items()}
            historiques = {nom for nom in provenances(read_entries(self.paths))
                           if nom.strip().casefold() not in noms_connus}
            cameras = sorted(historiques | set(surveillees))
            self.send_json({"cameras": cameras, "ignored": sorted(
                watch.normaliser_sourdines(etat.get("ignored") or [], surveillees))})
            return

        if route == "/api/suppression-auto":
            # Les caméras proposées sont celles de l'activité en cours
            # (dernier état de watch.py), pas l'historique cumulé du
            # registre de téléchargement : une caméra dont la clé interne a
            # changé au fil du temps (Sync Module remplacé, network_id
            # absent des vieux clips USB - blinkpy 0.25 n'expose pas de
            # device_id sur l'USB) y apparaissait sinon en double, sous des
            # libellés internes illisibles pour l'utilisateur. La
            # résolution nom -> clé(s) (camera-v2-*, suppression_auto_choices
            # ci-dessus) reste un détail interne, jamais exposé ici.
            etat = md.load_json(watch.WATCH_STATE, {})
            surveillees = etat.get("cameras") or {}
            noms = sorted(surveillees, key=str.casefold)
            groupes = watch.regrouper_entrees_cameras(surveillees, read_entries(self.paths))
            actives_keys = suppression_auto_keys()
            cameras = []
            actives = []
            for nom in noms:
                cles = [c["key"] for c in suppression_auto_choices(groupes[nom])]
                cameras.append({"name": nom})
                if any(cle in actives_keys for cle in cles):
                    actives.append(nom)
            self.send_json({"cameras": cameras, "actives": actives})
            return

        if route == "/api/clips":
            # ?all=1 lève explicitement toute borne : l'historique complet
            # reste à un clic, jamais perdu, seulement pas chargé d'office
            # (voir DEFAULT_WINDOW_DAYS). ?preset=today|week|month|2months
            # couvre les boutons de découpage rapide de la page (RANGE_
            # PRESETS_HOURS) ; ?depuis/?jusqua (ISO, sans fuseau : lus dans le
            # fuseau réglé, comme l'horodatage de chaque clip) couvrent sa
            # plage personnalisée à l'heure près. Sans aucun paramètre, la
            # fenêtre reste celle d'avant ces boutons : les DEFAULT_WINDOW_DAYS
            # derniers jours, exactement ce que rend aussi ?preset=month.
            query = parse_qs(urlparse(self.path).query)
            depuis = jusqua = None
            if query.get("all", ["0"])[0] != "1":
                maintenant = dt.datetime.now(self.timezone)
                preset = (query.get("preset") or [""])[0]
                depuis_brut = (query.get("depuis") or [""])[0]
                jusqua_brut = (query.get("jusqua") or [""])[0]
                if preset in RANGE_PRESETS_HOURS:
                    depuis = maintenant - dt.timedelta(hours=RANGE_PRESETS_HOURS[preset])
                elif depuis_brut or jusqua_brut:
                    try:
                        if depuis_brut:
                            depuis = dt.datetime.fromisoformat(depuis_brut) \
                                .replace(tzinfo=self.timezone)
                        if jusqua_brut:
                            jusqua = dt.datetime.fromisoformat(jusqua_brut) \
                                .replace(tzinfo=self.timezone)
                    except ValueError:
                        self.send_json({"error": msg("plage_dates_invalide")}, 400)
                        return
                else:
                    depuis = maintenant - dt.timedelta(hours=RANGE_PRESETS_HOURS["month"])
            try:
                self.send_json(collect(self.paths, self.timezone, self.ffmpeg,
                                       depuis, jusqua))
            except RuntimeError as error:
                self.send_json({"error": str(error)}, 500)
            return

        if route.startswith("/thumb/"):
            target = unquote(route[len("/thumb/"):])
            source = self.resolve_media(target)
            if source is None:
                self.send_error(404)
                return
            self.send_thumb(target, source)
            return

        if route.startswith("/media/"):
            path = self.resolve_media(unquote(route[len("/media/"):]))
            if path is None:
                self.send_error(404)
                return
            self.send_media(path)
            return

        if route.startswith("/snapshot/"):
            path = self.resolve_snapshot(unquote(route[len("/snapshot/"):]))
            if path is None:
                self.send_error(404)
                return
            self.send_media(path)
            return

        if route == "/api/snapshots":
            self.send_json({"snapshots": self.lister_snapshots()})
            return

        if route == "/api/passages":
            # Le nombre de clips au registre accompagne les heures : la page le
            # compare à ce qu'elle affiche et signale l'écart, sans rien
            # redessiner de lui-même.
            # La version publiée est lue dans le cache, jamais chez GitHub : la
            # page ne doit pas attendre un serveur distant. Un fil de fond tient
            # ce cache à jour.
            self.send_json({"passages": runtime.passages(),
                            "clips": nombre_clips_connus(read_entries(self.paths)),
                            "maj": maj.disponible(reseau=False)})
            return

        if route == "/api/travail":
            # Sonde fréquente et volontairement minuscule : ne pas relire ici
            # tout le registre des clips comme /api/passages. Sur un vieux PC
            # Windows 7, parser ce JSON croissant toutes les trois secondes
            # coûterait bien plus que la lecture du seul état de progression.
            manquant = runtime.dossier_sorties_manquant()
            self.send_json({"travail": runtime.travail_affichable(),
                            "dossier_absent": str(manquant) if manquant else ""})
            return

        if route == "/api/refresh":
            if self.initial_setup:
                # Défense côté serveur, pas seulement dialogue modal : même
                # une requête manuelle ne peut rapatrier un clip avant la
                # validation du dossier et du fuseau.
                self.send_json({"error": msg("reglages_initiaux_requis")}, 409)
                return
            self.stream_refresh()
            return

        self.send_error(404)

    # ------------------------------------------------------- actualisation

    def send_event(self, payload: dict) -> bool:
        """Émet un événement SSE. Renvoie False si le navigateur a coupé."""
        body = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
        try:
            self.wfile.write(body.encode("utf-8"))
            self.wfile.flush()
        except (BrokenPipeError, ConnectionResetError):
            return False
        return True

    def stream_refresh(self) -> None:
        """Enchaîne téléchargement puis fusion, en poussant la sortie au fil de l'eau.

        Server-Sent Events plutôt qu'une réponse unique en fin de traitement :
        c'est le mécanisme du navigateur pour recevoir un flux de messages
        (une simple balise data:, pas de protocole à inventer), et il n'y a
        rien à installer côté page. Le sens du flux est unique, du serveur vers
        la page, ce qui suffit ici : la page n'a rien à répondre.

        Deux self_command distincts (« download » puis « merge »), pas un
        verbe unique qui les enchaînerait : ce sont les deux seules mains de
        l'outil, et les appeler l'un après l'autre dit exactement ce qui se
        passe. Voir run_refresh()."""
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream; charset=utf-8")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "close")
        self.end_headers()

        if not self.lock.acquire(blocking=False):
            self.send_event({"line": msg("actualisation_en_cours")})
            self.send_event({"done": True, "ok": False})
            return
        # Un calcul lancé par les boucles de fond occupe déjà ffmpeg et le
        # registre. En lancer un second ne ferait qu'attendre le verrou pendant
        # de longues minutes, en donnant l'impression d'un blocage.
        en_cours = runtime.travail_en_cours()
        if en_cours:
            self.send_event({"line": msg(
                "calcul_en_cours", quoi=en_cours.get("quoi") or msg("un_calcul"))})
            self.send_event({"done": True, "ok": False})
            self.lock.release()
            return
        # La page renouvelle les vignettes à la fin de cette actualisation.
        try:
            # Le téléchargement interroge le Sync Module, comme le direct. Les
            # laisser se chevaucher garantirait un « System is busy » : mieux
            # vaut le dire tout de suite que d'échouer au milieu.
            if not MODULE_SLOT.acquire(blocking=False):
                self.send_event({"line": _slot_occupe_message()})
                self.send_event({"done": True, "ok": False})
                return
            _slot_pris("actualisation")
            try:
                # Le sous-processus ``download`` prend lui-même le verrou de
                # fichier du hub pendant son inventaire puis ses transferts.
                # Le prendre aussi ici, dans le parent, le rendait impossible à
                # réacquérir : chaque clic « Actualiser » sautait alors toute la
                # partie USB comme si un autre programme occupait le module.
                # MODULE_SLOT reste la protection mémoire contre un direct lancé
                # par ce serveur ; le verrou enfant couvre les autres processus.
                self.run_refresh()
            except blink_engine.BusyError as error:
                self.send_event({"line": msg("module_occupe_detail", erreur=error)})
                self.send_event({"done": True, "ok": False})
            finally:
                _slot_rendu()
                MODULE_SLOT.release()
        finally:
            self.lock.release()

    def run_refresh(self) -> None:
        """Télécharge puis assemble, en poussant la sortie des deux au fil de l'eau.

        Deux programmes enchaînés ici plutôt qu'un verbe qui les enchaînerait :
        « download » et « merge » sont les deux seules mains de l'outil, et les
        appeler l'un après l'autre dit exactement ce qui se passe."""
        auth = BASE_DIR / "blink_auth.json"
        hub_args = ["--hub", self.hub] if self.hub else []
        reglages = runtime.lire_reglages()
        # Mêmes drapeaux que runtime.standard() pour la boucle merge : un clic
        # sur Actualiser doit produire ce que produirait la boucle automatique
        # à son prochain tour, pas une horodatée/hebdomadaire/mensuelle par
        # défaut qui ignore la page Réglages. Comme dans standard(),
        # merge_jour est l'interrupteur général de l'assemblage : Actualiser
        # ne doit pas le réactiver implicitement.
        options_merge = runtime.options_fusion(reglages)
        etapes = [(msg("etape_telechargement"), "phase.step_download",
                  runtime.self_command("download", *hub_args))]
        if reglages["merge_jour"]:
            etapes.append((msg("etape_fusion"), "phase.step_merge",
                           runtime.self_command("merge", *options_merge)))
        if not auth.is_file():
            # Le téléchargement demanderait l'e-mail, le mot de passe et le code
            # de vérification sur l'entrée standard, qui n'existe pas ici : le
            # processus resterait bloqué. On le dit et on se contente
            # d'assembler ce qui est déjà là.
            self.send_event({"line": (
                msg("session_absente_login", fichier=auth.name) + " "
                + msg("reconstruction_seule" if reglages["merge_jour"]
                      else "aucun_traitement_fusion_off")
            )})
            etapes = etapes[1:]

        # PYTHONUNBUFFERED : sans lui, la sortie arriverait par blocs de
        # plusieurs kilo-octets et la barre avancerait par à-coups.
        # PYTHONIOENCODING évite les accents mutilés par la console Windows.
        env = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
        for phase, cle, command in etapes:
            self.send_event({"phase": phase, "phase_key": cle, "line": f"$ {phase.lower()}"})
            if not self.suivre(command, env, phase):
                return
        self.send_event({"done": True, "ok": True})

    def suivre(self, command: list, env: dict, phase: str) -> bool:
        """Fait avancer la barre au fil des lignes. Faux si on doit s'arrêter."""
        process = runtime.demarrer(
            command, cwd=str(BASE_DIR),
            stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT, text=True,
            encoding="utf-8", errors="replace", bufsize=1, env=env,
        )
        # Chien de garde par silence, même seuil et même raison que
        # run_ffmpeg_batch/concat_copy (merge_daily.py, AUDIT-2026-08-13
        # 28.82) : download comme merge peuvent rester silencieux longtemps
        # (réseau Blink qui ne répond plus, ffmpeg bloqué en sous-main) sans
        # qu'aucune ligne n'arrive sur stdout, ce qui figerait
        # indéfiniment la boucle ci-dessous et garderait self.lock pris pour
        # toute future Actualisation.
        dernier_signe = time.monotonic()
        signe_verrou = threading.Lock()
        termine = threading.Event()
        tue_par_silence = False

        def surveiller() -> None:
            nonlocal tue_par_silence
            while not termine.wait(10):
                with signe_verrou:
                    silence = time.monotonic() - dernier_signe
                if silence > md.SILENCE_MAX:
                    tue_par_silence = True
                    process.kill()
                    return

        chien_de_garde = threading.Thread(target=surveiller, daemon=True)
        chien_de_garde.start()
        try:
            for raw in process.stdout:
                with signe_verrou:
                    dernier_signe = time.monotonic()
                line = raw.rstrip("\n")
                event = {"line": line}
                # Les deux programmes annoncent leur avancement sous la forme
                # « [3/24] ». Une seule règle de lecture suffit, et chaque phase
                # repart naturellement de 1.
                counter = PROGRESS.search(line)
                if counter:
                    index, total = int(counter.group(1)), int(counter.group(2))
                    inner = INNER.match(line)
                    fraction = int(inner.group(1)) / 100 if inner else 0.0
                    if inner:
                        event.pop("line")
                    event["progress"] = {
                        "done": round(index - 1 + fraction, 3), "total": total
                    }
                heading = HEADING.match(line)
                if heading:
                    titre = heading.group(1).strip()
                    event["phase"] = titre.capitalize() if titre.isupper() else titre
                    # Ces deux titres sont les seuls que blink_engine.py émette
                    # sous cette forme (voir traiter_cloud/un_passage) : une clé
                    # stable permet à la page de les traduire, le nom du hub
                    # (donnée de l'utilisateur, jamais traduisible) passant à part.
                    hub_usb = _hub_section_usb(titre)
                    if titre in _TITRES_CLOUD:
                        event["phase_key"] = "phase.cloud_section"
                    elif hub_usb is not None:
                        event["phase_key"] = "phase.usb_section"
                        event["phase_hub"] = hub_usb
                if not self.send_event(event):
                    process.terminate()
                    process.wait()
                    return False
        finally:
            termine.set()
            chien_de_garde.join(timeout=5)
        process.wait()
        if tue_par_silence:
            self.send_event({"line": msg("phase_silencieuse", phase=phase,
                                         secondes=int(md.SILENCE_MAX))})
            self.send_event({"done": True, "ok": False})
            return False
        if process.returncode != 0:
            self.send_event({"line": msg("phase_echec", phase=phase, code=process.returncode)})
            self.send_event({"done": True, "ok": False})
            return False
        return True

    def _post_reglages(self, payload: dict) -> None:
        """Valide les réglages, publie le stockage puis organise la relance."""
        try:
            dossier, reglages = _preparer_reglages_web(payload)
        except _ReglagesInvalides as erreur:
            self.send_json({"error": str(erreur)}, 400)
            return

        try:
            # Le verrou couvre toute la transaction : préparation de la
            # destination, publication du pointeur et éventuel marqueur initial.
            with runtime.verrou_configuration():
                runtime.ecrire_dossier_stockage(
                    dossier, reglages=reglages, configuration_initiale=self.initial_setup)
        except runtime.BusyError as erreur:
            self.send_json(
                {"error": msg("reglages_deja_en_cours", erreur=erreur)},
                409)
            return
        except OSError as erreur:
            self.send_json(
                {"error": msg("reglages_echec", erreur=erreur)}, 500)
            return
        if self.initial_setup:
            # Le parent start détient encore le verrou de démarrage : il voit
            # le marqueur et remplace lui-même le serveur initial après réponse.
            self.send_json({"ok": True, "accepted": True,
                            "initial_setup": True})
            return
        # La relance relira les réglages ; son relais laisse partir la réponse
        # HTTP avant d'arrêter ce serveur.
        self.repondre_puis_redemarrer(["restart"])

    def _post_sourdine(self, payload: dict) -> None:
        """Délègue la sourdine à watch, sans redémarrer le serveur."""
        camera = str(payload.get("camera", "")).strip()
        ignored = bool(payload.get("ignored"))
        if not camera:
            self.send_json({"error": msg("camera_nom_manquant")}, 400)
            return
        # watch relit son état à chaque passage de sa propre boucle
        # (voir _controler) : contrairement aux autres réglages, la
        # sourdine n'a pas besoin de redémarrage, seulement de laisser
        # « watch --ignore/--unignore » écrire le fichier partagé. Fait
        # ici en tâche de fond pour que le clic reste immédiat, sur le
        # même principe que le bouton Écarter (28.33).
        option = "--ignore" if ignored else "--unignore"

        def travailler():
            runtime.lancer(
                runtime.self_command("watch", option, camera),
                cwd=str(runtime.app_dir()), stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
            )

        threading.Thread(target=travailler, daemon=True).start()
        self.send_json({"ok": True})
        return

    def _post_suppression_auto(self, payload: dict) -> None:
        """Modifie les autorisations sous le verrou de configuration."""
        camera = str(payload.get("camera", "")).strip()
        actif = bool(payload.get("actif"))
        try:
            # Sérialiser lecture ET modification, y compris avec une
            # bascule de stockage : replace seul perd les clics concurrents.
            with runtime.verrou_configuration("suppression-auto", attente=5):
                etat = md.load_json(watch.WATCH_STATE, {})
                surveillees = etat.get("cameras") or {}
                libelle = next((nom for nom in surveillees
                                if nom.strip().casefold() == camera.casefold()), None)
                if not camera or libelle is None:
                    self.send_json({"error": msg("camera_inconnue")}, 400)
                    return
                # Le nom affiché résout les identités internes persistées.
                cles = {
                    choice["key"] for choice in suppression_auto_choices(
                        watch.camera_entries(libelle, surveillees, read_entries(self.paths)))
                }
                cameras = suppression_auto_keys()
                if actif:
                    cameras |= cles
                else:
                    cameras -= cles
                runtime.ecrire_suppression_auto(cameras)
        except runtime.BusyError as erreur:
            self.send_json({"error": msg("reglages_deja_en_cours", erreur=erreur)}, 409)
            return
        except OSError as erreur:
            self.send_json({"error": msg("suppression_auto_echec", erreur=erreur)}, 500)
            return
        self.send_json({"ok": True})
        return

    def _post_appliquer_selection(self, payload: dict) -> None:
        """Valide le lot, lance les exclusions, puis applique les suppressions."""
        try:
            selection = _lire_selection_videos(payload)
            racine_direct = self.paths.get("direct")
            clips, directs, chemins_directs = _separer_clips_et_directs(
                selection, racine_direct)
        except (ValueError, OSError) as erreur:
            self.send_json({"error": str(erreur)}, 400)
            return

        # Le même instantané sert aux suppressions et au travail différé.
        entrees = read_entries(self.paths)
        if clips.exclure or clips.inclure:
            # Seules les exclusions/reconstructions sont asynchrones : la
            # réponse HTTP n'attend pas un verrou de registre déjà occupé.
            threading.Thread(
                target=self._appliquer_exclusions_clips,
                args=(clips, entrees), daemon=True,
            ).start()

        resultats = {}
        if clips.supprimer:
            self._supprimer_sources_blink(clips.supprimer, entrees, resultats)
        if directs.exclure or directs.inclure or directs.supprimer:
            self._appliquer_selection_directs(
                directs, racine_direct, chemins_directs, resultats)
        self.send_json({"ok": True, "resultats": resultats})

    def _appliquer_exclusions_clips(self, selection: _SelectionVideos, entrees: dict) -> None:
        """Travail de fond : écarter/réintégrer, puis reconstruire si autorisé."""
        # Le registre est partagé : sérialiser les deux actions et conserver
        # leur ordre. Une réintégration l'emporte sur une exclusion du même clip.
        with REGISTRE:
            for identites, exclu in ((selection.exclure, True), (selection.inclure, False)):
                if not identites:
                    continue
                try:
                    md.set_excluded(
                        self.paths["input"], self.paths["normalized"],
                        self.paths["excluded"],
                        [str(self.paths["input"] / identite) for identite in identites], exclu)
                except RuntimeError as erreur:
                    print(msg("ecarter_lot_erreur", erreur=erreur))

        reglages = runtime.lire_reglages()
        # Décocher Quotidienne interdit aussi les reconstructions déclenchées
        # par une sélection. Les réglages sont relus après l'écriture du registre.
        if not reglages["merge_jour"]:
            return
        jours = _jours_selection_a_reconstruire(
            selection.exclure + selection.inclure, entrees, reglages)
        options = runtime.options_fusion(reglages)
        # Même fuseau que le calcul des jours, et jamais deux assemblages
        # simultanés susceptibles d'écrire la même vidéo.
        with REASSEMBLAGE:
            for camera, jour in jours:
                runtime.lancer(
                    runtime.self_command("merge", "--camera", camera, "--date", jour, *options),
                    cwd=str(runtime.app_dir()), stdin=subprocess.DEVNULL,
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
                )

    def _supprimer_sources_blink(self, identites: list[str], entrees: dict,
                                resultats: dict) -> None:
        """Supprime un lot sous les verrous Blink, puis met à jour le registre."""
        cibles = _preparer_suppressions_blink(identites, entrees, resultats)
        nb_cameras_usb = len({
            str(cible.entree.get("camera") or "").strip() for cible in cibles
            if str(cible.entree.get("source") or "usb") != "cloud"
        })
        ids_presents_par_module = {}
        evenements_presents_par_module = {}

        slot_pris = False
        try:
            # Ressource partagée avec le direct et le téléchargement : une
            # suppression concurrente risquerait un manifeste ou un lot partiel.
            if not MODULE_SLOT.acquire(blocking=False):
                raise blink_engine.BusyError(_slot_occupe_message())
            slot_pris = True
            _slot_pris("suppression manuelle")
            with blink_engine.hub_lock("suppression manuelle"):
                BLINK.call(
                    lambda blink: self._supprimer_clips_blink(
                        blink, cibles, resultats, ids_presents_par_module,
                        evenements_presents_par_module),
                    timeout=30 + 90 * max(1, nb_cameras_usb),
                )
        except Exception as erreur:
            for cible in cibles:
                resultats.setdefault(cible.identite, f"echec: {type(erreur).__name__}")
        finally:
            if slot_pris:
                _slot_rendu()
                MODULE_SLOT.release()

        self._marquer_sources_supprimees(
            resultats, ids_presents_par_module, evenements_presents_par_module)

    async def _supprimer_clips_blink(self, blink, cibles: list[_CibleSuppressionBlink],
                                   resultats: dict, ids_presents_par_module: dict,
                                   evenements_presents_par_module: dict | None = None,
                                   ) -> None:
        """Exécute les appels distants, avec un seul manifeste par Sync Module.

        ``evenements_presents_par_module`` reçoit, par module, les couples
        (caméra, instant) encore présents sur son stockage : ceux du manifeste
        lu, moins ceux que ce lot vient de supprimer avec succès."""
        if evenements_presents_par_module is None:
            evenements_presents_par_module = {}
        manifestes = {}
        for cible in cibles:
            entree = cible.entree
            source = str(entree.get("source") or "usb")
            camera = str(entree.get("camera") or "").strip()
            try:
                if source == "cloud":
                    clip = blink_models.CloudClip({
                        "id": int(cible.id_distant), "device_name": camera,
                        "created_at": entree.get("created_at"),
                    })
                    resultats[cible.identite] = (
                        "supprime" if await clip.delete_video(blink) else "echec")
                    continue

                sync = BLINK.find_sync_module(blink, entree)
                cle_module = id(sync)
                cle_sync = str(getattr(sync, "sync_id", ""))
                if cle_module not in manifestes:
                    manifestes[cle_module] = await blink_models.read_local_manifest(sync)
                    ids_presents_par_module[cle_sync] = {
                        str(clip.id) for clip in manifestes[cle_module]}
                    evenements = {}
                    for clip in manifestes[cle_module]:
                        try:
                            evenements[id(clip)] = (
                                str(clip.name).strip().casefold(),
                                blink_models.clip_datetime_utc(clip))
                        except (AttributeError, TypeError, ValueError):
                            continue
                    evenements_presents_par_module[cle_sync] = evenements

                # Les numéros USB peuvent être réattribués après réindexation.
                # Retrouver le clip par caméra/date ; une ambiguïté interdit
                # toute suppression, même si l'ancien numéro semble valide.
                try:
                    attendu = md.parse_created_at(str(entree.get("created_at") or ""))
                except (ValueError, TypeError):
                    attendu = None
                candidats = [] if attendu is None else [
                    clip for clip in manifestes[cle_module]
                    if str(clip.name).casefold() == camera.casefold()
                    and abs((blink_models.clip_datetime_utc(clip)
                             - attendu).total_seconds()) <= 2
                ]
                if not candidats:
                    resultats[cible.identite] = "deja_absent"
                elif len(candidats) > 1:
                    resultats[cible.identite] = "ambigu"
                else:
                    supprime = await candidats[0].delete_video(blink)
                    if supprime:
                        evenements_presents_par_module.get(cle_sync, {}).pop(
                            id(candidats[0]), None)
                    resultats[cible.identite] = "supprime" if supprime else "echec"
            except Exception as erreur:
                resultats[cible.identite] = f"echec: {type(erreur).__name__}"

    @staticmethod
    def _evenement_encore_present(entree: dict, evenements) -> bool:
        """Vrai si le manifeste montre encore ce clip par caméra et instant.

        Même rapprochement que la suppression elle-même (caméra, ±2 s) : le
        numéro USB seul ne prouve pas une absence, le Sync Module le
        renumérote lors d'une réindexation."""
        if not evenements:
            return False
        camera = str(entree.get("camera") or "").strip().casefold()
        try:
            attendu = md.parse_created_at(str(entree.get("created_at") or ""))
        except (TypeError, ValueError):
            return False
        return any(
            nom == camera and abs((instant - attendu).total_seconds()) <= 2
            for nom, instant in list(evenements.values())
        )

    def _marquer_sources_supprimees(self, resultats: dict,
                                   ids_presents_par_module: dict,
                                   evenements_presents_par_module: dict | None = None,
                                   ) -> None:
        """Mémorise les absences constatées pour éviter de redemander une suppression."""
        evenements_presents_par_module = evenements_presents_par_module or {}
        marques = {identite for identite, statut in resultats.items()
                   if statut in ("supprime", "deja_absent")}
        if not marques and not ids_presents_par_module:
            return

        etat = blink_registre.load_download_state(self.paths["input"])
        for entree in etat["clips"].values():
            if not isinstance(entree, dict) or entree.get("source_deleted"):
                continue
            if entree.get("path") in marques:
                entree["source_deleted"] = True
                continue
            # La lecture du manifeste renseigne aussi les autres clips USB du
            # même module, sans requête réseau supplémentaire.
            cle_sync = str(entree.get("sync_id") or "")
            ids_presents = ids_presents_par_module.get(cle_sync)
            if ids_presents is None or str(entree.get("source") or "usb") != "usb":
                continue
            correspondance = re.search(
                r"_(\d+)_[0-9a-f]{12}\.mp4$", str(entree.get("path") or ""))
            id_connu = correspondance.group(1) if correspondance else str(
                entree.get("remote_id") or "")
            # Absent par son numéro ET par caméra/instant : un numéro absent
            # seul marquait à tort tout clip encore présent mais renuméroté,
            # ce qui masquait définitivement sa case « Supprimer » dans la
            # galerie (audit du 2026-09-24). Un faux négatif reste bénin : la
            # prochaine demande répond « deja_absent » et marque alors.
            if (id_connu and id_connu not in ids_presents
                    and not self._evenement_encore_present(
                        entree, evenements_presents_par_module.get(cle_sync))):
                entree["source_deleted"] = True
        blink_registre.save_download_state(self.paths["input"], etat)

    def _appliquer_selection_directs(self, selection: _SelectionVideos, racine_direct: Path,
                                    chemins_directs: dict, resultats: dict) -> None:
        """Modifie uniquement les fichiers directs et leurs exclusions locales."""
        # Aucun registre de clips, appel Blink ou réassemblage pour ces fichiers.
        # Lecture-modification-écriture sous verrou : deux sélections
        # simultanées (double-clic, deux onglets) relisaient la même liste et
        # la dernière écriture effaçait l'autre (39 exclusions perdues sur 40
        # lancées ensemble, mesuré le 2026-09-24).
        with _VERROU_EXCLUSION_DIRECTE:
            exclusion_directe = _lire_exclusion_directe(self.paths)
            exclusion_directe |= set(selection.exclure)
            exclusion_directe -= set(selection.inclure)
            for identite in selection.supprimer:
                try:
                    chemin = _chemin_direct_confine(racine_direct, identite)
                    if chemin != chemins_directs[identite]:
                        raise ValueError(msg("video_chemin_modifie"))
                    chemin.unlink()
                    resultats[identite] = "supprime"
                    exclusion_directe.discard(identite)
                except FileNotFoundError:
                    resultats[identite] = "deja_absent"
                except (OSError, ValueError) as erreur:
                    resultats[identite] = f"echec: {type(erreur).__name__}"
                    continue
                # Nettoyage cosmétique des deux parents, seulement s'ils sont
                # vides. Ne jamais remonter à la racine du stockage des directs
                # ou au-delà.
                for dossier in (chemin.parent, chemin.parent.parent):
                    if (dossier == racine_direct.resolve()
                            or not runtime.est_relatif_a(dossier, racine_direct.resolve())):
                        break
                    try:
                        dossier.rmdir()
                    except OSError:
                        break
            _ecrire_exclusion_directe(self.paths, exclusion_directe)

    # Au-delà, le corps d'une requête refusée n'est pas lu : la connexion est
    # fermée sans lui, un envoi abusif ne doit pas occuper le serveur.
    _CORPS_REFUSE_MAX = 1 << 20

    def _refuser(self, code: int) -> None:
        """Répond une erreur à un POST après avoir lu son corps, sans jamais
        l'interpréter. Fermée avec des octets non lus, la connexion part en RST
        sous Windows et le client perd la réponse (WinError 10053) : 25 refus
        de jeton perdus sur 300 POST avec corps, mesuré le 2026-09-26, soit la
        page qui garde un jeton périmé après un redémarrage. Lecture bornée
        et octets jetés : l'audit qui voulait refuser « avant lecture »
        cherchait à ne rien traiter d'un client non authentifié, ce qui reste
        vrai. Même correctif que les serveurs de lidar2map et watch2notif."""
        try:
            longueur = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            longueur = 0
        if 0 < longueur <= self._CORPS_REFUSE_MAX:
            self.rfile.read(longueur)
        self.send_error(code)

    def do_POST(self):
        # Meme raison que do_GET : un appelant externe (script de domotique)
        # n'a ni l'origine ni le jeton de session, le secret du webhook suffit.
        try:
            route_webhook = urlparse(self.path).path
        except ValueError:
            route_webhook = ""
        if route_webhook == WEBHOOK_ARM_ROUTE:
            self.gerer_webhook_arm()
            return
        if not self.hote_autorise() or not self.jeton_valide():
            self._refuser(403)
            return
        # Même filet que do_GET : après hote_autorise()/jeton_valide() ici,
        # donc seulement atteignable par un client déjà de confiance, mais
        # une requête malformée ne doit pas non plus planter la sienne.
        try:
            route = urlparse(self.path).path
        except ValueError:
            self._refuser(400)
            return
        try:
            length = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            self.send_error(400)
            return
        if length < 0:
            # rfile.read(-1) lirait jusqu'à la fermeture de la connexion,
            # qu'une connexion HTTP/1.1 persistante ne ferme jamais d'elle-même :
            # le thread de la requête restait bloqué (audit du 2026-09-24).
            self.send_error(400)
            return
        try:
            payload = json.loads(self.rfile.read(length) or b"{}")
        except ValueError:  # JSONDecodeError, ou UnicodeDecodeError (octets non UTF-8)
            self.send_json({"error": "corps JSON illisible"}, 400)
            return
        if not isinstance(payload, dict):
            # Toutes les routes lisent payload.get(...) : une liste ou un
            # nombre JSON valides levaient AttributeError, sans réponse propre.
            self.send_json({"error": "corps JSON : objet attendu"}, 400)
            return

        if route.startswith("/live-webrtc/"):
            self.send_offer_webrtc(unquote(route[len("/live-webrtc/"):]), payload)
            return

        if route.startswith("/api/direct-signal/"):
            # Seul maillon de toute la chaine que le serveur ne peut pas
            # observer lui-meme : le decodage/affichage reel dans le
            # <video> du navigateur. connectionstatechange (blink_webrtc.py)
            # dit que la negociation a abouti, jamais qu'une image est
            # effectivement arrivee a l'ecran - watchWebRTC()/watchMse()
            # (serve_app.js) le savent, eux, via loadeddata/playing ou
            # l'abandon apres reprises. Best-effort, jamais bloquant : un
            # signal perdu ne doit jamais faire echouer le direct lui-meme,
            # seul direct.log en patit.
            nom = unquote(route[len("/api/direct-signal/"):])
            evenement = str(payload.get("evenement") or "signal").strip()
            detail = str(payload.get("detail") or "").strip()
            suffixe = f" : {detail}" if detail else ""
            session_id = str(payload.get("session_id") or "")
            session = f"session {session_id[:8]}, " if DIRECT_SESSION_ID.fullmatch(session_id) else ""
            _journal_direct(nom, f"{session}navigateur : {evenement}{suffixe}")
            self.send_json({"ok": True})
            return

        if route == "/api/login":
            username = str(payload.get("username", "")).strip()
            password = str(payload.get("password", ""))
            if not username or not password:
                self.send_json({"status": "error",
                                "message": msg("identifiants_requis")})
                return
            self.send_json(self.login_flow.start(username, password))
            return

        if route == "/api/2fa":
            code = str(payload.get("code", "")).strip()
            if not code:
                self.send_json({"status": "error", "message": msg("code_requis")})
                return
            self.send_json(self.login_flow.submit_code(code))
            return

        if route == "/api/arm":
            name = str(payload.get("name", "")).strip()
            armed = bool(payload.get("armed"))
            scope = str(payload.get("scope", "camera"))
            try:
                self.set_armed(scope, name, armed)
                self.send_json(self.system_state())
            except RuntimeError as error:
                self.send_json({"error": str(error)}, 503)
            return

        if route == "/api/reveiller":
            name = str(payload.get("name", "")).strip()
            try:
                self.reveiller_camera(name)
                self.send_json(self.system_state())
            except blink_engine.BusyError as error:
                self.send_json({"error": str(error)}, 409)
            except Exception as error:
                # Delai large (jusqu'a 130 s, voir reveiller_camera) : outre
                # RuntimeError (camera inconnue), un timeout cote blinkpy ou
                # un refus reseau doivent aussi remonter un message lisible
                # plutot qu'une erreur 500 muette.
                self.send_json({"error": f"{type(error).__name__}: {error}"}, 503)
            return

        if route == "/api/snapshot":
            name = str(payload.get("name", "")).strip()
            try:
                chemin = self.declencher_snapshot(name)
                relatif = chemin.relative_to(self.paths["snapshots"]).as_posix()
                self.send_json({"ok": True, "fichier": relatif})
            except blink_engine.BusyError as error:
                self.send_json({"error": str(error)}, 409)
            except Exception as error:
                self.send_json({"error": f"{type(error).__name__}: {error}"}, 503)
            return

        if route == "/api/snapshot-supprimer":
            relatif = str(payload.get("fichier", ""))
            chemin = self.resolve_snapshot(relatif)
            if chemin is None:
                self.send_error(404)
                return
            chemin.unlink(missing_ok=True)
            self.send_json({"ok": True})
            return

        if route == "/api/webhook-regenerer":
            self.send_json({"token": runtime.regenerer_jeton_webhook()})
            return

        if route == "/api/direct-enregistrement":
            session_id = str(payload.get("session_id") or "")
            with DIRECT_WEBRTC_SESSION_LOCK:
                holder = DIRECT_WEBRTC_SESSION.get("session")
                mse_holder = DIRECT_MSE_SESSION.get("session")
                autorise = (
                    holder is not None and holder["session_id"] == session_id
                    and not holder["arret"].is_set() and not holder["rendu"]
                ) or (
                    mse_holder is not None
                    and (not session_id or mse_holder["session_id"] == session_id)
                    and not mse_holder["arret"].is_set()
                ) or (
                    holder is None and mse_holder is None and not session_id
                    and MODULE_SLOT_INFO.get("quoi") == "direct MSE"
                )
                if autorise:
                    if bool(payload.get("actif")):
                        ENREGISTREMENT_DIRECT_ACTIF.set()
                    else:
                        ENREGISTREMENT_DIRECT_ACTIF.clear()
            if not autorise:
                self.send_json({"error": msg("direct_inactif")}, 409)
                return
            self.send_json({"actif": ENREGISTREMENT_DIRECT_ACTIF.is_set()})
            return

        if route == "/api/arreter-direct":
            session_id = str(payload.get("session_id") or "")
            if not DIRECT_SESSION_ID.fullmatch(session_id):
                self.send_json({"error": msg("session_requise")}, 400)
                return
            arrete = _demander_arret_direct(session_id)
            if arrete:
                _journal_direct("arreter-direct", msg("fermeture_demandee", session=session_id[:8]))
            # L'attente éventuelle du polling Blink se poursuit sur sa boucle.
            # /api/attente-module reste la confirmation de libération réelle.
            self.send_json({"ok": True, "stopped": arrete, "session_id": session_id})
            return

        if route == "/api/maj/verifier":
            # Bouton « Vérifier les mises à jour » des réglages : GitHub tout
            # de suite, sans attendre le fil de fond (une heure au plus, voir
            # maj.FRAICHEUR). « ok » faux : GitHub n'a pas répondu, le cache
            # reste tel quel.
            neuve, ok = maj.verifier_maintenant()
            self.send_json({"maj": neuve, "ok": ok, "version": runtime.VERSION})
            return

        if route == "/api/update":
            neuve = maj.disponible(reseau=False)
            if not neuve:
                self.send_json({"error": msg("aucune_version")}, 409)
                return
            if not runtime.effacer_conclusion_travail("phase.update_noop"):
                self.send_json({"error": msg("conclusion_maj_occupee")}, 409)
                return
            # Détaché, et volontairement sans attendre : ce processus fait
            # partie de ce que la mise à jour va arrêter. Elle rend compte dans
            # maj.log, et la page attend simplement le retour du serveur.
            runtime.demarrer(
                runtime.self_command("update"), cwd=str(runtime.app_dir()),
                stdin=subprocess.DEVNULL,
                stdout=(runtime.app_dir() / "maj.log").open("ab"),
                stderr=subprocess.STDOUT,
                start_new_session=(os.name != "nt"))
            self.send_json({"ok": True, "version": neuve["version"]})
            return

        if route == "/api/appliquer-selection":
            return self._post_appliquer_selection(payload)

        if route == "/api/autostart":
            code = autostart.appliquer("on" if payload.get("actif") else "off")
            if code != 0:
                self.send_json(
                    {"error": msg("autostart_echec")}, 500)
                return
            # Relu plutôt que renvoyé tel quel : si le mécanisme de la
            # plateforme n'a pas vraiment pris (droits, service absent...),
            # l'interface montre l'état réel, pas ce qui a juste été demandé.
            self.send_json({"actif": autostart.est_installe()})
            return

        if route == "/api/reglages":
            return self._post_reglages(payload)

        if route == "/api/sourdine":
            return self._post_sourdine(payload)

        if route == "/api/suppression-auto":
            return self._post_suppression_auto(payload)

        if route == "/api/stop":
            # --sans-relance pour que « restart » s'arrête sans revenir,
            # exactement ce qu'un bouton Stop doit faire.
            self.repondre_puis_redemarrer(["restart", "--sans-relance"])
            return

        if route == "/api/redemarrer":
            # Même appel que la fin de _post_reglages(), sans passer par la
            # validation ni l'enregistrement : un redémarrage tout court,
            # pour reprendre un code fraîchement modifié ou sortir d'un état
            # bloqué, sans avoir à retoucher un réglage pour l'obtenir.
            self.repondre_puis_redemarrer(["restart"])
            return

        if route == "/api/lang":
            runtime.ecrire_langue(str(payload.get("lang", "")))
            self.send_json({"ok": True})
            return

        self.send_error(404)


PAGE = """<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITRE_PAGE__</title>
<link rel="icon" href="__FAVICON__">
<style>
__CSS__
</style>
</head>
<body>
<header>
  <h1>__ENTETE__<span class="v">__VERSION__ (<span id="pidServeur"></span>)</span></h1>
  <select id="view">
    <option value="live" data-i18n="view.live">Directs Vues</option>
    <option value="direct" data-i18n="view.direct">Directs Enregistrements</option>
    <option value="clips" data-i18n="view.clips">Détections Enregistrements</option>
    <option value="daily" data-i18n="view.daily">Détections Journalières</option>
    <option value="weekly" data-i18n="view.weekly">Détections Hebdomadaires</option>
    <option value="monthly" data-i18n="view.monthly">Détections Mensuelles</option>
    <option value="pictures" data-i18n="view.pictures">Photos</option>
  </select>
  <button id="filtreButton" data-i18n="filtre.button" data-i18n-title="filtre.button.title"
          title="Filtrer">🔍 Filtre</button>
  <button id="rondeButton" data-i18n="ronde.button" data-i18n-title="ronde.button.title"
          title="Les directs en plein écran, une caméra après l'autre" hidden>▶ Ronde…</button>
  <span class="filtreResume" id="filtreResume"></span>
  <span class="filtreResume" id="filtreCompte"></span>
  <span class="count" id="count"></span>
  <!-- Tout ce qui concerne la mise à jour tient ensemble, à droite : l'heure du
       dernier passage, la coche qui recharge seule, et le bouton. -->
  <div class="maj">
    <button id="update" hidden></button>
    <span class="sub tiny" id="passages"></span>
    <button class="danger" id="applyButton" hidden></button>
    <button class="primary" id="refresh" data-i18n="btn.refresh">↻ Actualiser</button>
    <button id="reglagesButton" data-i18n="btn.reglages" data-i18n-title="btn.reglages.title" title="Réglages">⚙ Réglages…</button>
  </div>
  <span class="langGroup" title="Langue / Language">
    <button class="btn-lang" data-lang-btn="fr">FR</button>
    <button class="btn-lang" data-lang-btn="en">EN</button>
  </span>
  <div id="work"><span id="phase"></span><progress id="bar"></progress></div>
  <p id="dossierAbsent" role="alert" hidden></p>
</header>
<main><div id="list"></div><pre id="log"></pre></main>

<dialog id="auth">
  <!-- Boîte modale : le fond assombri masque et rend inatteignables les
       boutons FR/EN de l'en-tête tant qu'elle est ouverte (showModal()).
       Doublon minimal ici, seul moyen de changer de langue avant de se
       connecter. -->
  <span class="langGroup langGroupAuth" title="Langue / Language">
    <button class="btn-lang" data-lang-btn="fr">FR</button>
    <button class="btn-lang" data-lang-btn="en">EN</button>
  </span>
  <h3 id="authTitle" data-i18n="auth.title">Connexion Blink</h3>
  <p id="authHint" data-i18n="auth.hint">Le mot de passe sert uniquement à ouvrir la session ; seuls
     les jetons sont enregistrés, jamais le mot de passe.</p>
  <p id="authError"></p>
  <div id="authCreds">
    <input id="user" type="email" data-i18n-placeholder="auth.email" placeholder="Adresse e-mail" autocomplete="username">
    <div class="champMdp">
      <input id="pass" type="password" data-i18n-placeholder="auth.password" placeholder="Mot de passe"
             autocomplete="current-password">
      <button type="button" id="passToggle" data-i18n="auth.show"
              aria-label="Afficher le mot de passe">Afficher</button>
    </div>
  </div>
  <div id="authCode" hidden>
    <input id="code" inputmode="numeric" data-i18n-placeholder="auth.code" placeholder="Code reçu par SMS ou e-mail"
           autocomplete="one-time-code">
  </div>
  <div class="row">
    <button id="authCancel" data-i18n="auth.cancel">Annuler</button>
    <button class="primary" id="authOk" data-i18n="auth.ok">Se connecter</button>
  </div>
</dialog>

<dialog id="reglages">
  <h3 data-i18n="reglages.title">Réglages</h3>
  <p id="initialSetupHint" class="sub" hidden data-i18n="reglages.initial.hint">
    Vérifiez notamment le dossier des données et le fuseau horaire. Aucun clip
    ne sera téléchargé avant que vous ayez appliqué ces réglages.
  </p>
  <div class="tabsReglages" role="tablist">
    <button type="button" id="tabGeneral" class="tabReglages"
            data-i18n="reglages.tab.general">Général</button>
    <button type="button" id="tabVideo" class="tabReglages"
            data-i18n="reglages.tab.video">Vidéo</button>
    <button type="button" id="tabAcces" class="tabReglages"
            data-i18n="reglages.tab.acces">Accès et automatisation</button>
    <button type="button" id="tabAlertes" class="tabReglages"
            data-i18n="reglages.tab.alertes">Alertes</button>
  </div>
  <div id="panelGeneral" class="panelReglages">
    <label id="autostartLabel"
           data-i18n-title="reglages.autostart.title"
           title="Démarre le serveur web et le traitement des clips en arrière-plan,
                   à l'ouverture de session (au démarrage de la machine pour un service
                   Linux avec lingering) ; n'ouvre pas cette page toute seule">
      <input type="checkbox" id="autostart"> <span data-i18n="reglages.autostart">Démarrage automatique
      de la surveillance</span>
    </label>
    <label id="autoLabel" data-i18n-title="reglages.auto.title" title="Recharger la liste dès que des clips arrivent">
      <input type="checkbox" id="auto"> <span data-i18n="reglages.auto">Actualisation automatique de la page</span>
    </label>
    <div class="champCadence">
      <label for="tailleCartes" data-i18n="reglages.tailleCartes">Taille des vignettes</label>
      <select id="tailleCartes">
        <option value="petites" data-i18n="reglages.tailleCartes.petites">Petites</option>
        <option value="moyennes" data-i18n="reglages.tailleCartes.moyennes">Moyennes</option>
        <option value="grandes" data-i18n="reglages.tailleCartes.grandes">Grandes</option>
        <option value="tres_grandes" data-i18n="reglages.tailleCartes.tres_grandes">Très grandes</option>
      </select>
    </div>
    <label id="barrePaginationLabel" data-i18n-title="reglages.barrePagination.title"
           title="Masquée, la barre du bas de la liste reste disponible pour changer de page. Propre à ce navigateur.">
      <input type="checkbox" id="barrePagination"> <span data-i18n="reglages.barrePagination">Afficher la barre de
      pagination en haut de la liste</span>
    </label>
    <fieldset>
      <legend data-i18n="reglages.masquees">Caméras masquées</legend>
      <p class="sub tiny" data-i18n="reglages.masquees.hint">Une caméra masquée disparaît des listes de cette page mais continue d'enregistrer et de télécharger. Réglage propre à ce navigateur.</p>
      <div id="camerasMasqueesListe" class="ligneCoches sub tiny"></div>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.entete">En-tête de la page</legend>
      <p class="sub tiny" data-i18n="reglages.entete.hint">Pour reconnaître cette installation d'une autre : le titre et l'icône s'affichent en haut de la page et dans l'onglet, sur tous les navigateurs. Vides : blink2video.</p>
      <div class="champCadence">
        <label for="headerTitle" data-i18n="reglages.entete.titre">Titre</label>
        <input type="text" id="headerTitle" maxlength="60" placeholder="blink2video">
      </div>
      <div class="champCadence">
        <label for="headerIcon" data-i18n="reglages.entete.icone">Icône (un émoji)</label>
        <input type="text" id="headerIcon" maxlength="16" placeholder="🏠">
      </div>
    </fieldset>
    <div class="champCadence">
      <label for="port" data-i18n="reglages.serveur">Port du serveur</label>
      <input type="number" id="port" min="1" max="65535" step="1">
    </div>
    <div class="champDossier" data-i18n-title="reglages.storageDir.hint"
         title="Clips, vidéos assemblées, directs et captures. Ne déplace pas ceux déjà enregistrés à l'ancien emplacement : à faire vous-même si vous changez ce chemin. Vide = emplacement par défaut, Documents/blink2video.">
      <label for="storageDir" data-i18n="reglages.storageDir">Dossier des vidéos</label>
      <input type="text" id="storageDir" data-i18n-placeholder="reglages.storageDir.placeholder"
             placeholder="C:/chemin/vers/le/dossier">
      <button type="button" id="storageDirBrowse" data-i18n="reglages.storageDir.browse">Parcourir…</button>
    </div>
  </div>
  <div id="panelVideo" class="panelReglages" hidden>
    <fieldset>
      <legend data-i18n="reglages.cadence">Cadence de lecture des caméras</legend>
      <label id="downloadAutoLabel" data-i18n-title="reglages.downloadAuto.hint"
             title="Décochée, aucun clip n'est plus récupéré ni stocké : utile pour ne garder que le direct. Les cadences ci-dessous n'ont alors plus d'effet.">
        <input type="checkbox" id="downloadAuto">
        <span data-i18n="reglages.downloadAuto">Télécharger les clips automatiquement</span>
      </label>
      <div class="champCadenceDouble">
        <label for="usbMinutes" data-i18n="reglages.usb">Stockage local (minutes)</label>
        <input type="number" id="usbMinutes" min="1" step="1">
        <label for="cloudMinutes" data-i18n="reglages.cloud">Cloud (minutes)</label>
        <input type="number" id="cloudMinutes" min="1" step="1">
      </div>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.video">Vidéo</legend>
      <label id="timestampLabel">
        <input type="checkbox" id="timestamp"> <span data-i18n="reglages.timestamp">Incruster la date et l'heure
        dans l'image</span>
      </label>
      <p class="sub tiny" data-i18n="reglages.timestamp.style.hint">
        Vide = taille automatique selon la hauteur de la vidéo. Couleur : nom
        (white, yellow...) ou hexadécimal, éventuellement avec une transparence
        (ex. white@0.8).
      </p>
      <div class="champCadenceDouble">
        <label for="fontSize" data-i18n="reglages.fontSize">Taille de police</label>
        <input type="number" id="fontSize" min="8" max="500" step="1" placeholder="auto">
        <label for="boxOpacity" data-i18n="reglages.boxOpacity">Opacité du bandeau</label>
        <input type="number" id="boxOpacity" min="0" max="1" step="0.05">
      </div>
      <div class="champCadence">
        <label for="fontColor" data-i18n="reglages.fontColor">Couleur</label>
        <input type="text" id="fontColor" list="couleursCourantes" placeholder="white">
      </div>
      <div class="champCadence">
        <label for="clock" data-i18n="reglages.clock">Format de l'heure</label>
        <select id="clock">
          <option value="24h" data-i18n="reglages.clock.24h">24 h (14:05:09)</option>
          <option value="12h" data-i18n="reglages.clock.12h">12 h (02:05:09 PM)</option>
        </select>
      </div>
      <datalist id="couleursCourantes">
        <option value="white">
        <option value="yellow">
        <option value="black">
        <option value="red">
        <option value="lime">
        <option value="cyan">
        <option value="orange">
      </datalist>
      <div class="champCadence">
        <label for="timezone" data-i18n="reglages.timezone">Fuseau horaire</label>
        <input type="text" id="timezone" list="fuseauxCourants" placeholder="Europe/Paris">
      </div>
      <datalist id="fuseauxCourants">
        <option value="Europe/Paris">
        <option value="Europe/London">
        <option value="Europe/Brussels">
        <option value="Europe/Madrid">
        <option value="Europe/Berlin">
        <option value="America/Montreal">
        <option value="America/New_York">
        <option value="America/Chicago">
        <option value="America/Denver">
        <option value="America/Los_Angeles">
        <option value="Africa/Casablanca">
        <option value="Africa/Abidjan">
        <option value="Indian/Reunion">
        <option value="Asia/Tokyo">
        <option value="Australia/Sydney">
        <option value="UTC">
      </datalist>
      <div class="champCadence">
        <label for="liveProtocol" data-i18n="reglages.liveProtocol">Protocole du direct</label>
        <select id="liveProtocol">
          <option value="webrtc" data-i18n="reglages.liveProtocol.webrtc">WebRTC (rapide)</option>
          <option value="mse" data-i18n="reglages.liveProtocol.mse">MSE (compatible)</option>
        </select>
      </div>
      <div class="champCadence" data-i18n-title="reglages.liveAutoStop.hint"
           title="Arrête le direct tout seul après ce délai, jusqu'à 420 s, même en direct continu. Vide ou 0 : pas d'arrêt automatique ; Blink ferme de lui-même chaque direct au bout d'environ 6 minutes, et la case « Direct continu » d'une caméra le relance.">
        <label for="liveAutoStopSeconds" data-i18n="reglages.liveAutoStop">Arrêt auto du direct (s)</label>
        <input type="number" id="liveAutoStopSeconds" min="0" max="420" placeholder="0">
      </div>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.cameraAudio.title">Audio de la caméra</legend>
      <label>
        <input type="checkbox" id="cameraAudioEnabled">
        <span data-i18n="reglages.cameraAudio">Inclure le son de la caméra dans le direct et les enregistrements</span>
      </label>
      <p class="sub tiny" data-i18n="reglages.cameraAudio.hint">Avec le son, les directs utilisent le mode compatible MSE et peuvent démarrer plus lentement. La lecture commence en sourdine ; écouter le son ne change pas l'enregistrement.</p>
      <div class="champCadence">
        <label for="cameraAudioTrack" data-i18n="reglages.cameraAudio.track">Canal audio</label>
        <select id="cameraAudioTrack">
          <option value="0" data-i18n="reglages.cameraAudio.primary">Principal</option>
          <option value="1" data-i18n="reglages.cameraAudio.alternate">Alternatif (si le principal reste silencieux)</option>
        </select>
      </div>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.archivage"
              data-i18n-title="reglages.archivage.hint"
              title="Hebdomadaire et mensuelle sont assemblées à partir de la quotidienne : décocher « Quotidienne » désactive aussi les deux autres.">Création des vidéos temporelles par caméra</legend>
      <div class="ligneCoches">
        <label id="mergeJourLabel">
          <input type="checkbox" id="mergeJour"> <span data-i18n="reglages.mergeJour">Quotidienne</span>
        </label>
        <label id="mergeSemaineLabel">
          <input type="checkbox" id="mergeSemaine"> <span data-i18n="reglages.mergeSemaine">Hebdomadaire</span>
        </label>
        <label id="mergeMoisLabel">
          <input type="checkbox" id="mergeMois"> <span data-i18n="reglages.mergeMois">Mensuelle</span>
        </label>
      </div>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.conservation">Conservation (jours, 0 = toujours)</legend>
      <p class="sub tiny" data-i18n="reglages.conservation.hint">
        Une vidéo assemblée est refaite tant que ce dont elle est faite existe :
        les clips bruts s'effacent par journée entière une fois la journalière
        faite, une journalière une fois ses bruts effacés, une hebdomadaire ou
        une mensuelle une fois ses journalières effacées. Chaque durée doit donc
        être au moins celle de sa source.
      </p>
      <div class="champCadence">
        <label for="conservationClips" data-i18n="reglages.conservation.clips">Clips bruts</label>
        <input type="number" id="conservationClips" min="0" max="3650" placeholder="0">
      </div>
      <div class="champCadence">
        <label for="conservationJour" data-i18n="reglages.conservation.jour">Journalières</label>
        <input type="number" id="conservationJour" min="0" max="3650" placeholder="0">
      </div>
      <div class="champCadence">
        <label for="conservationSemaine" data-i18n="reglages.conservation.semaine">Hebdomadaires</label>
        <input type="number" id="conservationSemaine" min="0" max="3650" placeholder="0">
      </div>
      <div class="champCadence">
        <label for="conservationMois" data-i18n="reglages.conservation.mois">Mensuelles</label>
        <input type="number" id="conservationMois" min="0" max="3650" placeholder="0">
      </div>
      <div class="champCadence">
        <label for="conservationDirects" data-i18n="reglages.conservation.directs">Directs</label>
        <input type="number" id="conservationDirects" min="0" max="3650" placeholder="0">
      </div>
      <div class="champCadence">
        <label for="conservationPhotos" data-i18n="reglages.conservation.photos">Photos</label>
        <input type="number" id="conservationPhotos" min="0" max="3650" placeholder="0">
      </div>
    </fieldset>
  </div>
  <div id="panelAcces" class="panelReglages" hidden>
    <fieldset>
      <legend data-i18n="reglages.accesDistant" data-i18n-title="reglages.accesDistant.hint"
              title="Pour joindre cette instance directement depuis un VPN maillé (Tailscale, WireGuard), sans reverse proxy devant.">Accès distant par VPN maillé</legend>
      <p class="sub tiny" data-i18n="reglages.accesDistant.hint.text">
        À utiliser avec la variable d'environnement BLINK_BIND réglée sur
        l'adresse d'écoute (voir le README, section « Reaching it remotely ») :
        ce réglage seul, sans elle, ne change rien à qui peut atteindre
        l'interface. Plusieurs valeurs possibles, séparées par des virgules :
        noms d'hôte, adresses IP ou sous-réseaux (192.168.1.0/24). Seuls les noms
        et adresses exacts autorisent aussi l'intégration de la page dans une
        iframe.
      </p>
      <div class="champDossier">
        <label for="trustedHost" data-i18n="reglages.trustedHost">Hôte de confiance</label>
        <input type="text" id="trustedHost" placeholder="100.x.y.z, 192.168.1.0/24, mon-pc">
      </div>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.webhook" data-i18n-title="reglages.webhook.hint"
              title="Déclenche une photo à distance (domotique, automatisation) sans ouvrir cette page.">Photo par webhook</legend>
      <p class="sub tiny" data-i18n="reglages.webhook.hint.text">
        Appelez cette URL en GET, en remplaçant NOM_CAMERA par le nom exact d'une
        caméra, pour déclencher une photo à distance. Le secret fait partie de
        l'URL : gardez-la privée.
      </p>
      <div class="champDossier">
        <label for="webhookUrl" data-i18n="reglages.webhook.url">URL</label>
        <input type="text" id="webhookUrl" readonly>
      </div>
      <button type="button" id="webhookRegenerer" data-i18n="reglages.webhook.regenerer">Régénérer le secret</button>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.webhookNotif" data-i18n-title="reglages.webhookNotif.hint"
              title="Appelle cette URL en POST à chaque nouveau clip ou photo, une fois le fichier bien écrit sur disque.">Notification par webhook</legend>
      <p class="sub tiny" data-i18n="reglages.webhookNotif.hint.text">
        Envoie un POST JSON (caméra, chemin, type) à l'URL ci-dessous chaque
        fois qu'un clip ou une photo est prêt. Au mieux, sans nouvelle
        tentative : une notification manquée n'interrompt jamais le
        téléchargement.
      </p>
      <div class="champDossier">
        <label for="webhookNotifUrl" data-i18n="reglages.webhookNotifUrl">URL de notification</label>
        <input type="text" id="webhookNotifUrl" placeholder="https://...">
      </div>
    </fieldset>
  </div>
  <div id="panelAlertes" class="panelReglages" hidden>
    <fieldset>
      <legend data-i18n="reglages.alertes">Mise en sourdine des alertes</legend>
      <div id="sourdineListe" class="ligneCoches sub tiny" data-i18n="sourdine.loading">Chargement…</div>
    </fieldset>
    <fieldset>
      <legend data-i18n="reglages.suppressionAuto" data-i18n-title="suppressionAuto.hint"
              title="Une fois un clip téléchargé avec succès, il est supprimé de sa source (stockage local USB/microSD ou cloud de l'abonnement selon la caméra).">Suppression automatique après téléchargement</legend>
      <div id="suppressionAutoListe" class="ligneCoches sub tiny" data-i18n="suppressionAuto.loading">Chargement…</div>
    </fieldset>
  </div>
  <div class="row row-boutons">
    <button class="primary" id="reglagesApply" data-i18n="reglages.apply"
            data-i18n-title="reglages.hint"
            title="Les réglages ne prennent effet qu'au redémarrage : « Appliquer » enregistre et redémarre. Changer le port redirige cette page vers la nouvelle adresse.">Appliquer</button>
    <button id="verifierMajButton" data-i18n="reglages.checkUpdates"
            data-i18n-title="reglages.checkUpdates.hint"
            title="Demande tout de suite à GitHub si une version plus récente est publiée. Sans ce bouton, blink2video vérifie de lui-même toutes les heures.">Vérifier les mises à jour</button>
    <button id="redemarrerButton" data-i18n="reglages.restart"
            data-i18n-title="reglages.restart.hint"
            title="Redémarre sans rien changer aux réglages, par exemple pour reprendre une mise à jour déjà en place.">Redémarrer</button>
    <button id="stopButton" data-i18n="reglages.stop">Arrêter la surveillance des caméras</button>
    <button id="reglagesClose" data-i18n="reglages.close">Fermer</button>
  </div>
</dialog>

<dialog id="filtre">
  <h3 data-i18n="filtre.title">Filtre</h3>
  <div class="champCadence">
    <label for="camera" data-i18n="filtre.camera">Caméra</label>
    <select id="camera"></select>
  </div>
  <div class="champCadence" id="groupBySection" hidden>
    <label for="groupBy" data-i18n="filtre.groupBy">Grouper par</label>
    <select id="groupBy">
      <option value="camera" data-i18n="filtre.groupBy.camera">Caméra</option>
      <option value="day" data-i18n="filtre.groupBy.day">Jour</option>
    </select>
  </div>
  <label id="outLabel">
    <input type="checkbox" id="showOut"> <span data-i18n="reglages.showOut">Voir les clips écartés</span>
  </label>
  <label id="hiddenLabel" hidden>
    <input type="checkbox" id="showHidden"> <span data-i18n="filtre.showHidden">Afficher les caméras masquées</span>
  </label>
  <div id="periodeSection">
    <p class="sub tiny" data-i18n="range.title">Période</p>
    <div class="presets">
      <button type="button" data-preset="today" data-i18n="range.today">Aujourd'hui (24 h)</button>
      <button type="button" data-preset="week" data-i18n="range.week">Cette semaine (7 j)</button>
      <button type="button" data-preset="month" data-i18n="range.month">Ce mois-ci</button>
      <button type="button" data-preset="2months" data-i18n="range.2months">2 derniers mois</button>
      <button type="button" data-preset="all" data-i18n="range.all">Tout l'historique</button>
    </div>
    <p class="sub tiny" data-i18n="range.custom.hint">Ou une plage précise, à l'heure près :</p>
    <div class="champCadence">
      <label for="rangeFrom" data-i18n="range.from">Du</label>
      <input type="datetime-local" id="rangeFrom">
    </div>
    <div class="champCadence">
      <label for="rangeTo" data-i18n="range.to">au</label>
      <input type="datetime-local" id="rangeTo">
    </div>
  </div>
  <div class="row row-boutons">
    <button class="primary" id="filtreApply" data-i18n="range.apply">Filtrer</button>
    <button id="filtreClose" data-i18n="reglages.close">Fermer</button>
  </div>
</dialog>

<dialog id="rondeReglages">
  <h3 data-i18n="ronde.titre">Ronde des directs</h3>
  <p data-i18n="ronde.explication">Plein écran, une caméra après l'autre : chacune reste à l'écran le temps choisi, compté à partir de sa première image.</p>
  <p class="sub tiny" data-i18n="ronde.cameras">Caméras de la ronde</p>
  <div id="rondeCameras"></div>
  <div class="champCadence">
    <label for="rondeSecondes" data-i18n="ronde.secondes">Secondes par caméra</label>
    <input type="number" id="rondeSecondes" min="10" max="300" step="5">
  </div>
  <div class="champCadenceDouble">
    <label for="rondeDebut" data-i18n="ronde.plage">Seulement de</label>
    <input type="time" id="rondeDebut">
    <label for="rondeFin" data-i18n="ronde.plage.a">à</label>
    <input type="time" id="rondeFin">
  </div>
  <p class="sub tiny" data-i18n="ronde.plage.aide">Laissez les heures vides pour toute la journée.</p>
  <p class="sub tiny" data-i18n="ronde.batterie">Chaque passage réveille la caméra : sur batterie, la ronde l'use vite. Tant qu'elle tourne, la relève du stockage local (clé USB) attend sa pause ou son arrêt.</p>
  <div class="row row-boutons">
    <button class="primary" id="rondeDemarrer" data-i18n="ronde.demarrer">Démarrer</button>
    <button id="rondeFermer" data-i18n="reglages.close">Fermer</button>
  </div>
</dialog>

<!-- Ronde des directs : couvre toute la fenêtre, plein écran quand le
     navigateur l'accorde ; les cases de direct y passent tour à tour. -->
<div id="ronde" hidden>
  <div class="rondeBarre">
    <span id="rondeTitre"></span>
    <button id="rondeSuivante" data-i18n="ronde.suivante">Suivante ⏭</button>
    <button id="rondeArreter" data-i18n="ronde.arreter">■ Arrêter</button>
  </div>
  <p id="rondeMessage" hidden></p>
  <div id="rondeScene"></div>
</div>
<script nonce="__SCRIPT_NONCE__">
__APP_JS__
</script>
</body>
</html>
"""

# CSS et JS vivent dans leurs propres fichiers (serve_style.css,
# serve_app.js) : un .py qui les contenait en dur n'avait ni coloration ni
# lint corrects pour l'un ou l'autre. Splicés ici avant les substitutions
# suivantes pour que __TOKEN__ (dans serve_app.js) en profite comme le reste
# du gabarit. runtime.resource_dir() plutôt que Path(__file__).parent : seul
# le premier reste correct une fois le bundle figé (voir assets/, même
# convention pour le favicon).
PAGE = PAGE.replace(
    "__CSS__",
    (runtime.resource_dir() / "serve_style.css").read_text(encoding="utf-8"),
)
PAGE = PAGE.replace(
    "__APP_JS__",
    (runtime.resource_dir() / "serve_app.js").read_text(encoding="utf-8"),
)

# La page est un gabarit constant, plein d'accolades CSS et JavaScript :
# impossible d'en faire une f-string. Une substitution unique au chargement
# suffit, et laisse le gabarit lisible.
PAGE = PAGE.replace("__VERSION__", runtime.VERSION)
# Le PID à côté de la version distingue en un coup d'œil un onglet resté
# ouvert sur l'ancien processus de celui qui vient de repartir après un
# redémarrage : les deux affichent la même page tant que l'onglet ne
# recharge pas, mais pas le même PID (constaté en réel, session du
# 2026-09-16 : plusieurs redémarrages sans trace nulle part de ce qui avait
# effectivement tourné).
PAGE = PAGE.replace("__PID__", str(os.getpid()))

# Un jeton par processus (28.60/28.59), pas par requête : engendré une seule
# fois ici, au chargement du module, comme VERSION ci-dessus. uuid4 plutôt que
# secrets pour rester sur la génération de jeton déjà en usage ailleurs
# (runtime.py, verrou disque ; blink_auth.py, fichiers temporaires).
TOKEN = uuid.uuid4().hex
PAGE = PAGE.replace("__TOKEN__", TOKEN)
SCRIPT_NONCE = uuid.uuid4().hex
PAGE = PAGE.replace("__SCRIPT_NONCE__", SCRIPT_NONCE)

# Titre et icône de l'en-tête (issue #40) : lus à chaque envoi de la page, pas
# au chargement comme la version, pour qu'un fichier de réglages retouché à la
# main se voie au rechargement. En une seule passe : un titre qui contiendrait
# « __FAVICON__ » reste du texte.
_ENTETE_RE = re.compile(r"__(TITRE_PAGE|FAVICON|ENTETE)__")


def page_avec_entete(reglages: dict) -> str:
    """La page, avec le titre et l'icône choisis dans les réglages.

    L'icône, un émoji en général, sert aussi d'icône d'onglet : l'image SVG
    d'un seul caractère, l'usage courant pour un favicon en émoji, passe par
    `img-src data:` que la CSP autorise déjà. Sans icône, celle de
    l'application."""
    titre = reglages.get("header_title") or "blink2video"
    icone = reglages.get("header_icon") or ""
    favicon = "/favicon.ico"
    entete = html.escape(titre)
    if icone:
        svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
               f'<text y=".9em" font-size="90">{html.escape(icone)}</text></svg>')
        favicon = "data:image/svg+xml," + quote(svg)
        entete = f'<span class="icone">{html.escape(icone)}</span> {entete}'
    valeurs = {"TITRE_PAGE": html.escape(titre), "FAVICON": html.escape(favicon),
               "ENTETE": entete}
    return _ENTETE_RE.sub(lambda m: valeurs[m.group(1)], PAGE)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="blink2video serve",
        description=msg("aide_desc"),
    )
    parser.add_argument("--input", type=Path, default=md.DEFAULT_INPUT)
    parser.add_argument("--output", type=Path, default=md.DEFAULT_OUTPUT)
    parser.add_argument("--weekly-output", type=Path, default=md.DEFAULT_WEEKLY)
    parser.add_argument("--monthly-output", type=Path, default=md.DEFAULT_MONTHLY)
    parser.add_argument("--normalized-output", type=Path, default=md.DEFAULT_NORMALIZED)
    parser.add_argument("--excluded-output", type=Path, default=md.DEFAULT_EXCLUDED)
    parser.add_argument("--timezone", default="Europe/Paris")
    parser.add_argument("--hub", help=msg("aide_hub"))
    # Avec les sorties : les vignettes et les exclusions de directs
    # (DIRECT_EXCLUSION) décrivent ces vidéos-là, et les suivent.
    parser.add_argument(
        "--thumbs", type=Path, default=runtime.dossier_sorties() / ".blink_thumbs",
        help=msg("aide_thumbs"),
    )
    parser.add_argument("--port", type=runtime.port_valide, default=8765)
    parser.add_argument("--trusted-host", default="", help=msg("aide_trusted_host"))
    parser.add_argument("--initial-setup", action="store_true",
                        help=argparse.SUPPRESS)
    parser.add_argument(
        # Un serveur n'ouvre pas de fenêtre de lui-même : c'est l'usage, et
        # celui-ci passe l'essentiel de sa vie lancé au démarrage de session, où
        # surgir dans le navigateur serait déplacé. On le demande donc.
        "--open-browser", action="store_true", help=msg("aide_open_browser"),
    )
    return parser.parse_args()


def veiller_sur_les_versions() -> None:
    """Tient à jour, en fond, la connaissance de la dernière version publiée.

    Un fil séparé plutôt qu'un appel dans la page : GitHub peut mettre dix
    secondes à répondre, ou ne pas répondre du tout, et rien de tout cela ne
    doit se voir depuis l'interface. Une visite par heure (maj.FRAICHEUR) :
    à six heures, une publication pouvait attendre une demi-journée avant
    d'apparaître (issue #35). La boucle est celle du commun (Verificateur.veiller),
    la même pour les quatre applications : elle ne redemande pas une réponse
    encore fraîche, celle du cache disque d'un démarrage récent."""
    if runtime.build_windows7():
        return             # pas de mise à jour automatique pour cette édition
    try:
        maj._verificateur().veiller()
    except Exception:      # une panne n'arrête pas le serveur
        pass


def main() -> int:
    args = parse_args()
    Handler.paths = {
        "input": args.input.resolve(),
        "normalized": args.normalized_output.resolve(),
        "excluded": args.excluded_output.resolve(),
        "thumbs": args.thumbs.resolve(),
        "daily": args.output.resolve(),
        "weekly": args.weekly_output.resolve(),
        "monthly": args.monthly_output.resolve(),
        "direct": DOSSIER_DIRECT.resolve(),
        "snapshots": DOSSIER_SNAPSHOTS.resolve(),
    }
    Handler.hub = args.hub
    Handler.initial_setup = args.initial_setup
    Handler.trusted_host = args.trusted_host.strip()
    try:
        Handler.ffmpeg = md.find_ffmpeg()
        Handler.timezone = ZoneInfo(args.timezone)
        collect(Handler.paths, Handler.timezone, Handler.ffmpeg)
    except (RuntimeError, ZoneInfoNotFoundError) as error:
        print(msg("erreur_generique", erreur=error))
        return 1

    # 127.0.0.1 et pas 0.0.0.0 par défaut : le tableau de bord n'a aucune
    # authentification, il n'a rien à faire sur le réseau local sans un
    # choix explicite. BLINK_BIND permet d'y déroger pour deux usages :
    # accès direct depuis d'autres machines du LAN (à réserver à un réseau
    # domestique de confiance, jamais exposé sur internet), ou conteneur
    # Docker où 127.0.0.1 désignerait la boucle locale du conteneur,
    # injoignable depuis l'hôte même avec le port publié (le réseau en pont
    # route vers l'interface du conteneur, pas sa boucle locale) : la
    # frontière de sécurité reste alors posée par la publication du port
    # elle-même (voir docker-compose.yml, 127.0.0.1: en dur).
    bind = os.environ.get("BLINK_BIND", "127.0.0.1")

    class Server(http.server.ThreadingHTTPServer):
        # http.server active allow_reuse_address, dont la sémantique diffère
        # sous Windows : plusieurs serveurs peuvent s'y lier au même port sans
        # la moindre erreur, et lequel reçoit les connexions est indéterminé.
        # Une instance oubliée continue alors de servir sa page en mémoire, et
        # l'on croit ses modifications sans effet. On préfère donc un refus
        # franc ici, en gardant le comportement Unix habituel ailleurs (où
        # l'option sert à relancer sans attendre la fin du TIME_WAIT).
        allow_reuse_address = os.name != "nt"

        def handle_error(self, request, client_address):
            # Comportement par défaut de socketserver : traceback.print_exc()
            # sur stderr seulement - invisible depuis console=False (revue du
            # 27/08 : un changement de dossier de stockage ne redémarrait
            # plus du tout, sans la moindre trace nulle part pour dire
            # pourquoi). Consigné en plus dans un fichier, jamais à la place :
            # reste utile aussi quand une vraie console existe.
            import traceback
            try:
                with (runtime.app_dir() / "serve_erreurs.log").open(
                        "a", encoding="utf-8") as journal:
                    journal.write(f"\n--- {dt.datetime.now().isoformat()} "
                                  f"{client_address} ---\n")
                    traceback.print_exc(file=journal)
            except OSError:
                pass
            super().handle_error(request, client_address)

    try:
        server = Server((bind, args.port), Handler)
    except OSError as error:
        print(msg("echec_ecoute_port", port=args.port, erreur=error))
        print(msg("autre_instance_deja"))
        return 1
    url = f"http://127.0.0.1:{args.port}/"
    print(msg("interface_disponible", url=url))
    if bind not in ("127.0.0.1", "localhost"):
        # webbrowser.open() ci-dessous garde volontairement 127.0.0.1 (le
        # navigateur ouvert est celui de cette machine), mais quelqu'un qui a
        # positionné BLINK_BIND pour l'accès LAN doit voir, sans avoir à
        # relire la doc, qu'il vient d'ouvrir l'interface sans authentification
        # au reste du réseau.
        print(msg("attention_blink_bind", bind=bind, port=args.port))
    if Handler.trusted_host:
        print(msg("attention_trusted_host", hote=Handler.trusted_host))
    # Le serveur de configuration initiale ne doit créer aucun travail de
    # fond avant validation. Même la veille de version, sans rapport avec les
    # clips, attend donc le vrai démarrage pour garder ce mode strictement
    # limité au formulaire.
    if not args.initial_setup:
        veiller_sur_les_versions()
    if args.open_browser:
        threading.Timer(0.5, webbrowser.open, [url]).start()

    def surveiller_arret_demande():
        # Un arrêt externe (tray, stop) pose ce drapeau plutôt que de tuer le
        # processus directement : shutdown() laisse toute requête déjà en
        # cours se terminer normalement (revue du 27/08, arrêt coopératif).
        while not runtime.arret_demande():
            time.sleep(1)
        server.shutdown()

    fil_arret = threading.Thread(target=surveiller_arret_demande, daemon=True)
    fil_arret.start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print(msg("interruption_arret"))
    finally:
        server.server_close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())