Flux video temps reel : l'iPhone pousse, MediaMTX restitue

L'iPhone n'est joignable depuis nulle part - CGNAT operateur en 4G, NAT
de la box sur le WiFi du studio, UPnP indisponible. Le serveur RTSP de
l'application camera est donc inutilisable de l'exterieur. Seule une
connexion sortante traverse les deux NAT : c'est le telephone qui pousse.

- MediaMTX en conteneur : SRT/RTMP en entree, WebRTC (WHEP) et HLS en
  sortie. La video ne traverse jamais la JVM - 800 kbps continus dans
  Spring Boot couteraient un thread par spectateur sans rien apporter.
- SRT privilegie sur RTMP : RTMP est du TCP, un paquet perdu sur un lien
  mobile bloque tout le flux. SRT retransmet selectivement en UDP.
- Lecture publique (choix assume), publication authentifiee : sans ca
  n'importe qui pousserait sa propre video sur le flux "biblio".
- Console : client WHEP maison avec repli HLS automatique, et mise en
  pause quand l'onglet passe en arriere-plan.

Correction d'un bug trouve en deployant : le @ExceptionHandler(Exception)
avalait les exceptions que Spring sait deja traduire, si bien que toute
URL inconnue renvoyait 500 au lieu de 404 - ce qui noyait les vraies
pannes au milieu des 500 provoques par les crawlers. Test de regression
ajoute.

35 tests au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-12 18:08:34 +02:00
parent 94e893af97
commit d70892dce0
12 changed files with 827 additions and 13 deletions

104
README.md
View file

@ -29,6 +29,39 @@ tests plutôt que par la discipline.
## Le flux
### Deux flux, deux chemins
Le système transporte deux choses de nature très différente, et elles ne
suivent volontairement pas le même chemin :
```
┌─────────────────────┐
│ iPhone (capteur) │
└──────────┬──────────┘
événements │ vidéo
(quelques octets) │ (800 kbps continu)
┌──────────┴──────────┐
▼ ▼
┌───────────────┐ ┌────────────────┐
│ Spring Boot │ │ MediaMTX │
│ Kafka · JPA │ │ SRT → WebRTC │
│ domaine │ │ transport pur │
└───────┬───────┘ └────────┬───────┘
└──────────┬───────────┘
▼
Console web (une seule page)
```
**La vidéo ne traverse jamais la JVM.** Faire transiter 800 kbps continus par
Spring Boot n'apporterait rien et coûterait un thread par spectateur. Le média
est un problème de transport, résolu par un service dédié de 83 Mo ; la JVM
garde le domaine métier. Les deux se rejoignent seulement dans le navigateur.
Corollaire pratique : MediaMTX peut tomber sans que l'alarme cesse de
fonctionner, et inversement.
### Le flux d'événements en détail
```
┌──────────────────┐
│ iPhone 6s │ Détection de mouvement
@ -166,6 +199,74 @@ implémentation de `AlarmReadModelPort`.
---
## Le flux vidéo
### Le téléphone pousse, on ne l'interroge pas
C'est la contrainte qui a dicté toute cette partie. Un iPhone n'a **aucune
adresse joignable depuis Internet** : en 4G il est derrière le CGNAT de
l'opérateur, sur le WiFi du studio derrière le NAT de la box, et l'application
signale `UPnP unavailable`. Le serveur RTSP intégré à l'application caméra est
donc inutilisable de l'extérieur, quelle que soit la configuration.
La seule direction qui traverse ces deux NAT est la sortante. Le téléphone
**pousse** son flux vers le serveur :
```
iPhone ──SRT (UDP 8890)──▶ MediaMTX ──┬──▶ WebRTC < 1 s
└─RTMP (TCP 1935)──▶ └──▶ HLS ~ 6 s
```
**SRT plutôt que RTMP** quand le choix est possible : RTMP est du TCP, et sur
un lien mobile un paquet perdu déclenche une retransmission qui bloque tout le
reste. SRT transporte en UDP avec sa propre retransmission sélective — c'est
exactement le protocole conçu pour ce cas.
**WebRTC plutôt que HLS** côté lecture : voir quelqu'un six secondes après son
passage n'a pas le même intérêt. La console tente WebRTC (WHEP) d'abord et
bascule sur HLS si l'UDP est filtré — ce qui arrive précisément sur les réseaux
d'entreprise.
### Ce qui est protégé, ce qui ne l'est pas
| | |
|---|---|
| **Lecture** | publique — le flux est une vitrine, c'est assumé |
| **Publication** | authentifiée — sans ça, n'importe qui sur Internet pousserait sa propre vidéo sur le flux `biblio` |
### Brancher un vrai téléphone
Application : **IP Camera Lite** (iOS), menu *Live Push*.
```
SRT srt://<serveur>:8890?streamid=publish:biblio:iphone:<mot-de-passe>
RTMP rtmp://<serveur>:1935/biblio?user=iphone&pass=<mot-de-passe>
```
Le mot de passe est généré au déploiement dans le `.env` du serveur
(`STREAM_PUBLISH_PASSWORD`) et n'existe nulle part dans ce dépôt.
Pour valider la chaîne sans téléphone, une mire suffit :
```bash
docker run --rm --network iot-alarm-internal jrottenberg/ffmpeg:6.1-alpine \
-re -f lavfi -i testsrc=size=640x360:rate=25 \
-c:v libx264 -preset ultrafast -tune zerolatency -b:v 800k -pix_fmt yuv420p \
-f flv "rtmp://mediamtx:1935/biblio?user=iphone&pass=$PASS"
```
### Le piège du reverse proxy
MediaMTX répond d'abord une redirection vers un chemin **absolu**
(`/biblio/index.m3u8?cookieCheck=1`). Derrière un `handle_path /hls/*` qui
retire le préfixe, le navigateur suivait cette redirection jusqu'à
l'application Spring Boot et recevait du JSON là où il attendait une playlist.
Le `header_down Location` du [snippet Caddy](deploy/Caddyfile.snippet) réécrit
le préfixe. Symptôme trompeur : `curl -I` renvoyait un 404 sans rapport, le
serveur HLS ne gérant simplement pas la méthode HEAD.
---
## Les décisions non évidentes
Les choix ci-dessous ne sont pas les plus courts. Ils sont justifiés ici parce
@ -259,6 +360,8 @@ mvn verify -Pintegration # Testcontainers (Kafka + PostgreSQL réels)
| `GET` | `/api/v1/alarm/status` | **Requête** — état courant |
| `GET` | `/api/v1/alerts?limit=50` | **Requête** — historique |
| `WS` | `/ws` → `/topic/alerts` | Push temps réel (STOMP) |
| `POST` | `/webrtc/biblio/whep` | Négociation WebRTC (servi par MediaMTX) |
| `GET` | `/hls/biblio/index.m3u8` | Repli HLS (servi par MediaMTX) |
Documentation OpenAPI : `/swagger-ui.html`
@ -310,6 +413,7 @@ services.
| Messagerie | Apache Kafka 3.9 (KRaft) |
| Persistance | PostgreSQL 16 · Spring Data JPA · Flyway |
| Temps réel | WebSocket / STOMP / SockJS |
| Vidéo | MediaMTX · SRT / RTMP en entrée · WebRTC (WHEP) / HLS en sortie |
| Build | Maven · Docker multi-stage |
| Tests | JUnit 5 · AssertJ · **ArchUnit** · Testcontainers |
| Observabilité | Actuator · Micrometer / Prometheus |