// Jeton anti-CSRF (voir Handler.jeton_valide côté serveur, 28.60) : posé sur // toute requête qui modifie quelque chose. Fait une fois ici, avant tout // autre script, pour qu'aucun fetch() plus bas n'ait à s'en soucier. const BLINK_TOKEN = "__TOKEN__"; const BLINK_PID = "__PID__"; const _fetchNatif = window.fetch; window.fetch = (entree, options) => { options = options || {}; const headers = new Headers(options.headers || {}); headers.set("X-Blink-Token", BLINK_TOKEN); options = { ...options, headers }; return _fetchNatif(entree, options); }; // Le navigateur peut fermer sa connexion sans qu'aiortc le remarque tout de // suite. Signaler explicitement les sessions de CET onglet, y compris une // offre encore en cours : une page sans direct ne doit jamais couper celui // d'un autre onglet. pagehide couvre aussi les rechargements et le bfcache. window.addEventListener("pagehide", () => { for (const name of nomsDirectsActifs()) stopWatch(name, true); }); // Toute valeur reçue de Blink ou lue dans un nom de fichier est une donnée, // jamais du balisage. Les quelques vues rendues par gabarits HTML passent // toutes par cette fonction ; les actions utilisent des data-* et une // délégation d'événements, aucun nom n'est réinjecté dans du JavaScript. const h = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); const avecJeton = (url) => `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(BLINK_TOKEN)}`; // Si le serveur a redémarré hors des trois transitions suivies plus bas // (réinstallation manuelle pendant que l'onglet restait ouvert, crash...), // le jeton posé par cette page est périmé : toute route protégée retombe // sur le send_error() par défaut de http.server, qui répond en HTML // (DOCTYPE), jamais en JSON. .json() y échoue avec un SyntaxError précis, // jamais levé par une vraie erreur JSON (send_json() reste toujours du // JSON valide, même à 500). La seule récupération sensée est un // rechargement complet, qui obtient une page et un jeton frais (signalé // par cutthin sur Reddit, 2026-08-31). // Exception : 502/503/504. Un mandataire (Caddy, nginx...) répond ainsi à la // place du serveur tant qu'il redémarre, avec un corps vide ou HTML : ce // n'est pas un jeton périmé, le serveur est simplement absent. Recharger // alors ramenait la page d'erreur du navigateur, sans script pour la // relancer (issue #35, Caddy) ; c'est sonderRelance() qui recharge au retour. async function lireJSON(reponse) { try { return await reponse.json(); } catch (erreur) { const absent = reponse.status >= 502 && reponse.status <= 504; if (erreur instanceof SyntaxError && !absent) location.reload(); throw erreur; } } let data = { clips: [], cameras: [], days: [] }; let videos = { daily: [], weekly: [], monthly: [] }; let snapshots = []; const $ = (id) => document.getElementById(id); // ── audio direct (Live View) ─────────────────────────────────────────────── const videosAudioInitialises = new WeakSet(); function actualiserBoutonAudio(video) { const bouton = video.closest?.(".live")?.querySelector('[data-action="toggle-audio"]'); if (!bouton) return; const disponible = video.blinkAudioDisponible; const ecouteActive = disponible === true && !video.muted && video.volume > 0; const cle = disponible === true ? (ecouteActive ? "live.audio.mute" : "live.audio.listen") : (disponible === false ? "live.audio.unavailable" : "live.audio.waiting"); bouton.disabled = disponible !== true; bouton.classList?.toggle?.("active", ecouteActive); bouton.textContent = t(cle); bouton.title = t(cle); bouton.setAttribute("aria-label", t(cle)); } function preparerAudioVideo(video) { if (!videosAudioInitialises.has(video)) { videosAudioInitialises.add(video); video.addEventListener("volumechange", () => actualiserBoutonAudio(video)); } video.defaultMuted = true; video.muted = true; video.volume = 1; actualiserBoutonAudio(video); } async function lireVideo(video) { try { await video.play(); } catch (error) { if (error.name !== "NotAllowedError" || video.muted) throw error; // Une politique d'autoplay ne doit pas être traitée comme une panne Blink. video.muted = true; actualiserBoutonAudio(video); await video.play(); } } function audioBtn(name) { if (!system?.camera_audio_enabled) return ""; return ``; } function toggleAudio(name) { const video = $("live-" + cssId(name))?.querySelector("video"); if (!video || video.blinkAudioDisponible !== true) return; const ecouter = video.muted || video.volume === 0; video.muted = !ecouter; if (ecouter && video.volume === 0) video.volume = 1; actualiserBoutonAudio(video); } // ── fin audio direct (Live View) ────────────────────────────────────────── // ── i18n ───────────────────────────────────────────────────────────────── // Même pattern que gui/app.js de lidar2map : dico inline par locale + attribut // data-i18n sur les nœuds statiques, t()/tf() appelés directement dans le // texte généré en JS. Zéro dépendance. Le FR en dur dans le HTML reste le // repli si une clé manque : pas de page cassée. Détection : navigator.language // au premier chargement ; override manuel persisté en localStorage (page web // ordinaire servie par serve.py, pas de webview packagée à contourner ici). const I18N = { fr: { "view.live": "Directs Vues", "view.direct": "Directs Enregistrements", "view.clips": "Détections Enregistrements", "view.daily": "Détections Journalières", "view.weekly": "Détections Hebdomadaires", "view.monthly": "Détections Mensuelles", "view.pictures": "Photos", "filter.allcameras": "toutes caméras", "btn.refresh": "↻ Actualiser", "btn.reglages": "⚙ Réglages…", "btn.reglages.title": "Réglages", "entete.pid": "PID serveur {pid}", "update.installing": "Installer {version}", "update.title": "Version {version} publiée. Le téléchargement, l'arrêt et la relance sont automatiques.", "update.updating": "Mise à jour…", "update.progress": "Mise à jour vers {version} : téléchargement, puis relance…", "update.failed": "La mise à jour n'a pas pu être lancée : {erreur}", "passages.updated": "actualisé {heure}", "passages.new.one": " · {n} nouveau clip, cliquez sur Actualiser", "passages.new.many": " · {n} nouveaux clips, cliquez sur Actualiser", "auth.title": "Connexion Blink", "auth.title.2fa": "Vérification en deux étapes", "auth.hint": "Le mot de passe sert uniquement à ouvrir la session ; seuls les jetons sont enregistrés, jamais le mot de passe.", "auth.hint.2fa": "Blink vient d'envoyer un code. Saisissez-le pour terminer la connexion.", "auth.email": "Adresse e-mail", "auth.password": "Mot de passe", "auth.show": "Afficher", "auth.hide": "Masquer", "auth.show.aria": "Afficher le mot de passe", "auth.hide.aria": "Masquer le mot de passe", "auth.code": "Code reçu par SMS ou e-mail", "auth.cancel": "Annuler", "auth.ok": "Se connecter", "auth.validate": "Valider", "auth.connecting": "Connexion en cours…", "auth.failed": "Échec de la connexion.", "reglages.title": "Réglages", "reglages.tab.general": "Général", "reglages.tab.video": "Vidéo", "reglages.tab.acces": "Accès et automatisation", "reglages.tab.alertes": "Alertes", "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.", "reglages.autostart": "Démarrage automatique de la surveillance", "reglages.autostart.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", "reglages.auto": "Actualisation automatique de la page", "reglages.auto.title": "Recharger la liste dès que des clips arrivent", "reglages.showOut": "Voir les clips écartés", "reglages.serveur": "Port du serveur", "dossier.absent": "Dossier des vidéos introuvable : {dossier}. Rien n'est écrit ailleurs en attendant son retour. Montez son disque, ou choisissez un autre dossier dans les réglages.", "reglages.storageDir": "Dossier des vidéos", "reglages.storageDir.placeholder": "C:/chemin/vers/le/dossier", "reglages.storageDir.hint": "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.", "reglages.storageDir.browse": "Parcourir…", "reglages.storageDir.browse.unavailable": "Sélecteur de dossier indisponible sur cette machine : saisissez le chemin directement.", "reglages.accesDistant": "Accès distant par VPN maillé", "reglages.accesDistant.hint": "Pour joindre cette instance directement depuis un VPN maillé (Tailscale, WireGuard), sans reverse proxy devant.", "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.", "reglages.trustedHost": "Hôte de confiance", "reglages.cadence": "Cadence de lecture des caméras", "reglages.usb": "Stockage local (minutes)", "reglages.cloud": "Cloud (minutes)", "reglages.video": "Vidéo", "reglages.timestamp": "Incruster la date et l'heure dans l'image", "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).", "reglages.fontSize": "Taille de police", "reglages.fontColor": "Couleur", "reglages.boxOpacity": "Opacité du bandeau", "reglages.clock": "Format de l'heure", "reglages.clock.24h": "24 h (14:05:09)", "reglages.clock.12h": "12 h (02:05:09 PM)", "reglages.timezone": "Fuseau horaire", "reglages.cameraAudio.title": "Audio de la caméra", "reglages.cameraAudio": "Inclure le son de la caméra dans le direct et les enregistrements", "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.", "reglages.cameraAudio.track": "Canal audio", "reglages.cameraAudio.primary": "Principal", "reglages.cameraAudio.alternate": "Alternatif (si le principal reste silencieux)", "live.audio.listen": "🔇 Écouter", "live.audio.mute": "🔊 Couper le son", "live.audio.unavailable": "🔇 Audio indisponible", "live.audio.waiting": "🔇 Attente du son", "reglages.liveProtocol": "Protocole du direct", "reglages.liveProtocol.webrtc": "WebRTC (rapide)", "reglages.liveProtocol.mse": "MSE (compatible)", "reglages.liveAutoStop": "Arrêt auto du direct (s)", "reglages.liveAutoStop.hint": "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.", "reglages.archivage": "Création des vidéos temporelles par caméra", "reglages.downloadAuto": "Télécharger les clips automatiquement", "reglages.downloadAuto.hint": "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.", "reglages.mergeJour": "Quotidienne", "reglages.mergeSemaine": "Hebdomadaire", "reglages.mergeMois": "Mensuelle", "reglages.conservation": "Conservation (jours, 0 = toujours)", "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.", "reglages.conservation.clips": "Clips bruts", "reglages.conservation.jour": "Journalières", "reglages.conservation.semaine": "Hebdomadaires", "reglages.conservation.mois": "Mensuelles", "reglages.conservation.directs": "Directs", "reglages.conservation.photos": "Photos", "reglages.archivage.hint": "Hebdomadaire et mensuelle sont assemblées à partir de la quotidienne : décocher « Quotidienne » désactive aussi les deux autres.", "reglages.alertes": "Mise en sourdine des alertes", "reglages.suppressionAuto": "Suppression automatique après téléchargement", "reglages.webhook": "Photo par webhook", "reglages.webhook.hint": "Déclenche une photo à distance (domotique, automatisation) sans ouvrir cette page.", "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.", "reglages.webhook.url": "URL", "reglages.webhook.regenerer": "Régénérer le secret", "reglages.webhook.regenerer.confirm": "Régénérer invalidera l'URL actuelle : toute automatisation existante devra être mise à jour avec la nouvelle. Continuer ?", "reglages.webhookNotif": "Notification par webhook", "reglages.webhookNotif.hint": "Appelle cette URL en POST à chaque nouveau clip ou photo, une fois le fichier bien écrit sur disque.", "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.", "reglages.webhookNotifUrl": "URL de notification", "reglages.entete": "En-tête de la page", "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.", "reglages.entete.titre": "Titre", "reglages.entete.icone": "Icône (un émoji)", "reglages.hint": "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.", "reglages.apply": "Appliquer", "reglages.restarting": "Redémarrage…", "reglages.restart": "Redémarrer", "reglages.restart.hint": "Redémarre sans rien changer aux réglages, par exemple pour reprendre une mise à jour déjà en place.", "reglages.checkUpdates": "Vérifier les mises à jour", "reglages.checkUpdates.hint": "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.", "reglages.checkUpdates.running": "Vérification…", "reglages.checkUpdates.none": "blink2video {version} est à jour.", "reglages.checkUpdates.found": "La version {version} est publiée : le bouton « Installer {version} » est en haut de la page.", "reglages.checkUpdates.offline": "GitHub n'a pas répondu. Réessayez dans un moment.", "reglages.restarting.settings": "Redémarrage avec les nouveaux réglages…", "reglages.portchange": "Port changé : redirection vers {url} dès l'arrêt confirmé…", "reglages.stop": "Arrêter la surveillance des caméras", "reglages.close": "Fermer", "reglages.error.cadence": "Les cadences doivent valoir au moins 1 minute.", "reglages.error.port": "Le port doit être compris entre 1 et 65535.", "reglages.error.timezone": "Le fuseau horaire ne peut pas être vide.", "stop.stopping": "Arrêt…", "stop.stopped": "Arrêt demandé. Cette page va devenir indisponible.", "stop.failed": "L'arrêt n'a pas abouti ; blink2video répond toujours.", "reglages.restartFailed": "Le redémarrage n'a pas commencé. Vérifiez les journaux puis réessayez.", "sourdine.loading": "Chargement…", "sourdine.unavailable": "Liste des caméras indisponible.", "sourdine.none": "Aucune caméra connue pour l'instant.", "suppressionAuto.loading": "Chargement…", "suppressionAuto.unavailable": "Liste des caméras indisponible.", "suppressionAuto.none": "Aucune caméra connue pour l'instant.", "suppressionAuto.hint": "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).", "phase.inventory_clips": "Inventaire des clips à télécharger", "phase.download_clips": "Téléchargement des clips", "phase.prepare_clips": "Préparation des clips", "phase.assemble_videos": "Assemblage des vidéos", "phase.update_download": "Téléchargement de la mise à jour ({mo} Mo)", "phase.update_install": "Installation de la mise à jour", "phase.step_download": "Téléchargement", "phase.step_merge": "Fusion", "phase.cloud_section": "Cloud de l'abonnement", "phase.usb_section": "Stockage local : {hub}", "live.querying": "Interrogation du système Blink…", "live.count": "{n} caméra(s) · {m} avec détection active", "system.armed": "Système armé", "system.disarmed": "Système désarmé", "camera.offline": "HORS LIGNE", "camera.noeffect": "sans effet, système désarmé", "camera.detection.on": "Détection active", "camera.detection.off": "Détection coupée", "camera.wake": "Réveiller", "camera.waking": "Réveil…", "camera.wake.title": "Réveille la caméra maintenant (prend une photo). Consomme un peu de batterie, jusqu'à 2 minutes.", "camera.snapshot": "Photo", "camera.snapshotting": "Photo…", "camera.snapshot.saved": "Photo enregistrée ✓", "camera.snapshot.title": "Prend une photo maintenant et la garde dans Photos. Consomme un peu de batterie, jusqu'à 2 minutes.", "camera.continuous": "Direct continu", "camera.continuous.title": "Relance le direct chaque fois que Blink le ferme, au bout d'environ 6 minutes, jusqu'à ce que vous cliquiez sur Arrêter. Une pause d'une dizaine de secondes marque chaque relance.", "camera.continuous.title.battery": "Relance le direct chaque fois que Blink le ferme, au bout d'environ 6 minutes, jusqu'à ce que vous cliquiez sur Arrêter. Une pause d'une dizaine de secondes marque chaque relance. Sur cette caméra à batterie, un direct sans fin la vide vite.", "camera.battery": "batterie {v}", "camera.wifi": "Wi-Fi {v} dBm", "camera.lfr": "liaison module {v}", "camera.measured.at": "relevé à {v}", "camera.measured.on": "relevé du {d} à {h}", "camera.firmware": "micrologiciel {v}", "camera.noclips": "aucun clip récupéré", "camera.clipssource": "clips : {v}", "camera.none": "—", "watch.live": "Voir en direct", "watch.retry": "Réessayer", "watch.stop": "Arrêter", "watch.noh264": "Ce navigateur ne propose pas H.264 pour WebRTC. Vérifiez OpenH264 dans Firefox, ou choisissez MSE dans les réglages.", "watch.waking": "Réveil de la caméra…", "watch.waking.seconds": "Réveil de la caméra… {s} s", "watch.waking.slow": "Réveil de la caméra… {s} s (une caméra sur batterie est plus lente)", "watch.reconnecting": "Reconnexion…", "live.fullscreen.title": "Agrandir en plein écran", "live.fullscreen.title.exit": "Quitter le plein écran", "live.record.start": "Enregistrer", "live.record.stop": "Arrêter l'enregistrement", "live.record.title": "Enregistre ce direct sur le disque (dossier Blink_Direct)", "ronde.button": "▶ Ronde…", "ronde.button.title": "Les directs en plein écran, une caméra après l'autre", "ronde.titre": "Ronde des directs", "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.", "ronde.cameras": "Caméras de la ronde", "ronde.secondes": "Secondes par caméra", "ronde.plage": "Seulement de", "ronde.plage.a": "à", "ronde.plage.aide": "Laissez les heures vides pour toute la journée.", "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.", "ronde.demarrer": "Démarrer", "ronde.suivante": "Suivante ⏭", "ronde.arreter": "■ Arrêter", "ronde.pause": "Ronde en pause jusqu'à {debut}.", "ronde.aucune": "Aucune caméra en ligne dans la ronde.", "ronde.waiting": "En attente de connexion", "watch.noimage": "Aucune image reçue. La caméra n'a pas répondu.", "watch.interrupted": "Le direct s'est interrompu. Vous pouvez le relancer.", "watch.refused": "Le flux a été refusé par le serveur.", "watch.refused.code": "Le flux a été refusé par le serveur ({code}).", "watch.refused.retry": "Flux refusé. Un direct précédent finit peut-être de se fermer : réessayez dans quelques secondes.", "watch.codec.unsupported": "Codec non supporté par ce navigateur : {codec}", "command.sending": "Envoi de la commande…", "clips.none.filtered": "Aucun clip ne correspond à ce filtre.", "clips.none.ever": "Aucun clip récupéré pour l'instant.
Le téléchargement tourne déjà en arrière-plan (clé USB toutes les 10 min, cloud toutes les minutes) : les clips apparaîtront ici sans rien faire. Vérifiez qu'une clé USB est branchée sur le module : sans elle, les enregistrements ne vont que dans le cloud de l'abonnement Blink, que cet outil ne lit pas.", "direct.none.ever": "Aucun enregistrement du direct pour l'instant.
Ouvrez Direct, cliquez sur Enregistrer pendant qu'une caméra joue : il apparaîtra ici.", "clips.window": "{m}/{total} clips", "clips.previous": "Précédent", "clips.next": "Suivant", "clips.first": "Première", "clips.last": "Dernière", "clips.go.page": "Aller à la page", "clips.go": "Aller", "clips.per.page": "Clips par page", "clips.all": "Tous", "clips.page": "Page {page}/{pages} · {total} clips", "filtre.showHidden": "Afficher les caméras masquées", "reglages.tailleCartes": "Taille des vignettes", "reglages.tailleCartes.petites": "Petites", "reglages.tailleCartes.moyennes": "Moyennes", "reglages.tailleCartes.grandes": "Grandes", "reglages.tailleCartes.tres_grandes": "Très grandes", "reglages.barrePagination": "Afficher la barre de pagination en haut de la liste", "reglages.barrePagination.title": "Masquée, la barre du bas de la liste reste disponible pour changer de page. Propre à ce navigateur.", "reglages.masquees": "Caméras masquées", "reglages.masquees.hint": "Une caméra masquée disparaît des listes de cette page (clips, vidéos, photos, direct) mais continue d'enregistrer et de télécharger. Réglage propre à ce navigateur.", "masquees.none": "Aucune caméra connue pour l'instant.", "live.toutesMasquees": "Toutes les caméras de ce système sont masquées.", "range.title": "Période", "range.today": "Aujourd'hui (24 h)", "range.week": "Cette semaine (7 j)", "range.month": "Ce mois-ci", "range.2months": "2 derniers mois", "range.all": "Tout l'historique", "range.custom": "Période personnalisée", "range.custom.depuis": "depuis {v}", "range.custom.jusqua": "jusqu'au {v}", "range.custom.hint": "Ou une plage précise, à l'heure près :", "filtre.button": "🔍 Filtre", "filtre.button.title": "Filtrer", "filtre.title": "Filtre", "filtre.camera": "Caméra", "filtre.groupBy": "Grouper par", "filtre.groupBy.camera": "Caméra", "filtre.groupBy.day": "Jour", "range.from": "Du", "range.to": "au", "range.apply": "Filtrer", "videos.count": "{n} vidéo(s) · {duree} au total", "videos.none": "Aucune vidéo assemblée. Lancez une actualisation.", "videos.download": "Télécharger", "pictures.count": "{n} photo(s)", "pictures.none": "Aucune photo pour l'instant. Prenez-en une depuis Direct, ou via le webhook.", "pictures.delete": "Supprimer", "pictures.delete.title": "Supprimer cette photo du disque.", "pictures.delete.confirm": "Supprimer cette photo ?", "clip.resume": "Reprendre", "clip.discard": "Écarter", "clip.discard.title": "Retirer ce clip des vidéos assemblées (quotidienne, hebdomadaire, mensuelle). La copie téléchargée reste sur le disque.", "clip.resume.title": "Réinclure ce clip dans les prochains assemblages.", "clip.deleteSource": "Supprimer", "clip.deleteSource.pending": "Suppression…", "clip.deleteSource.title": "Supprimer ce clip de sa source (stockage local ou cloud de l'abonnement). La copie déjà téléchargée ici n'est pas touchée. Peut prendre jusqu'à une minute pour le stockage local.", "selection.apply": "✓ Appliquer ({n})", "selection.page": "Cette page :", "selection.page.discard": "Tout écarter", "selection.page.discard.title": "Cocher (ou décocher) « Écarter » sur tous les clips de cette page. Rien n'est fait avant Appliquer.", "selection.page.delete": "Tout supprimer", "selection.page.delete.title": "Cocher (ou décocher) « Supprimer » sur tous les clips de cette page. Rien n'est supprimé avant Appliquer et sa confirmation.", "selection.confirm.suppression": "{n} clip(s) vont être supprimés de leur source (stockage local USB/microSD ou cloud de l'abonnement). Les copies déjà téléchargées ne sont pas touchées. Continuer ?", "selection.partial": "{n} suppression(s) ont échoué ou n'ont rien trouvé à supprimer (déjà retiré ailleurs). Le reste de la sélection a été appliqué.", "refresh.starting": "Démarrage…", "refresh.errors": "Terminé avec des erreurs", "refresh.disconnected": "\\nConnexion interrompue.\\n", }, en: { "view.live": "Live Views", "view.direct": "Live Recordings", "view.clips": "Detection Recordings", "view.daily": "Detection Daily", "view.weekly": "Detection Weekly", "view.monthly": "Detection Monthly", "view.pictures": "Pictures", "filter.allcameras": "all cameras", "btn.refresh": "↻ Refresh", "btn.reglages": "⚙ Settings…", "btn.reglages.title": "Settings", "entete.pid": "Server PID {pid}", "update.installing": "Install {version}", "update.title": "Version {version} published. Download, stop and restart are automatic.", "update.updating": "Updating…", "update.progress": "Updating to {version}: downloading, then restarting…", "update.failed": "The update could not be started: {erreur}", "passages.updated": "updated {heure}", "passages.new.one": " · {n} new clip, click Refresh", "passages.new.many": " · {n} new clips, click Refresh", "auth.title": "Blink login", "auth.title.2fa": "Two-step verification", "auth.hint": "The password is only used to open the session; only the tokens are stored, never the password.", "auth.hint.2fa": "Blink just sent a code. Enter it to finish logging in.", "auth.email": "Email address", "auth.password": "Password", "auth.show": "Show", "auth.hide": "Hide", "auth.show.aria": "Show password", "auth.hide.aria": "Hide password", "auth.code": "Code received by SMS or email", "auth.cancel": "Cancel", "auth.ok": "Log in", "auth.validate": "Confirm", "auth.connecting": "Signing in…", "auth.failed": "Login failed.", "reglages.title": "Settings", "reglages.tab.general": "General", "reglages.tab.video": "Video", "reglages.tab.acces": "Access & automation", "reglages.tab.alertes": "Alerts", "reglages.initial.hint": "Check the data folder and time zone in particular. No clip will be downloaded until you apply these settings.", "reglages.autostart": "Start monitoring automatically", "reglages.autostart.title": "Starts the web server and clip processing in the background when your session opens (at boot for a Linux service with lingering); does not open this page by itself", "reglages.auto": "Automatic page refresh", "reglages.auto.title": "Reload the list as soon as clips arrive", "reglages.showOut": "Show discarded clips", "reglages.serveur": "Server port", "dossier.absent": "Videos folder not found: {dossier}. Nothing is written elsewhere while waiting for it. Mount its disk, or choose another folder in Settings.", "reglages.storageDir": "Videos folder", "reglages.storageDir.placeholder": "C:/path/to/the/folder", "reglages.storageDir.hint": "Clips, assembled videos, lives and snapshots. Does not move those already saved at the old location: do it yourself if you change this path. Empty = default location, Documents/blink2video.", "reglages.storageDir.browse": "Browse…", "reglages.storageDir.browse.unavailable": "Folder picker unavailable on this machine: type the path directly.", "reglages.accesDistant": "Remote access over a mesh VPN", "reglages.accesDistant.hint": "To reach this instance directly from a mesh VPN (Tailscale, WireGuard), no reverse proxy in front.", "reglages.accesDistant.hint.text": "Use together with the BLINK_BIND environment variable set to the listening address (see the README, \"Reaching it remotely\" section): this setting alone, without it, changes nothing about who can reach the interface. Several values allowed, comma-separated: hostnames, IP addresses or subnets (192.168.1.0/24). Only exact names and addresses also allow embedding the page in an iframe.", "reglages.trustedHost": "Trusted host", "reglages.cadence": "Camera polling interval", "reglages.usb": "Local storage (minutes)", "reglages.cloud": "Cloud (minutes)", "reglages.video": "Video", "reglages.timestamp": "Burn the date and time into the image", "reglages.timestamp.style.hint": "Empty = size automatically scaled to the video's height. Color: a name (white, yellow…) or hex code, optionally with transparency (e.g. white@0.8).", "reglages.fontSize": "Font size", "reglages.fontColor": "Color", "reglages.boxOpacity": "Box opacity", "reglages.clock": "Time format", "reglages.clock.24h": "24h (14:05:09)", "reglages.clock.12h": "12h (02:05:09 PM)", "reglages.timezone": "Time zone", "reglages.cameraAudio.title": "Camera audio", "reglages.cameraAudio": "Include camera audio in Live View and recordings", "reglages.cameraAudio.hint": "With audio enabled, live sessions use compatible MSE mode and may take longer to start. Playback starts muted; listening does not change recorded audio.", "reglages.cameraAudio.track": "Audio channel", "reglages.cameraAudio.primary": "Primary", "reglages.cameraAudio.alternate": "Alternate (if the primary stays silent)", "live.audio.listen": "🔇 Listen", "live.audio.mute": "🔊 Mute", "live.audio.unavailable": "🔇 Audio unavailable", "live.audio.waiting": "🔇 Waiting for audio", "reglages.liveProtocol": "Live view protocol", "reglages.liveProtocol.webrtc": "WebRTC (fast)", "reglages.liveProtocol.mse": "MSE (compatible)", "reglages.liveAutoStop": "Auto-stop live view (s)", "reglages.liveAutoStop.hint": "Stops the live view on its own after this delay, up to 420 s, even in continuous live. Empty or 0: no auto-stop; Blink closes every live view by itself after about 6 minutes, and a camera's \"Continuous live\" box starts it again.", "reglages.archivage": "Per-camera time-based video creation", "reglages.downloadAuto": "Download clips automatically", "reglages.downloadAuto.hint": "Unchecked, no clip is fetched or stored anymore: useful to keep only the live view. The cadences below then have no effect.", "reglages.mergeJour": "Daily", "reglages.mergeSemaine": "Weekly", "reglages.mergeMois": "Monthly", "reglages.conservation": "Retention (days, 0 = forever)", "reglages.conservation.hint": "An assembled video is rebuilt as long as what it is made of exists: raw clips are deleted a whole day at a time once the daily video is made, a daily video once its raw clips are gone, a weekly or monthly video once its daily videos are gone. Each period must therefore be at least that of its source.", "reglages.conservation.clips": "Raw clips", "reglages.conservation.jour": "Daily videos", "reglages.conservation.semaine": "Weekly videos", "reglages.conservation.mois": "Monthly videos", "reglages.conservation.directs": "Live recordings", "reglages.conservation.photos": "Snapshots", "reglages.archivage.hint": "Weekly and Monthly are assembled from the Daily: unchecking \u201cDaily\u201d also disables the other two.", "reglages.alertes": "Mute alerts", "reglages.suppressionAuto": "Automatic deletion after download", "reglages.webhook": "Webhook picture", "reglages.webhook.hint": "Triggers a picture remotely (home automation) without opening this page.", "reglages.webhook.hint.text": "Call this URL with GET, replacing NOM_CAMERA with a camera's exact name, to trigger a picture remotely. The secret is part of the URL: keep it private.", "reglages.webhook.url": "URL", "reglages.webhook.regenerer": "Regenerate the secret", "reglages.webhook.regenerer.confirm": "Regenerating will invalidate the current URL: any existing automation will need the new one. Continue?", "reglages.webhookNotif": "Webhook notification", "reglages.webhookNotif.hint": "Calls this URL with POST on every new clip or picture, once the file is actually done writing to disk.", "reglages.webhookNotif.hint.text": "Sends a JSON POST (camera, path, type) to the URL below every time a clip or picture is ready. Best-effort, no retry: a missed notification never holds up downloading.", "reglages.webhookNotifUrl": "Notification URL", "reglages.entete": "Page header", "reglages.entete.hint": "To tell this installation from another one: the title and icon show at the top of the page and in the browser tab, in every browser. Empty: blink2video.", "reglages.entete.titre": "Title", "reglages.entete.icone": "Icon (an emoji)", "reglages.hint": "Settings only take effect on restart: \u201cApply\u201d saves and restarts. Changing the port redirects this page to the new address.", "reglages.apply": "Apply", "reglages.restarting": "Restarting…", "reglages.restart": "Restart", "reglages.restart.hint": "Restarts without changing any setting, for example to pick up an update already in place.", "reglages.checkUpdates": "Check for updates", "reglages.checkUpdates.hint": "Asks GitHub right away whether a newer version is out. Without this button, blink2video checks by itself every hour.", "reglages.checkUpdates.running": "Checking…", "reglages.checkUpdates.none": "blink2video {version} is up to date.", "reglages.checkUpdates.found": "Version {version} is out: the \"Install {version}\" button is at the top of the page.", "reglages.checkUpdates.offline": "GitHub did not answer. Try again in a moment.", "reglages.restarting.settings": "Restarting with the new settings…", "reglages.portchange": "Port changed: redirecting to {url} once the shutdown is confirmed…", "reglages.stop": "Stop camera monitoring", "reglages.close": "Close", "reglages.error.cadence": "Intervals must be at least 1 minute.", "reglages.error.port": "The port must be between 1 and 65535.", "reglages.error.timezone": "The time zone cannot be empty.", "stop.stopping": "Stopping…", "stop.stopped": "Shutdown requested. This page will become unavailable.", "stop.failed": "Shutdown did not complete; blink2video is still responding.", "reglages.restartFailed": "Restart did not begin. Check the logs and try again.", "sourdine.loading": "Loading…", "sourdine.unavailable": "Camera list unavailable.", "sourdine.none": "No known camera yet.", "suppressionAuto.loading": "Loading…", "suppressionAuto.unavailable": "Camera list unavailable.", "suppressionAuto.none": "No known camera yet.", "suppressionAuto.hint": "Once a clip is successfully downloaded, it is deleted from its source (local USB/microSD storage or subscription cloud, depending on the camera).", "phase.inventory_clips": "Finding clips to download", "phase.download_clips": "Downloading clips", "phase.prepare_clips": "Preparing clips", "phase.assemble_videos": "Assembling videos", "phase.update_download": "Downloading the update ({mo} MB)", "phase.update_install": "Installing the update", "phase.step_download": "Downloading", "phase.step_merge": "Merging", "phase.cloud_section": "Subscription cloud", "phase.usb_section": "Local storage: {hub}", "live.querying": "Querying the Blink system…", "live.count": "{n} camera(s) · {m} with detection on", "system.armed": "System armed", "system.disarmed": "System disarmed", "camera.offline": "OFFLINE", "camera.noeffect": "no effect, system disarmed", "camera.detection.on": "Detection on", "camera.detection.off": "Detection off", "camera.wake": "Wake", "camera.waking": "Waking…", "camera.wake.title": "Wakes the camera now (takes a photo). Uses a bit of battery, up to 2 minutes.", "camera.snapshot": "Snapshot", "camera.snapshotting": "Snapshot…", "camera.snapshot.saved": "Snapshot saved ✓", "camera.snapshot.title": "Takes a picture now and keeps it under Pictures. Uses a bit of battery, up to 2 minutes.", "camera.continuous": "Continuous live", "camera.continuous.title": "Starts the live view again each time Blink closes it, after about 6 minutes, until you click Stop. A pause of about ten seconds marks each restart.", "camera.continuous.title.battery": "Starts the live view again each time Blink closes it, after about 6 minutes, until you click Stop. A pause of about ten seconds marks each restart. On this battery camera, an endless live view drains it quickly.", "camera.battery": "battery {v}", "camera.wifi": "Wi-Fi {v} dBm", "camera.lfr": "module link {v}", "camera.measured.at": "measured at {v}", "camera.measured.on": "measured on {d} at {h}", "camera.firmware": "firmware {v}", "camera.noclips": "no clip retrieved", "camera.clipssource": "clips: {v}", "camera.none": "—", "watch.live": "View live", "watch.retry": "Retry", "watch.stop": "Stop", "watch.noh264": "This browser does not offer H.264 for WebRTC. Check OpenH264 in Firefox, or select MSE in settings.", "watch.waking": "Waking the camera…", "watch.waking.seconds": "Waking the camera… {s} s", "watch.waking.slow": "Waking the camera… {s} s (a battery camera is slower)", "watch.reconnecting": "Reconnecting…", "live.fullscreen.title": "Expand fullscreen", "live.fullscreen.title.exit": "Exit fullscreen", "live.record.start": "Record", "live.record.stop": "Stop recording", "live.record.title": "Records this live view to disk (Blink_Direct folder)", "ronde.button": "▶ Rotation…", "ronde.button.title": "Live views in fullscreen, one camera after another", "ronde.titre": "Rotating live view", "ronde.explication": "Fullscreen, one camera after another: each one stays on screen for the chosen time, counted from its first image.", "ronde.cameras": "Cameras in the rotation", "ronde.secondes": "Seconds per camera", "ronde.plage": "Only from", "ronde.plage.a": "to", "ronde.plage.aide": "Leave the hours empty for the whole day.", "ronde.batterie": "Each turn wakes the camera: on battery, the rotation drains it quickly. While it runs, local storage (USB) downloads wait for a pause or the end.", "ronde.demarrer": "Start", "ronde.suivante": "Next ⏭", "ronde.arreter": "■ Stop", "ronde.pause": "Rotation paused until {debut}.", "ronde.aucune": "No online camera in the rotation.", "ronde.waiting": "Waiting for connection", "watch.noimage": "No image received. The camera did not respond.", "watch.interrupted": "The live view was interrupted. You can restart it.", "watch.refused": "The stream was refused by the server.", "watch.refused.code": "The stream was refused by the server ({code}).", "watch.refused.retry": "Stream refused. A previous live view may still be closing: try again in a few seconds.", "watch.codec.unsupported": "Codec not supported by this browser: {codec}", "command.sending": "Sending command…", "clips.none.filtered": "No clip matches this filter.", "clips.none.ever": "No clip retrieved yet.
Download is already running in the background (USB every 10 min, cloud every minute): clips will appear here on their own. Check that a USB drive is plugged into the module: without it, recordings only go to the Blink subscription cloud, which this tool does not read.", "direct.none.ever": "No direct recording yet.
Open Direct, click Record while a camera is playing: it will appear here.", "clips.window": "{m}/{total} clips", "clips.previous": "Previous", "clips.next": "Next", "clips.first": "First", "clips.last": "Last", "clips.go.page": "Go to page", "clips.go": "Go", "clips.per.page": "Clips per page", "clips.all": "All", "clips.page": "Page {page}/{pages} · {total} clips", "filtre.showHidden": "Show hidden cameras", "reglages.tailleCartes": "Thumbnail size", "reglages.tailleCartes.petites": "Small", "reglages.tailleCartes.moyennes": "Medium", "reglages.tailleCartes.grandes": "Large", "reglages.tailleCartes.tres_grandes": "Extra large", "reglages.barrePagination": "Show the pagination bar at the top of the list", "reglages.barrePagination.title": "When hidden, the bar at the bottom of the list stays available to change page. Specific to this browser.", "reglages.masquees": "Hidden cameras", "reglages.masquees.hint": "A hidden camera disappears from the lists on this page (clips, videos, pictures, live) but keeps recording and downloading. This setting is specific to this browser.", "masquees.none": "No known camera yet.", "live.toutesMasquees": "All cameras of this system are hidden.", "range.title": "Period", "range.today": "Today (24h)", "range.week": "This week (7d)", "range.month": "This month", "range.2months": "Last 2 months", "range.all": "All history", "range.custom": "Custom period", "range.custom.depuis": "from {v}", "range.custom.jusqua": "until {v}", "range.custom.hint": "Or a precise range, down to the hour:", "filtre.button": "🔍 Filter", "filtre.button.title": "Filter", "filtre.title": "Filter", "filtre.camera": "Camera", "filtre.groupBy": "Group by", "filtre.groupBy.camera": "Camera", "filtre.groupBy.day": "Day", "range.from": "From", "range.to": "to", "range.apply": "Filter", "videos.count": "{n} video(s) · {duree} total", "videos.none": "No assembled video. Run a refresh.", "videos.download": "Download", "pictures.count": "{n} picture(s)", "pictures.none": "No picture yet. Take one from Live, or via the webhook.", "pictures.delete": "Delete", "pictures.delete.title": "Delete this picture from disk.", "pictures.delete.confirm": "Delete this picture?", "clip.resume": "Resume", "clip.discard": "Discard", "clip.discard.title": "Remove this clip from the assembled videos (daily, weekly, monthly). The downloaded copy stays on disk.", "clip.resume.title": "Include this clip in future assemblies again.", "clip.deleteSource": "Delete", "clip.deleteSource.pending": "Deleting…", "clip.deleteSource.title": "Delete this clip from its source (local storage or subscription cloud). The copy already downloaded here is not affected. Can take up to a minute for local storage.", "selection.apply": "✓ Apply ({n})", "selection.page": "This page:", "selection.page.discard": "Discard all", "selection.page.discard.title": "Tick (or untick) “Discard” on every clip of this page. Nothing happens before Apply.", "selection.page.delete": "Delete all", "selection.page.delete.title": "Tick (or untick) “Delete” on every clip of this page. Nothing is deleted before Apply and its confirmation.", "selection.confirm.suppression": "{n} clip(s) will be deleted from their source (local USB/microSD storage or subscription cloud). Copies already downloaded here are not affected. Continue?", "selection.partial": "{n} deletion(s) failed or found nothing to delete (already removed elsewhere). The rest of the selection was applied.", "refresh.starting": "Starting…", "refresh.errors": "Finished with errors", "refresh.disconnected": "\\nConnection lost.\\n", }, }; let _lang = "fr"; function t(k) { return (I18N[_lang] && I18N[_lang][k]) || I18N.fr[k] || k; } function tf(k, v) { let s = t(k); for (const p in (v || {})) s = s.split("{" + p + "}").join(v[p]); return s; } function detectLang() { return (navigator.language || "en").toLowerCase().startsWith("fr") ? "fr" : "en"; } function applyI18n() { document.documentElement.lang = _lang; document.querySelectorAll("[data-i18n]").forEach((el) => { const v = t(el.dataset.i18n); if (v) el.textContent = v; }); document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { const v = t(el.dataset.i18nPlaceholder); if (v) el.placeholder = v; }); document.querySelectorAll("[data-i18n-title]").forEach((el) => { const v = t(el.dataset.i18nTitle); if (v) el.title = v; }); document.querySelectorAll("[data-lang-btn]").forEach((b) => b.classList.toggle("active", b.dataset.langBtn === _lang)); } function setLang(code, persist) { _lang = code === "en" ? "en" : "fr"; applyI18n(); // Valeur dynamique (PID du process), donc rendue ici plutôt que par // data-i18n, qui ne sait interpoler aucun paramètre dans sa traduction. $("pidServeur").textContent = tf("entete.pid", { pid: BLINK_PID }); // Le bouton « afficher/masquer » le mot de passe suit son propre état // (masqué ou non), qu'applyI18n ne connaît pas : ré-appliqué ici plutôt // que par data-i18n, qui écraserait « Masquer » par « Afficher » si le // mot de passe était déjà visible au moment du changement de langue. const pass = $("pass"); if (pass) { const masque = pass.type === "password"; $("passToggle").textContent = t(masque ? "auth.show" : "auth.hide"); $("passToggle").setAttribute("aria-label", t(masque ? "auth.show.aria" : "auth.hide.aria")); } // Rendu par JS plutôt que par data-i18n : reconstruire pour que la langue // s'applique immédiatement, sans attendre le prochain événement qui // déclencherait normalement ce rendu. fill() gère un tableau vide sans // problème (l'option « tout » reste posée), donc pas de garde ici. if (typeof fill === "function") { fill($("camera"), camerasConnues(), t("filter.allcameras")); } if (typeof render === "function" && data.clips) render(); if (typeof renderLive === "function" && system) renderLive(); // Un direct actif gèle la grille (voir renderLive()) : le bouton plein // écran déjà posé survit donc au changement de langue sans se refaire, // et doit être retraduit ici plutôt que de rester dans l'ancienne langue. if (typeof syncExpandButtons === "function") syncExpandButtons(); for (const video of document.querySelectorAll(".live video")) actualiserBoutonAudio(video); // #sourdineListe porte data-i18n="sourdine.loading" en repli HTML : // applyI18n() vient d'écraser ses cases à cocher réelles par ce texte de // chargement si le panneau est ouvert pendant la bascule de langue. // Reconstruire immédiatement plutôt que de laisser la liste figée ainsi // jusqu'à la prochaine ouverture du panneau. if (typeof chargerSourdine === "function" && $("reglages")?.open) chargerSourdine(); if (typeof chargerCamerasMasquees === "function" && $("reglages")?.open) chargerCamerasMasquees(); if (typeof chargerSuppressionAuto === "function" && $("reglages")?.open) chargerSuppressionAuto(); if (persist) localStorage.setItem("lang", _lang); // Envoyé à chaque appel, pas seulement un choix explicite (persist) : // le menu du systray (tray.py) lit cette valeur pour s'afficher dans la // même langue que la page, y compris quand elle vient de detectLang() // et n'a jamais été choisie à la main. Best-effort, jamais bloquant : une // page ouverte hors-ligne ou un onglet d'arrière-plan ne doit pas faire // échouer l'affichage. fetch("/api/lang", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lang: _lang }) }).catch(() => {}); } // ── Préférences d'affichage propres à ce navigateur (issue #40) ─────────── // Comme la taille de page et l'actualisation automatique : mémorisées dans // localStorage, donc propres à chaque appareil (un téléphone et un PC peuvent // vouloir des choses différentes), sans réglage serveur ni redémarrage. Masquer // une caméra ne la touche pas : elle continue d'enregistrer et de télécharger. const CLE_CAMERAS_MASQUEES = "blink2video.camerasMasquees"; const CLE_TAILLE_CARTES = "blink2video.tailleCartes"; const TAILLES_CARTES = { petites: 220, moyennes: 320, grandes: 480, tres_grandes: 720 }; const TAILLE_CARTES_DEFAUT = "moyennes"; let camerasMasquees = restaurerCamerasMasquees(); // Dévoilement temporaire (case de la fenêtre de filtre) : jamais mémorisé, la // liste redevient masquée au prochain chargement. let montrerMasquees = false; let tailleCartes = restaurerTailleCartes(); function restaurerCamerasMasquees() { try { const liste = JSON.parse(localStorage.getItem(CLE_CAMERAS_MASQUEES) || "[]"); if (Array.isArray(liste)) return new Set(liste.filter((nom) => typeof nom === "string")); } catch (erreur) {} return new Set(); } function memoriserCamerasMasquees() { try { localStorage.setItem(CLE_CAMERAS_MASQUEES, JSON.stringify([...camerasMasquees].sort())); } catch (erreur) {} } function estMasquee(nom) { return camerasMasquees.has(nom) && !montrerMasquees; } function restaurerTailleCartes() { try { const nom = localStorage.getItem(CLE_TAILLE_CARTES); if (Object.prototype.hasOwnProperty.call(TAILLES_CARTES, nom)) return nom; } catch (erreur) {} return TAILLE_CARTES_DEFAUT; } function appliquerTailleCartes() { document.documentElement.style.setProperty("--carte-min", `${TAILLES_CARTES[tailleCartes]}px`); } appliquerTailleCartes(); // Ordre naturel des noms de caméras (« 2-Escalier » avant « 10-Cave »), dans // la langue de la page. Un collateur par langue : en construire un par // comparaison coûterait cher sur une liste longue. const _collateurs = {}; function comparerNoms(a, b) { if (!_collateurs[_lang]) { _collateurs[_lang] = new Intl.Collator(_lang, { numeric: true, sensitivity: "base" }); } return _collateurs[_lang].compare(a, b); } // Ordre des caméras dans la vue Direct (issue #40) : celles en ligne d'abord, // puis les hors ligne, qui n'ont rien à montrer ; chaque groupe dans l'ordre // naturel des noms. La grille et la ronde le partagent. function comparerCamerasDirect(a, b) { return (a.offline ? 1 : 0) - (b.offline ? 1 : 0) || comparerNoms(a.name, b.name); } function majCaseMasquees() { $("hiddenLabel").hidden = camerasMasquees.size === 0; if (!camerasMasquees.size) { montrerMasquees = false; $("showHidden").checked = false; } } function chargerCamerasMasquees() { const conteneur = $("camerasMasqueesListe"); const noms = camerasPourMasquage(); if (!noms.length) { conteneur.textContent = t("masquees.none"); return; } conteneur.replaceChildren(); for (const nom of noms) { const label = document.createElement("label"); const case_ = document.createElement("input"); case_.type = "checkbox"; case_.checked = camerasMasquees.has(nom); case_.onchange = () => { if (case_.checked) camerasMasquees.add(nom); else camerasMasquees.delete(nom); memoriserCamerasMasquees(); majCaseMasquees(); fill($("camera"), camerasConnues(), t("filter.allcameras")); render(); }; label.appendChild(case_); label.append(` ${nom}`); conteneur.appendChild(label); } } // data.cameras (issu de /api/clips) ne connaît qu'une caméra qui a au moins // un clip de détection téléchargé. Une caméra seulement vue en direct // (bouton Enregistrement Direct, jamais de clip) en était donc absente : ses // vidéos s'affichaient bien groupées sous son nom, mais impossible de la // choisir dans le filtre pour s'y limiter (constaté en réel, 2026-09-04). // L'union avec les caméras réellement présentes dans chaque catégorie de // videos (daily/weekly/monthly/direct) couvre aussi bien ce cas que les // mêmes catégories pour la même raison, sans rien de spécifique à direct. function toutesLesCameras() { const ensemble = new Set(data.cameras || []); for (const liste of Object.values(videos)) { for (const v of liste) ensemble.add(v.camera); } // Une caméra sans aucun clip ni vidéo assemblée (webhook seul, jamais // armée) doit quand même apparaître ici : sinon rien ne permet de filtrer // sur elle dans Photos. for (const s of snapshots) ensemble.add(s.camera); return [...ensemble]; } // Le filtre ne propose pas les caméras masquées (sauf dévoilement temporaire), // triées dans l'ordre naturel : « 2-Escalier » avant « 10-Cave ». function camerasConnues() { return toutesLesCameras().filter((nom) => !estMasquee(nom)).sort(comparerNoms); } // Liste de la fenêtre des réglages : toutes les caméras, masquées ou non, y // compris celles vues seulement en direct et celles masquées puis disparues. function camerasPourMasquage() { const ensemble = new Set([...toutesLesCameras(), ...camerasMasquees]); for (const s of (system && system.systems) || []) { for (const c of s.cameras) ensemble.add(c.name); } return [...ensemble].sort(comparerNoms); } // c.battery (cameraCard, plus bas) n'est renseigné que si Blink en publie // un pour cette caméra : une Blink Mini, câblée en permanence, n'en a pas // - vu en vrai (2026-09-04) : le texte de réveil prolongé ("une caméra sur // batterie est plus lente") s'affichait quand même dessus, une affirmation // fausse pour ce modèle. Inconnue (systeme pas encore charge) => true : on // garde alors l'ancien texte plutot que d'affirmer a tort le contraire. function cameraSurBatterie(name) { for (const s of (system && system.systems) || []) { const c = s.cameras.find((cam) => cam.key === name); if (c) return Boolean(c.battery); } return true; } function fill(select, values, all) { const kept = select.value; select.replaceChildren(); const optionToutes = document.createElement("option"); optionToutes.value = ""; optionToutes.textContent = all; select.append(optionToutes); for (const value of values) { const option = document.createElement("option"); option.value = value; option.textContent = value; select.append(option); } if (values.includes(kept)) select.value = kept; } // "clips" et "direct" partagent data.clips (voir collect(), serve.py) : le // champ "kind" de chaque entrée décide laquelle des deux vues la montre. function visible() { const kindAttendu = $("view").value === "direct" ? "direct" : "clip"; return data.clips.filter((c) => c.kind === kindAttendu && (!$("camera").value || c.camera === $("camera").value) && !estMasquee(c.camera) && ($("showOut").checked || !c.excluded)); } function duration(seconds) { const s = Math.round(seconds); const parts = [Math.floor(s / 3600), Math.floor(s / 60) % 60, s % 60]; return (parts[0] ? parts : parts.slice(1)) .map((v, i) => (i ? String(v).padStart(2, "0") : v)).join(":"); } function render() { heuresDePassage(); const kind = $("view").value; // Clips et Clips Directs partagent data.clips et donc renderClips() // (2026-09-04, même présentation demandée pour les deux) ; Journalières/ // Hebdomadaires/Mensuelles restent sur renderVideos(kind), Direct (le // live) sur renderLive(). const carteClips = kind === "clips" || kind === "direct"; $("outLabel").hidden = !carteClips; // Même périmètre que la caméra : Clips, Clips Directs, Journalières, // Hebdomadaires, Mensuelles s'y filtrent toutes, seul Direct (le live) // n'en a pas l'usage. La période, elle, vaut pour Clips et Clips Directs // (/api/clips, les deux depuis data.clips) : daily/weekly/monthly ne la // lisent toujours pas. $("filtreButton").hidden = kind === "live"; // La ronde n'a de sens que dans le direct, et ne lui survit pas. $("rondeButton").hidden = kind !== "live"; if (kind !== "live") arreterRonde(); $("periodeSection").hidden = !carteClips; // Grouper par jour n'a de sens que pour les journalieres : hebdomadaires // et mensuelles n'ont qu'un seul fichier par semaine/mois et par camera, // regrouper par jour n'y changerait rien (issue #14 : "week and month // makes no sense" pour ce mode, dixit le rapporteur). Pas davantage avec // une caméra choisie (issue #37, voir regroupable()). majGroupBy(); $("filtreResume").textContent = carteClips ? resumeFiltre() : ""; // Le décompte n'a de sens que pour clips/direct ; renderClips() le repose // à chaque rendu, mais quitter cette vue doit l'effacer, pas le laisser // périmé derrière une autre vue. if (!carteClips) $("filtreCompte").textContent = ""; if (!carteClips) nettoyerLecteursClips(); if (kind === "live") return renderLive(); if (kind === "pictures") return renderPictures(); return carteClips ? renderClips() : renderVideos(kind); } // --- direct et armement ---------------------------------------------------- let system = null; let rafraichirVignettes = true; async function loadSystem(force) { if (system && !force) return renderLive(); // La vue peut avoir changé entre le déclenchement de cet appel et sa // résolution (bascule rapide vers Clips, ou déclenchement précoce au // chargement avant que la vue par défaut ne soit posée) : ne toucher au // DOM que si Direct est encore affiché, sinon la réponse tardive // écraserait une liste de clips déjà à l'écran sans que le menu déroulant // ne le laisse deviner. if ($("view").value === "live" && !nomsDirectsActifs().length && !ronde.boite) { $("list").innerHTML = `

