serve.py
Rama main · réplica de lectura del repositorio original.
"""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())