DOCS/v2/media-storage-plan.md
Thomas Fransolet a5a8ecdb20 Documentation interne MyInfoMate / Unov
Import initial de la documentation : statut, roadmap, plans V1/V2,
specs verticales (creche, sport), audits securite, plan de test,
analyse concurrentielle et maquettes de design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:17:01 +02:00

327 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Médias & stockage — Plan
> **Contexte** : décisions prises le 2026-08-07, en amont de la migration Postgres.
> Ce chantier est un **prérequis à la release de migration** — il touche des données déjà écrites (URL, tailles), contrairement au RAG (`rag-pgvector-integration-plan.md`) qui est purement additif et peut suivre.
---
## Pourquoi maintenant
La règle qui a servi à trancher :
> **Ce qui touche aux données déjà écrites se fait pendant la migration. Ce qui est additif attend.**
Les URL absolues Firebase sont dispersées dans `Resource.Url`, `ImageSource`, `VideoSource` et le contenu JSONB des sections. Les reprendre plus tard = migrer les mêmes données deux fois. Une table `ContentEmbedding` vide, elle, s'ajoute quand on veut.
---
## 1. Compression des images côté client
### État actuel : aucune compression
Le chemin d'upload réel (`resources_screen.dart`) pousse les octets bruts du fichier choisi :
```dart
UploadTask uploadTask = ref.putData(platformFile.bytes!, metadata);
```
Une photo 4K de 8 Mo part entière. Le redimensionnement + watermark qui existe dans `ResourceController.Upload` (`ImageHelper.ResizeAndAddWatermark`) est **du code mort** : il vit dans l'endpoint legacy base64 que manager-app n'appelle plus depuis la bascule Firebase.
> `ImageHelper` s'appuie sur `System.Drawing.Common`, qui ne fonctionne plus sur Linux depuis .NET 6 sans libgdiplus — et l'image finale est `aspnet:8.0` Linux. Ce code lèverait probablement une exception s'il était appelé. Si un traitement serveur redevenait nécessaire un jour, passer par **ImageSharp** ou **SkiaSharp**, pas `System.Drawing`.
### Décision : compression navigateur avant upload
Le fichier ne transite plus par le backend (navigateur → Firebase en direct), donc le redimensionnement serveur est structurellement impossible sans réintroduire un proxy. Ça se fait côté client.
**Paramètres retenus : 2560 px sur le grand côté, JPEG qualité 82.**
Le raisonnement : les visiteurs peuvent zoomer sur un détail d'œuvre ou lire un cartel. Or **pour du zoom, c'est la résolution qui compte, pas le facteur de qualité** — une image 2560 px à q80 se regarde bien mieux agrandie qu'une 1920 px à q95, où l'on ne fait qu'agrandir du flou proprement compressé. D'où : dimension généreuse, qualité légèrement rognée.
| | Avant | Après |
|---|---|---|
| Photo 4K typique | 8 Mo | **~600-700 Ko** |
| Gain | | **~12×** sur le stockage *et* sur chaque consultation visiteur |
### Implémentation
manager-app est un back-office **web** : `flutter_image_compress` est hors jeu (natif uniquement). Deux voies :
**Package `image`** (pur Dart, multiplateforme) — retenu par défaut :
```dart
final decoded = img.decodeImage(platformFile.bytes!);
final resized = img.copyResize(decoded!, width: 2560, maintainAspect: true);
final bytes = Uint8List.fromList(img.encodeJpg(resized, quality: 82));
```
**Canvas natif** (`createImageBitmap` + `OffscreenCanvas` + `toBlob`) — accéléré matériellement, ne fige pas l'UI. Mais code web-only, donc imports conditionnels puisque manager-app builde aussi macOS/Windows. À basculer si le figement de l'UI sur une 4K devient gênant.
> Le format retenu est **JPEG**, pas WebP. Le canvas sait encoder du WebP nativement (~30 % de moins à qualité égale), mais c'est 30 % sur un problème déjà réglé à 12× — pas de quoi complexifier la chaîne aujourd'hui. À reconsidérer seulement si le poids redevient un sujet, et en n'oubliant pas alors la table `Content-Type → extension` de visitapp.
### Règles
- **Convertir les PNG photo en JPEG.** Garder le PNG uniquement s'il y a de la transparence (logos, pictos). Un PNG de photo pèse 5 à 10× son équivalent JPEG — gros gain souvent oublié.
- **Sauter la compression sous ~300 Ko.** Inutile de ré-encoder.
- **Ne pas conserver d'original haute définition.** Le musée a ses fichiers sources sur ses machines ; le CMS est un canal de diffusion, pas un service d'archivage.
### Vidéo
Le transcodage navigateur n'est pas réaliste (ffmpeg.wasm trop lourd et trop lent). Plafond de taille à l'upload + orienter vers YouTube/Vimeo — `SectionVideo` supporte déjà les deux, l'échappatoire existe.
---
## 2. Watermark — flag d'instance
Aujourd'hui codé en dur sur un id d'instance, avec un TODO qui traîne :
```csharp
bool isFort = instanceId == "633ee379d9405f32f166f047"; // TODO add watermark in configuration and model
```
**Décision : colonne booléenne sur `Instance`, basculée manuellement en base.** Pas d'UI, pas de support, pas de client qui s'en sert de travers. Si la demande vient, ajouter l'écran plus tard est trivial.
Deux conséquences :
- **Le flag doit remonter jusqu'au client.** Le watermark s'appliquant désormais dans le navigateur au moment de l'upload, il ne suffit pas qu'il existe en base : il doit être exposé dans la config d'instance que manager-app lit au démarrage.
- **Un flag invisible s'oublie.** Le nommer explicitement et le documenter dans la fiche d'instance — dans six mois personne ne saura qu'il existe.
Le watermark est appliqué **à l'upload**, donc cuit dans le fichier stocké : il protège en aval quel que soit le canal.
---
## 3. `IStorageService` + `StoragePath`
### Le problème à corriger
`Resource.Url` stocke une **URL de téléchargement Firebase absolue, à jeton**. Le client Flutter la génère après l'upload et la renvoie au backend. Conséquences :
- c'est le client qui décide où vivent les fichiers
- changer de fournisseur imposerait de réécrire chaque URL en base, y compris celles noyées dans les champs JSONB des sections
### Décision
Une colonne **`StoragePath`** (clé relative) devient la source de vérité, l'URL absolue étant résolue à la lecture derrière une interface :
```csharp
public interface IStorageService
{
string GetPublicUrl(string storagePath);
Task<long> GetSizeAsync(string storagePath);
Task DeleteAsync(string storagePath);
}
```
Une seule implémentation pour l'instant : Firebase. C'est ce qui transforme une migration future de « réécrire toutes les URL » en « changer une implémentation et recopier les blobs ».
### Le chemin est déjà déterministe
```dart
storage.ref().child('pictures/${instanceId}/${Path.basename(newResource.id)}')
```
`pictures/{instanceId}/{resourceId}`, sans extension. Donc **inutile de parser les URL à jeton** pour quoi que ce soit : la clé se reconstruit depuis la ligne `Resource`. Ça simplifie le backfill (§4).
### Anomalie du flux actuel
La ligne `Resource` est créée **avant** l'upload, et l'URL écrite après coup par le client. Si l'upload échoue ou que l'onglet se ferme, il reste une ligne sans URL et sans fichier — l'orphelin dans l'autre sens. À traiter avec le même inventaire.
---
## 4. Quota de stockage
### État actuel : non appliqué
Le contrôle `StorageQuotaBytes` n'existe que dans l'endpoint legacy `Upload` (chemin base64, plus utilisé). `Create` — le chemin réellement emprunté — **ne vérifie aucun quota et ne renseigne pas `SizeBytes`**.
### Corrections, dans cet ordre
1. **Endpoint de pré-vol**`POST /api/Resource/check-quota { instanceId, sizeBytes }`, appelé **avant** de pousser dans Firebase. Répond OK / dépassement + octets restants. Purement UX : éviter d'attendre l'upload de 180 Mo pour se faire refuser. Non autoritaire, une course reste possible, sans gravité.
2. **Contrôle autoritaire à `Create`, taille lue depuis Firebase.** Ne **jamais** faire confiance au `sizeBytes` envoyé par le client — un appelant qui envoie `0` bypasse le quota définitivement. `FirebaseAdmin` est déjà référencé dans le `.csproj`. En cas de dépassement : supprimer le blob puis renvoyer 413. Sans la suppression, un refus laisse un orphelin qui occupe du stockage sans être compté.
3. **`Delete` doit supprimer le blob.** Aujourd'hui il nettoie les références en base et laisse le fichier. Le quota calculé (`SUM(SizeBytes)`) diverge donc du stockage réellement facturé, et l'écart ne fait que croître.
`SizeBytes` doit être renseigné à la création, pas seulement sur `Update`.
---
## 5. Backfill des tailles existantes
Les `Resource` actuelles ont `SizeBytes = 0` : sans reprise, tous les clients démarrent à 0 octet consommé et le quota ne veut rien dire.
**À faire pendant la migration Postgres**, qui touche déjà chaque ligne `Resource`.
### État mesuré en base le 2026-08-09 (45 ressources, instance du Fort)
| Colonne | Lignes renseignées |
|---|---|
| `StoragePath` | **0**~~la colonne n'est affectée nulle part dans le code~~ → écrite à `Create` depuis le 2026-08-10 ; les 45 lignes existantes restent à backfiller |
| `SizeBytes > 0` | **0** — le quota de stockage vaut donc 0 pour tout le monde ; renseignée à `Create` depuis le 2026-08-10 |
| `FileName` | **0** |
⚠️ **Le périmètre n'est pas « chaque ligne `Resource` ».** Sur 45 lignes :
- **37** ont un blob Firebase au chemin attendu → seules celles-là sont concernées ;
- **7** sont des types *URL* (`ImageUrl`, `VideoUrl`, `JSONUrl` — Wikipedia, YouTube, `agenda.php`, `swagger.json`) : elles pointent hors du bucket et **n'ont aucun blob**. Leur donner un `StoragePath` serait faux, et les compter dans le quota le serait aussi. **À exclure explicitement** ;
- **1** est de type fichier (`Video`, « Exemple webm ») mais **sans aucune URL** : une ligne sans fichier, l'inverse d'un blob orphelin. C'est le premier cas que l'inventaire doit remonter.
### Deux moitiés, dont une gratuite
Le chemin étant déterministe pour les types fichier, **`StoragePath` se backfille en SQL pur**, sans lire le bucket :
```sql
UPDATE "Resources" SET "StoragePath" = 'pictures/' || "InstanceId" || '/' || "Id"
WHERE "Type" NOT IN (2, 3, 7) -- ImageUrl, VideoUrl, JSONUrl : pas de blob
AND "Url" IS NOT NULL; -- écarte les lignes sans fichier
```
Seul **`SizeBytes` exige de lister Firebase**. Découper ainsi permet de faire la moitié utile immédiatement.
> ~~**Prérequis à ne pas inverser** : `StoragePath` et `SizeBytes` doivent d'abord être **écrits à `Create`**.~~ ✅ **Fait le 2026-08-10** — `ResourceController.Create` écrit désormais `StoragePath = pictures/{instanceId}/{resourceId}` et `SizeBytes`, en excluant les types URL (2, 3, 7). Côté `manager-app`, `resources_screen.dart` renseigne `sizeBytes` **avant** l'appel de création (la taille est connue avant l'upload) : elle n'arrivait jusque-là qu'au `Update` post-upload. Le backfill peut donc se faire une seule fois.
>
> ⚠️ Reste non autoritaire : la taille vient du client. Le contrôle relu depuis Firebase est le point 2 du §4, pas encore fait.
```
1. Lister le bucket Firebase une fois (paginé) → map { objectPath: sizeBytes }
2. Pour chaque Resource : reconstruire la clé `pictures/{instanceId}/{id}`, chercher dans la map
3. Trouvé → SizeBytes = taille réelle, StoragePath = clé
Pas trouvé → journaliser (référence morte : ligne sans fichier)
4. Objets du bucket sans Resource correspondante → journaliser (blob orphelin)
```
Lister le bucket **une seule fois** plutôt qu'un appel de métadonnées par ressource : quelques appels au lieu de plusieurs milliers.
**Le bonus** : l'étape 4 donne l'inventaire des blobs orphelins, qui existent forcément puisque `Delete` n'a jamais supprimé les fichiers. Tu récupères probablement du stockage réel avant même de mettre les quotas en service.
> **Ne pas purger automatiquement.** Le script *rapporte*, il ne supprime pas. Un défaut de rapprochement supprimerait des fichiers vivants. On regarde la liste, on valide, on purge ensuite.
### Bascule pour les clients existants
Une fois les tailles réelles connues, certains clients seront peut-être déjà au-dessus du quota de leur plan. Ne pas les bloquer du jour au lendemain : calculer, regarder les chiffres, puis décider — quota sur les seuls nouveaux uploads, ou dépassement toléré avec alerte. À trancher sur les vrais chiffres.
---
## 6. Fournisseur de stockage — décision et déclencheur
### Situation
Bucket **en Europe**. Or les quotas gratuits Firebase Storage ne s'appliquent qu'aux régions `us-central1`, `us-west1` et `us-east1`. **Il n'y a donc aucun quota gratuit** : la facturation court depuis le premier octet.
Tarifs Blaze (bucket legacy `*.appspot.com`, hors quotas gratuits) :
| Poste | Prix |
|---|---|
| Stockage | 0,026 $/Go/mois |
| **Bande passante sortante** | **0,12 $/Go** |
| Ops upload | 0,05 $/10K |
| Ops download | 0,004 $/10K |
### Pourquoi la facture reste faible aujourd'hui
Le canal actuel est la **tablette kiosk** : elle télécharge le contenu une fois et sert hors ligne. L'egress est donc proportionnel au nombre d'installations, pas au nombre de visiteurs. Le stockage seul ne fait jamais une grosse facture (50 Go ≈ 1,30 $/mois), et **l'ingress est gratuit** — un client qui déverse toutes ses données ne coûte quasiment rien.
### Ce qui change avec le mobile
`mymuseum-visitapp` sur les téléphones des visiteurs rend l'egress proportionnel à la **fréquentation**.
**Comportement confirmé** : visitapp ne pré-télécharge **pas** le contenu, et ne doit pas le faire. Le chargement est à la demande — un visiteur qui consulte trois POI coûte trois POI. C'est le bon défaut et il faut le préserver.
**L'exception, c'est la visite hors ligne** : un visiteur peut télécharger explicitement une visite complète, pour les lieux sans réseau (le Fort Saint Héribert et ses murs épais). C'est là — et pratiquement là seulement — que vit la facture d'egress.
### La visite hors ligne est le poste de coût
Ordre de grandeur pour une visite type de 100 images + audio :
| | Sans compression | Avec compression (2560 / q82) |
|---|---|---|
| Poids du paquet | ~1 Go | ~150 Mo |
| Coût par téléchargement (0,12 $/Go) | ~0,12 $ | ~0,018 $ |
| **900 téléchargements/mois** (30/jour) | **~108 $/mois** | **~16 $/mois** |
C'est le chiffre qui justifie la compression à lui seul. Le reste de l'app, en chargement à la demande, pèse une fraction de ça.
### Mécanisme actuel — `downloadConfiguration.dart`
1. `configurationExport(configurationId, language)``ExportConfigurationDTO` (sections + toutes les ressources)
2. Création d'un dossier local `{localPath}/{configurationId}`
3. Filtre incrémental, puis téléchargement séquentiel des ressources retenues
4. Insertion des métadonnées dans le SQLite local
**Le coût d'egress est déjà maîtrisé** — il existe bien un filtre incrémental :
```dart
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) =>
resource.type != ResourceType.ImageUrl && ... &&
resource.url != null &&
!fileList.any((fileL) => fileL.uri.pathSegments.last.contains(resource.id!)));
```
Une ressource dont l'**id apparaît déjà dans un nom de fichier local** est sautée. Un second téléchargement de la même visite ne re-télécharge donc rien.
### Le vrai problème n'est pas le coût, c'est la fraîcheur
Le filtre teste la **présence** du fichier, pas sa **version**. Or lorsqu'un client remplace une image dans le CMS, `Update` conserve l'id de la ressource et ne change que l'`Url`.
> **Conséquence : une ressource modifiée n'est jamais re-téléchargée.** Le fichier local existe, l'id correspond, elle est sautée. Le visiteur qui avait déjà téléchargé la visite garde l'ancienne image **indéfiniment**. Les ajouts fonctionnent, les mises à jour non.
C'est un bug de correction, pas d'optimisation — et il est invisible côté client, qui voit son contenu à jour dans le CMS et sur le web.
**Correctif** : comparer une version, pas une présence. `Resource.DateUpdate` existe déjà sur l'entité — le stocker dans le SQLite local à côté du chemin, et re-télécharger quand la valeur distante est plus récente. Alternative plus robuste : un hash de contenu, qui évite de re-télécharger quand seul un champ de métadonnée a bougé.
> **Le hors ligne est plus cassé que ça.** Le `switch` du backend qui collecte les ressources internes des sections (images d'articles, **audios**, quiz, cartes, sliders) est entièrement commenté dans `ConfigurationController.Export`, et le bloc symétrique côté visitapp aussi. Une visite « téléchargée » ne contient aujourd'hui que des métadonnées et quelques vignettes. Analyse complète et plan de remise en état : **`offline-visit-plan.md`**.
### Trois autres constats dans le même fichier
- **La purge des fichiers obsolètes est désactivée.** La liste `resourceToDelete` est calculée puis le `deleteSync()` est commenté (`// Preserve call to firebase // TODO uncomment if needed`), et `cleanLocalResources` est commenté aussi. Le stockage sur le device grossit indéfiniment. Pas un coût d'egress, mais un problème sur un téléphone de visiteur.
- **Seuls les types `Article` et `Quiz` sont embarqués** (TODO explicite dans le code pour Game, Menu, Map, PDF, Video, Slider, Web, Weather, Agenda). La visite hors ligne est donc partielle aujourd'hui — à savoir avant de la vendre comme argument.
- **L'extension est déduite du `Content-Type`** via une table de correspondance qui renvoie `"unknown"` en cas d'absence. Or elle contient `audio/mp3`, qui n'est pas un type MIME standard — le vrai MIME d'un MP3 est **`audio/mpeg`**, absent de la table. Les audios téléchargés atterrissent donc probablement déjà en `.unknown`. À vérifier sur un device, c'est du contenu hors ligne cassé sans bruit.
*(Accessoirement, `image/webp` n'y est pas non plus — sans conséquence tant qu'on reste en JPEG, à corriger le jour où on changerait de format.)*
### Piste
Servir un paquet pré-assemblé (archive générée au save) plutôt que N requêtes individuelles : l'egress est identique, mais le nombre d'opérations de download facturées chute et c'est nettement plus robuste sur une connexion instable — typiquement à l'entrée d'un fort.
### Décision : rester sur Firebase, garder l'abstraction
L'abstraction (`StoragePath` + `IStorageService`) **est** la préparation. Elle réduit une migration future à : changer d'implémentation, recopier les blobs, aucun changement de schéma. Migrer le fournisseur maintenant n'apporte rien de plus et ajoute un chantier pendant la migration Postgres.
Le levier immédiat sur le coût, c'est la **compression (12×)**, pas le fournisseur.
### Si migration un jour : OVH Object Storage, avec ses conditions
**Ce que ça implique concrètement**
- S3-compatible → `AWSSDK.S3` pointé sur l'endpoint OVH
- L'upload direct navigateur passe par des **URL présignées** : le backend génère une URL PUT temporaire, le client uploade dessus. Même principe, autre plomberie. Plus la config CORS du bucket.
- Firebase donne des URL de téléchargement **permanentes** à jeton, stockables en base. En S3 : soit objets publics (aucun contrôle d'accès), soit URL présignées **qui expirent** — donc générées à la lecture. `StoragePath` rend ce basculement indolore.
- Les push notifications restent chez Firebase de toute façon.
**Un bucket global, préfixé par instance** — pas un bucket par client. C'est déjà le modèle actuel (`pictures/{instanceId}/{id}`). Un bucket par client impose d'en provisionner un à chaque onboarding, gérer des quotas de nombre de buckets et plus d'IAM, pour une isolation déjà obtenue par le préfixe et une comptabilité de quota qui vit en base. À réserver à un client qui exigerait une séparation physique — improbable avec des musées.
**Durabilité : la sauvegarde devient obligatoire.** OVH a un précédent — l'incendie de SBG2 (Strasbourg, 2021) a détruit les données de clients sans sauvegarde ailleurs. Google Cloud Storage annonce 11 neufs de durabilité et n'a pas cet historique. Partir chez OVH impose donc une sauvegarde des médias dans une autre région, ce qui mange une partie de l'économie sur l'egress et ajoute de l'ops.
### Déclencheur de réouverture
**Quand la facture mensuelle Firebase dépasse ~30 €.** En dessous, ça ne vaut pas le temps passé. Mettre une **alerte de budget GCP** dès maintenant — c'est cinq minutes et c'est la vraie protection contre la surprise.
---
## Ordre d'implémentation
| # | Tâche | Quand |
|---|---|---|
| 1 | Alerte de budget GCP | tout de suite, 5 min |
| 2 | Compression client 2560 px / q82 + PNG→JPEG | avant la release |
| 3 | Colonne `StoragePath` + `IStorageService` (impl. Firebase) | migration |
| 4 | `SizeBytes` renseigné à `Create`, lu depuis Firebase | migration |
| 5 | Backfill tailles + `StoragePath` + inventaire orphelins | migration |
| 6 | Flag watermark sur `Instance` + exposition dans la config | migration |
| 7 | Pré-vol quota + contrôle autoritaire + `Delete` du blob | avant les uploads documentaires |
| 8 | Remise en état de la visite hors ligne → `offline-visit-plan.md` | **bugs — dès que possible** |
---
## Liens
- `rag-pgvector-integration-plan.md` — formats de documents, extraction, ingestion (dépend de §3 et §4 de ce doc)
- `myinfomate-ai-persona-analysis.md` — vision d'ensemble