${t("live.querying")}

`; $("count").textContent = ""; } try { system = await lireJSON(await fetch("/api/system")); } catch (error) { system = { error: String(error) }; } if ($("view").value === "live") renderLive(); // Ronde en cours à la fermeture de la page : elle reprend une fois les // caméras connues, pas avant (sans elles, elle attendrait 30 s sur // « aucune caméra »). if (rondeAReprendre && $("view").value === "live" && !system.error) { rondeAReprendre = false; demarrerRonde(); } } // Un calcul lancé par les boucles de fond, hors de cette page : le // téléchargement et l'assemblage publient leur avancement dans un fichier, seul // moyen pour la page d'apprendre que la machine travaille. Tant qu'il tourne, // le bouton reste inactif : un second calcul attendrait le même verrou, sans // rien avancer. let travailEnCours = false; let travailVisible = false; let actualisationLocale = false; // Boucle d'attente d'un redémarrage après clic sur « Mettre à jour » (voir // son onclick plus bas). montrerTravail() l'arrête depuis l'extérieur quand // la mise à jour conclut sans redémarrer (phase.update_noop) : sans ça, elle // attendrait un retour du serveur qui ne viendra jamais. let miseAJourAttente = null; let generationMaj = 0; // Le serveur ne connaît jamais la langue affichée (choix propre à chaque // onglet, en localStorage) : un libellé de phase arrive donc toujours en // français, accompagné d'une clé stable quand une traduction existe. Clé // absente ou inconnue de ce dictionnaire : le texte reçu reste affiché tel // quel plutôt qu'une chaîne vide, qui masquerait un travail réellement en // cours (ex. bug vécu en vrai : « Téléchargement des clips » figé en // français quelle que soit la langue choisie). function libellePhase(cle, texteBrut, valeurs) { if (!cle || !((I18N[_lang] && I18N[_lang][cle]) || I18N.fr[cle])) return texteBrut; return valeurs ? tf(cle, valeurs) : t(cle); } function montrerTravail(travail) { if (actualisationLocale) return; // notre propre barre parle déjà const visible = !!(travail && travail.quoi); if (!visible) { if (travailVisible) { $("work").classList.remove("on"); rechargerEnArrierePlan(); } travailEnCours = false; travailVisible = false; $("refresh").disabled = false; return; } if (travail.cle === "phase.update_noop") { // La mise à jour a conclu sans arrêter ni relancer le serveur (déjà à // jour, archive absente, git pull refusé...). L'onclick du bouton // n'attend qu'une chose, sa disparition-puis-retour : sans ce // déblocage explicite, il resterait bloqué sur « Mise à jour… » // indéfiniment alors que rien n'est en cours. if (miseAJourAttente !== null) { clearInterval(miseAJourAttente); miseAJourAttente = null; } const bouton = $("update"); delete bouton.dataset.encours; bouton.disabled = false; montrerMaj({ version: bouton.dataset.version }); gelerPendantMaj(false); } const termine = !!travail.termine; const actif = travail.actif === undefined ? !termine : !!travail.actif; const etaitActif = travailEnCours; travailEnCours = actif; travailVisible = true; $("refresh").disabled = actif; $("work").classList.add("on"); const total = travail.total || 0; const fait = travail.fait || 0; // Le téléchargement de la mise à jour avance en mégaoctets décimaux, pas en // éléments comptés : des entiers, et pas de « +1 » de rang (« 77.5/ // 82.67831420898438 », vu en vrai le 2026-10-17). const enMo = travail.cle === "phase.update_download"; const quoi = enMo ? libellePhase(travail.cle, travail.quoi, { mo: Math.round(total) }) : libellePhase(travail.cle, travail.quoi); // total === 1 : un pas unique, pas une mesure de progression (ex. « déjà à // jour », voir phase.update_noop) - une fraction « 1/1 (100 %) » n'y // ajouterait rien. if (total > 1) { $("bar").max = total; $("bar").value = fait; const plafond = enMo ? Math.round(total) : total; const courant = fait >= total ? plafond : (enMo ? Math.floor(fait) : fait + 1); $("phase").textContent = `${quoi} ${courant}/${plafond} (${Math.round((fait / total) * 100)} %)`; } else { $("bar").removeAttribute("value"); $("phase").textContent = quoi; } // Le N/N retenu dix secondes est visible, mais n'est plus un verrou : un // nouveau clic reste possible. Si la page avait vu le travail actif, sa fin // déclenche exactement le même rafraîchissement qu'une disparition directe. if (termine && etaitActif && !actif) rechargerEnArrierePlan(); } // Une version publiée plus récente que celle qui tourne : le bouton apparaît, // et il fait tout, du téléchargement à la relance. Pendant l'opération le // serveur s'arrête et revient : la page attend son retour, puis se recharge. function montrerMaj(neuve) { const bouton = $("update"); bouton.hidden = !(neuve && neuve.version); if (bouton.hidden || bouton.dataset.encours) return; bouton.dataset.version = neuve.version; bouton.textContent = tf("update.installing", { version: neuve.version }); bouton.title = tf("update.title", { version: neuve.version }); } // Contrôles capables d'envoyer une requête ou d'ouvrir un nouvel état // (réglages, filtre, sélection, passage en direct) pendant qu'une mise à // jour est en vol : gelés le temps de l'opération, faute de quoi un clic // ailleurs peut atterrir sur un serveur en train de s'arrêter puis de // revenir sous une autre version. « refresh » a déjà sa propre règle (lié à // n'importe quel travail actif dans montrerTravail) et n'a pas besoin d'y être. const CONTROLES_GELES_PENDANT_MAJ = ["reglagesButton", "filtreButton", "applyButton", "view"]; function gelerPendantMaj(gele) { for (const id of CONTROLES_GELES_PENDANT_MAJ) { const element = $(id); if (element) element.disabled = gele; } } // État du serveur vu depuis la page, pour attendre une relance (mise à jour, // Appliquer, Redémarrer) : // "absent" connexion refusée, ou 502/503/504 : un mandataire (nginx...) // devant le serveur répond à sa place tant qu'il est arrêté, sans // que fetch() lève quoi que ce soit ; // "autre" 403 : un autre processus répond, avec son propre jeton. C'est // le serveur relancé, plus vite que le sondage n'a pu le voir // absent ; // "present" toute autre réponse. async function etatServeur() { try { const reponse = await fetch("/api/status", { cache: "no-store" }); if (reponse.status === 403) return "autre"; if (reponse.status >= 502 && reponse.status <= 504) return "absent"; return "present"; } catch (erreur) { return "absent"; } } // Un pas de sondage : la relance est finie, et la page se recharge, quand le // serveur revient après avoir été vu absent, ou dès qu'un autre processus // répond (issue #35 : la page restait sur l'ancien serveur, et il fallait la // recharger à la main). suivi.parti dit si l'absence a été vue. async function sonderRelance(suivi) { const etat = await etatServeur(); if (etat === "absent") suivi.parti = true; else if (etat === "autre" || suivi.parti) location.reload(); } $("update").onclick = async () => { const generation = ++generationMaj; const bouton = $("update"); bouton.dataset.encours = "1"; bouton.disabled = true; bouton.textContent = t("update.updating"); // Le bouton reste utilisable tant que le serveur n'a pas confirmé le // lancement : une requête rejetée (serveur injoignable) ou une réponse // illisible le laissait grisé jusqu'au rechargement de la page (audit du // 2026-10-02, B13). let resultat; try { const reponse = await fetch("/api/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); resultat = await lireJSON(reponse); } catch (erreur) { resultat = { error: tf("update.failed", { erreur: String(erreur) }) }; } if (resultat.error) { alert(resultat.error); bouton.disabled = false; delete bouton.dataset.encours; montrerMaj({ version: bouton.dataset.version }); return; } gelerPendantMaj(true); $("phase").textContent = tf("update.progress", { version: resultat.version }); $("bar").removeAttribute("value"); $("work").classList.add("on"); $("refresh").disabled = true; // Le serveur va disparaître puis revenir sous sa nouvelle version. On teste // sa présence, et c'est son retour qui sert de fin de course. Une conclusion // qui ne redémarre rien (déjà à jour, échec...) ne déclenche jamais ça : // c'est phase.update_noop, vu par montrerTravail(), qui arrête alors cette // boucle et débloque le bouton depuis l'extérieur. const suivi = { parti: false }; miseAJourAttente = setInterval(() => sonderRelance(suivi), 2000); // Dernier filet si même phase.update_noop n'est jamais arrivé (ex. sous- // processus mort avant de pouvoir écrire quoi que ce soit) : mieux vaut un // bouton qui se débloque sans explication qu'un bouton mort pour de bon. setTimeout(() => { if (generation !== generationMaj || miseAJourAttente === null) return; clearInterval(miseAJourAttente); miseAJourAttente = null; bouton.disabled = false; delete bouton.dataset.encours; gelerPendantMaj(false); }, 900000); }; let dernierRechargementAuto = 0; // Rechargement déclenché par l'arrière-plan (nouveau clip détecté, ou fin // d'un calcul) plutôt que par un clic explicite : jamais plus d'une fois // toutes les 60 secondes, le rythme normal de veille de veiller(). Pendant // un gros lot en cours de traitement, chaque nouveau clip détecté ou chaque // bascule de travailEnCours pouvait sinon redéclencher un rechargement // complet de la grille (toutes les vignettes vidéo détruites et // reconstruites), perçu comme un clignotement (constaté en réel, 2026-08-27). function rechargerEnArrierePlan() { const maintenant = Date.now(); if (maintenant - dernierRechargementAuto < 60000) return; // load() remet à zéro toute sélection Écarter/Supprimer non encore // appliquée (voir son commentaire) : correct pour un rechargement voulu // par l'utilisateur, mais ici l'appelant est un événement de fond (fin de // travail suivi, nouveaux clips détectés) sans rapport avec ce qu'il est // en train de faire. Reporter au lieu d'écraser en silence ; le prochain // passage de heuresDePassage()/montrerTravail() retentera. const { exclure, inclure, supprimer } = calculerSelection(); if (exclure.length + inclure.length + supprimer.length > 0) return; // Même raison que le gel de renderLive() pour un direct actif (voir son // commentaire) : reconstruire #list détruit toute