forge-mvc-audio 1.0.0b16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge_mvc_audio-1.0.0b16/LICENSE +36 -0
- forge_mvc_audio-1.0.0b16/PKG-INFO +87 -0
- forge_mvc_audio-1.0.0b16/README.md +66 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/__init__.py +43 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/cli/__init__.py +1 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/cli/doctor.py +139 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/config.py +83 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/http.py +108 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/ingest.py +73 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/probe.py +130 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/storage.py +97 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio/transcode.py +84 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio.egg-info/PKG-INFO +87 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio.egg-info/SOURCES.txt +17 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio.egg-info/dependency_links.txt +1 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio.egg-info/requires.txt +1 -0
- forge_mvc_audio-1.0.0b16/forge_mvc_audio.egg-info/top_level.txt +1 -0
- forge_mvc_audio-1.0.0b16/pyproject.toml +37 -0
- forge_mvc_audio-1.0.0b16/setup.cfg +4 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Forge — Licence propriétaire / source disponible
|
|
2
|
+
|
|
3
|
+
Copyright (c) Roger Lequette.
|
|
4
|
+
Tous droits réservés.
|
|
5
|
+
|
|
6
|
+
Le code source de Forge est mis à disposition uniquement pour lecture, étude,
|
|
7
|
+
évaluation et usage éducatif personnel.
|
|
8
|
+
|
|
9
|
+
Sauf autorisation écrite préalable de Roger Lequette, il est interdit de :
|
|
10
|
+
|
|
11
|
+
- utiliser Forge dans un cadre professionnel, commercial ou institutionnel ;
|
|
12
|
+
- utiliser Forge pour une prestation client ;
|
|
13
|
+
- intégrer Forge dans un produit, service, SaaS, application vendue ou solution
|
|
14
|
+
déployée pour un tiers ;
|
|
15
|
+
- redistribuer Forge, modifié ou non ;
|
|
16
|
+
- publier une version modifiée de Forge ;
|
|
17
|
+
- vendre, louer, sous-licencier ou exploiter commercialement Forge ;
|
|
18
|
+
- supprimer ou modifier les mentions de copyright et de licence.
|
|
19
|
+
|
|
20
|
+
Les usages autorisés sans autorisation écrite préalable sont limités à :
|
|
21
|
+
|
|
22
|
+
- lire le code ;
|
|
23
|
+
- étudier son fonctionnement ;
|
|
24
|
+
- tester Forge à titre personnel ;
|
|
25
|
+
- l'utiliser dans un cadre éducatif personnel ou pédagogique non commercial ;
|
|
26
|
+
- évaluer le framework avant une éventuelle demande d'autorisation.
|
|
27
|
+
|
|
28
|
+
Toute utilisation non explicitement autorisée par cette licence nécessite une
|
|
29
|
+
autorisation écrite préalable de Roger Lequette.
|
|
30
|
+
|
|
31
|
+
Cette licence pourra évoluer ultérieurement. Toute version publiée de Forge
|
|
32
|
+
reste soumise à la licence présente dans son dépôt au moment de sa récupération.
|
|
33
|
+
|
|
34
|
+
LE LOGICIEL EST FOURNI « TEL QUEL », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE OU
|
|
35
|
+
IMPLICITE. EN AUCUN CAS LE DÉTENTEUR DU COPYRIGHT NE POURRA ÊTRE TENU
|
|
36
|
+
RESPONSABLE DE TOUT DOMMAGE DÉCOULANT DE L'UTILISATION DE CE LOGICIEL.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mvc-audio
|
|
3
|
+
Version: 1.0.0b16
|
|
4
|
+
Summary: Forge Audio — module opt-in pour l'upload, le sondage de métadonnées (ffprobe), le transcodage MP3 (ffmpeg) et la lecture audio en streaming (HTTP Range). Sans état : aucune base de données. Worker CLI forge audio:doctor.
|
|
5
|
+
Author: Roger Lequette
|
|
6
|
+
License-Expression: LicenseRef-Forge-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/caucrogeGit/Forge
|
|
8
|
+
Project-URL: Repository, https://github.com/caucrogeGit/Forge
|
|
9
|
+
Project-URL: Documentation, https://forgemvc.com/docs/forge/audio/
|
|
10
|
+
Keywords: python,mvc,forge,audio,ffmpeg,mp3,streaming
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: forge-mvc<2,>=1.0.0b16
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# forge-mvc-audio
|
|
23
|
+
|
|
24
|
+
Module **opt-in** Forge pour la gestion audio : upload, sondage de métadonnées,
|
|
25
|
+
transcodage MP3 et lecture en streaming. **Sans état** (aucune base de données).
|
|
26
|
+
|
|
27
|
+
## Statut : Beta — opt-in officiel
|
|
28
|
+
|
|
29
|
+
`forge-mvc-audio` fournit une chaîne audio complète et **sobre**, calquée sur
|
|
30
|
+
`forge-mvc-video` mais **sans la machinerie à état** (pas de table SQL, pas de
|
|
31
|
+
suivi de jobs, pas de file de transcodage). C'est un choix délibéré : les
|
|
32
|
+
opérations audio sont synchrones et la lecture retrouve les fichiers par `uuid`
|
|
33
|
+
sur le disque.
|
|
34
|
+
|
|
35
|
+
> **ffmpeg / ffprobe** sont des binaires **système** (pas des dépendances pip).
|
|
36
|
+
> Le module se branche sans eux (le service de lecture fonctionne), mais le
|
|
37
|
+
> sondage exige `ffprobe` et le transcodage exige `ffmpeg`.
|
|
38
|
+
> `forge audio:doctor` signale leur absence.
|
|
39
|
+
|
|
40
|
+
Installation (mode éditable depuis les sources) :
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e packages/forge-mvc-audio/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Ce que contient le module
|
|
47
|
+
|
|
48
|
+
- `config` — `AudioConfig` + `load_audio_config()` (configuration depuis
|
|
49
|
+
`FORGE_AUDIO_*`, valeurs par défaut sûres).
|
|
50
|
+
- `storage` — disposition **uuid-based** des fichiers (le nom utilisateur
|
|
51
|
+
n'apparaît jamais dans le chemin → anti-traversal par construction).
|
|
52
|
+
- `probe` — `probe_audio()` via `ffprobe` : durée, codec, bitrate, sample rate,
|
|
53
|
+
nombre de canaux, conteneur. Sert aussi de validation profonde (rejet d'un
|
|
54
|
+
fichier sans flux audio).
|
|
55
|
+
- `ingest` — `ingest_audio(data, filename)` : valide (taille, extension), stocke
|
|
56
|
+
la source, retourne un enregistrement (`uuid`, chemin, taille, MIME).
|
|
57
|
+
- `transcode` — `transcode_to_mp3()` via `ffmpeg` (profil MP3 192 kbps, stéréo,
|
|
58
|
+
métadonnées d'origine retirées). Constructeurs de commande **purs**, runner
|
|
59
|
+
injectable (testable sans ffmpeg).
|
|
60
|
+
- `http` — `register_audio_routes(router)` : route `GET /audio/{uuid}` servie en
|
|
61
|
+
**streaming HTTP Range** (seek), Bearer token optionnel
|
|
62
|
+
(`FORGE_AUDIO_API_TOKEN`).
|
|
63
|
+
- `cli.doctor` — `forge audio:doctor` : diagnostic statique (package, config,
|
|
64
|
+
ffprobe, ffmpeg, routes).
|
|
65
|
+
|
|
66
|
+
## Formats acceptés à l'upload
|
|
67
|
+
|
|
68
|
+
`mp3`, `wav`, `ogg`, `flac`, `m4a`, `aac`. La sortie de transcodage est MP3.
|
|
69
|
+
|
|
70
|
+
## Sécurité
|
|
71
|
+
|
|
72
|
+
- Invocation `ffmpeg`/`ffprobe` en **liste d'arguments** (jamais `shell=True`).
|
|
73
|
+
- Chemins **uuid-based** : le `uuid` de l'URL n'est qu'une clé de lookup, validée
|
|
74
|
+
comme un UUID — aucun *path traversal*.
|
|
75
|
+
- Token Bearer optionnel sur la route de lecture (mode local ouvert par défaut).
|
|
76
|
+
- L'auth vit dans ce module, **jamais** dans Forge Core.
|
|
77
|
+
|
|
78
|
+
## Configuration (`FORGE_AUDIO_*`)
|
|
79
|
+
|
|
80
|
+
| Variable | Défaut | Rôle |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `FORGE_AUDIO_FFMPEG_BIN` | `ffmpeg` | binaire ffmpeg |
|
|
83
|
+
| `FORGE_AUDIO_FFPROBE_BIN` | `ffprobe` | binaire ffprobe |
|
|
84
|
+
| `FORGE_AUDIO_STORAGE_ROOT` | `storage/audio` | racine de stockage |
|
|
85
|
+
| `FORGE_AUDIO_MAX_UPLOAD_MB` | `200` | taille max d'upload |
|
|
86
|
+
| `FORGE_AUDIO_MAX_DURATION_SECONDS` | `7200` | durée max (2 h) |
|
|
87
|
+
| `FORGE_AUDIO_API_TOKEN` | *(absent)* | Bearer token de lecture (optionnel) |
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# forge-mvc-audio
|
|
2
|
+
|
|
3
|
+
Module **opt-in** Forge pour la gestion audio : upload, sondage de métadonnées,
|
|
4
|
+
transcodage MP3 et lecture en streaming. **Sans état** (aucune base de données).
|
|
5
|
+
|
|
6
|
+
## Statut : Beta — opt-in officiel
|
|
7
|
+
|
|
8
|
+
`forge-mvc-audio` fournit une chaîne audio complète et **sobre**, calquée sur
|
|
9
|
+
`forge-mvc-video` mais **sans la machinerie à état** (pas de table SQL, pas de
|
|
10
|
+
suivi de jobs, pas de file de transcodage). C'est un choix délibéré : les
|
|
11
|
+
opérations audio sont synchrones et la lecture retrouve les fichiers par `uuid`
|
|
12
|
+
sur le disque.
|
|
13
|
+
|
|
14
|
+
> **ffmpeg / ffprobe** sont des binaires **système** (pas des dépendances pip).
|
|
15
|
+
> Le module se branche sans eux (le service de lecture fonctionne), mais le
|
|
16
|
+
> sondage exige `ffprobe` et le transcodage exige `ffmpeg`.
|
|
17
|
+
> `forge audio:doctor` signale leur absence.
|
|
18
|
+
|
|
19
|
+
Installation (mode éditable depuis les sources) :
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e packages/forge-mvc-audio/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Ce que contient le module
|
|
26
|
+
|
|
27
|
+
- `config` — `AudioConfig` + `load_audio_config()` (configuration depuis
|
|
28
|
+
`FORGE_AUDIO_*`, valeurs par défaut sûres).
|
|
29
|
+
- `storage` — disposition **uuid-based** des fichiers (le nom utilisateur
|
|
30
|
+
n'apparaît jamais dans le chemin → anti-traversal par construction).
|
|
31
|
+
- `probe` — `probe_audio()` via `ffprobe` : durée, codec, bitrate, sample rate,
|
|
32
|
+
nombre de canaux, conteneur. Sert aussi de validation profonde (rejet d'un
|
|
33
|
+
fichier sans flux audio).
|
|
34
|
+
- `ingest` — `ingest_audio(data, filename)` : valide (taille, extension), stocke
|
|
35
|
+
la source, retourne un enregistrement (`uuid`, chemin, taille, MIME).
|
|
36
|
+
- `transcode` — `transcode_to_mp3()` via `ffmpeg` (profil MP3 192 kbps, stéréo,
|
|
37
|
+
métadonnées d'origine retirées). Constructeurs de commande **purs**, runner
|
|
38
|
+
injectable (testable sans ffmpeg).
|
|
39
|
+
- `http` — `register_audio_routes(router)` : route `GET /audio/{uuid}` servie en
|
|
40
|
+
**streaming HTTP Range** (seek), Bearer token optionnel
|
|
41
|
+
(`FORGE_AUDIO_API_TOKEN`).
|
|
42
|
+
- `cli.doctor` — `forge audio:doctor` : diagnostic statique (package, config,
|
|
43
|
+
ffprobe, ffmpeg, routes).
|
|
44
|
+
|
|
45
|
+
## Formats acceptés à l'upload
|
|
46
|
+
|
|
47
|
+
`mp3`, `wav`, `ogg`, `flac`, `m4a`, `aac`. La sortie de transcodage est MP3.
|
|
48
|
+
|
|
49
|
+
## Sécurité
|
|
50
|
+
|
|
51
|
+
- Invocation `ffmpeg`/`ffprobe` en **liste d'arguments** (jamais `shell=True`).
|
|
52
|
+
- Chemins **uuid-based** : le `uuid` de l'URL n'est qu'une clé de lookup, validée
|
|
53
|
+
comme un UUID — aucun *path traversal*.
|
|
54
|
+
- Token Bearer optionnel sur la route de lecture (mode local ouvert par défaut).
|
|
55
|
+
- L'auth vit dans ce module, **jamais** dans Forge Core.
|
|
56
|
+
|
|
57
|
+
## Configuration (`FORGE_AUDIO_*`)
|
|
58
|
+
|
|
59
|
+
| Variable | Défaut | Rôle |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `FORGE_AUDIO_FFMPEG_BIN` | `ffmpeg` | binaire ffmpeg |
|
|
62
|
+
| `FORGE_AUDIO_FFPROBE_BIN` | `ffprobe` | binaire ffprobe |
|
|
63
|
+
| `FORGE_AUDIO_STORAGE_ROOT` | `storage/audio` | racine de stockage |
|
|
64
|
+
| `FORGE_AUDIO_MAX_UPLOAD_MB` | `200` | taille max d'upload |
|
|
65
|
+
| `FORGE_AUDIO_MAX_DURATION_SECONDS` | `7200` | durée max (2 h) |
|
|
66
|
+
| `FORGE_AUDIO_API_TOKEN` | *(absent)* | Bearer token de lecture (optionnel) |
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Forge MVC Audio — module opt-in pour la gestion audio.
|
|
2
|
+
|
|
3
|
+
Chaîne audio **complète et sans état** (aucune base de données) :
|
|
4
|
+
|
|
5
|
+
- ``config`` — contrat de configuration (`AudioConfig`, `load_audio_config`) ;
|
|
6
|
+
- ``storage`` — disposition uuid-based des fichiers (anti-traversal) ;
|
|
7
|
+
- ``probe`` — métadonnées via ``ffprobe`` (durée, codec, bitrate, canaux…) ;
|
|
8
|
+
- ``ingest`` — upload validé + stockage (``ingest_audio``) ;
|
|
9
|
+
- ``transcode`` — conversion MP3 via ``ffmpeg`` (``transcode_to_mp3``) ;
|
|
10
|
+
- ``http`` — lecture en streaming HTTP Range (``register_audio_routes``) ;
|
|
11
|
+
- ``cli.doctor`` — diagnostic ``forge audio:doctor``.
|
|
12
|
+
|
|
13
|
+
Volontairement **sobre** : pas de table SQL, pas de suivi de jobs, pas de file
|
|
14
|
+
de transcodage. Les opérations sont synchrones et le service de lecture
|
|
15
|
+
retrouve les fichiers par ``uuid`` sur le disque. ``ffmpeg``/``ffprobe`` sont
|
|
16
|
+
des binaires système (pas des dépendances pip).
|
|
17
|
+
|
|
18
|
+
Le module reste **opt-in** : l'application appelle ``register_audio_routes``
|
|
19
|
+
explicitement. Aucune écriture dans le code utilisateur (charte §9).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0b16"
|
|
25
|
+
|
|
26
|
+
from forge_mvc_audio.config import AudioConfig, load_audio_config
|
|
27
|
+
from forge_mvc_audio.http import register_audio_routes
|
|
28
|
+
from forge_mvc_audio.ingest import AudioIngestError, ingest_audio
|
|
29
|
+
from forge_mvc_audio.probe import AudioMetadata, AudioProbeError, probe_audio
|
|
30
|
+
from forge_mvc_audio.transcode import FfmpegError, transcode_to_mp3
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AudioConfig",
|
|
34
|
+
"AudioIngestError",
|
|
35
|
+
"AudioMetadata",
|
|
36
|
+
"AudioProbeError",
|
|
37
|
+
"FfmpegError",
|
|
38
|
+
"ingest_audio",
|
|
39
|
+
"load_audio_config",
|
|
40
|
+
"probe_audio",
|
|
41
|
+
"register_audio_routes",
|
|
42
|
+
"transcode_to_mp3",
|
|
43
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Interface CLI du module opt-in Forge Audio (``forge audio:*``)."""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Diagnostic ``forge audio:doctor``.
|
|
2
|
+
|
|
3
|
+
Diagnostic **statique** : ne lance aucun ``ffmpeg``, n'ouvre aucun fichier
|
|
4
|
+
audio, ne touche à aucune base (il n'y en a pas). Il vérifie que l'environnement
|
|
5
|
+
est prêt :
|
|
6
|
+
|
|
7
|
+
1. le package ``forge_mvc_audio`` est importable et expose ``__version__`` ;
|
|
8
|
+
2. ``load_audio_config()`` charge une configuration cohérente ;
|
|
9
|
+
3. le binaire ``ffprobe`` est présent dans le PATH (validation/métadonnées) ;
|
|
10
|
+
4. le binaire ``ffmpeg`` est présent dans le PATH (transcodage MP3) ;
|
|
11
|
+
5. ``register_audio_routes`` est exposée (branchement HTTP).
|
|
12
|
+
|
|
13
|
+
``ffmpeg``/``ffprobe`` absents → ``fail`` : sondage et transcodage les exigent.
|
|
14
|
+
Convention alignée sur ``forge video:doctor`` / ``forge iot:doctor`` : statuts
|
|
15
|
+
minuscules ``ok``/``warn``/``fail``/``skip``, dataclass ``CheckResult``.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CheckResult",
|
|
25
|
+
"check_package_importable",
|
|
26
|
+
"check_config_loadable",
|
|
27
|
+
"check_ffprobe_present",
|
|
28
|
+
"check_ffmpeg_present",
|
|
29
|
+
"check_routes_registrable",
|
|
30
|
+
"run_all",
|
|
31
|
+
"print_report",
|
|
32
|
+
"has_failures",
|
|
33
|
+
"main",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
Status = Literal["ok", "warn", "fail", "skip"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CheckResult:
|
|
41
|
+
status: Status
|
|
42
|
+
name: str
|
|
43
|
+
detail: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_package_importable() -> CheckResult:
|
|
47
|
+
try:
|
|
48
|
+
import forge_mvc_audio
|
|
49
|
+
|
|
50
|
+
version = getattr(forge_mvc_audio, "__version__", None)
|
|
51
|
+
except Exception as exc: # pragma: no cover — défensif
|
|
52
|
+
return CheckResult("fail", "package", f"import impossible : {exc}")
|
|
53
|
+
if not version:
|
|
54
|
+
return CheckResult("warn", "package", "importable mais sans __version__")
|
|
55
|
+
return CheckResult("ok", "package", f"forge_mvc_audio {version}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_config_loadable() -> CheckResult:
|
|
59
|
+
try:
|
|
60
|
+
from forge_mvc_audio.config import load_audio_config
|
|
61
|
+
|
|
62
|
+
cfg = load_audio_config()
|
|
63
|
+
except Exception as exc: # pragma: no cover — défensif
|
|
64
|
+
return CheckResult("fail", "config", f"chargement impossible : {exc}")
|
|
65
|
+
return CheckResult(
|
|
66
|
+
"ok",
|
|
67
|
+
"config",
|
|
68
|
+
f"storage={cfg.storage_root}, max_upload={cfg.max_upload_mb} Mo, "
|
|
69
|
+
f"max_durée={cfg.max_duration_seconds}s",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_binary(name: str, bin_value: str, purpose: str) -> CheckResult:
|
|
74
|
+
path = shutil.which(bin_value)
|
|
75
|
+
if path is None:
|
|
76
|
+
return CheckResult(
|
|
77
|
+
"fail",
|
|
78
|
+
name,
|
|
79
|
+
f"`{bin_value}` introuvable dans le PATH — requis pour {purpose}",
|
|
80
|
+
)
|
|
81
|
+
return CheckResult("ok", name, path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_ffprobe_present() -> CheckResult:
|
|
85
|
+
from forge_mvc_audio.config import load_audio_config
|
|
86
|
+
|
|
87
|
+
return _check_binary(
|
|
88
|
+
"ffprobe", load_audio_config().ffprobe_bin, "la validation et les métadonnées"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_ffmpeg_present() -> CheckResult:
|
|
93
|
+
from forge_mvc_audio.config import load_audio_config
|
|
94
|
+
|
|
95
|
+
return _check_binary(
|
|
96
|
+
"ffmpeg", load_audio_config().ffmpeg_bin, "le transcodage MP3"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_routes_registrable() -> CheckResult:
|
|
101
|
+
try:
|
|
102
|
+
from forge_mvc_audio import register_audio_routes
|
|
103
|
+
except Exception as exc: # pragma: no cover — défensif
|
|
104
|
+
return CheckResult("fail", "routes", f"register_audio_routes absente : {exc}")
|
|
105
|
+
if not callable(register_audio_routes):
|
|
106
|
+
return CheckResult("fail", "routes", "register_audio_routes non appelable")
|
|
107
|
+
return CheckResult("ok", "routes", "register_audio_routes exposée")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_all() -> list[CheckResult]:
|
|
111
|
+
return [
|
|
112
|
+
check_package_importable(),
|
|
113
|
+
check_config_loadable(),
|
|
114
|
+
check_ffprobe_present(),
|
|
115
|
+
check_ffmpeg_present(),
|
|
116
|
+
check_routes_registrable(),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_TAGS = {"ok": "[OK] ", "warn": "[WARN]", "fail": "[FAIL]", "skip": "[SKIP]"}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def print_report(results: list[CheckResult]) -> None:
|
|
124
|
+
for r in results:
|
|
125
|
+
tag = _TAGS.get(r.status, "[????]")
|
|
126
|
+
line = f"{tag} {r.name}"
|
|
127
|
+
if r.detail:
|
|
128
|
+
line += f" — {r.detail}"
|
|
129
|
+
print(line)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def has_failures(results: list[CheckResult]) -> bool:
|
|
133
|
+
return any(r.status == "fail" for r in results)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(args: list[str] | None = None) -> int:
|
|
137
|
+
results = run_all()
|
|
138
|
+
print_report(results)
|
|
139
|
+
return 1 if has_failures(results) else 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Configuration Forge Audio.
|
|
2
|
+
|
|
3
|
+
Charge la configuration du module audio depuis un mapping (``os.environ`` par
|
|
4
|
+
défaut, ou un dict injecté pour les tests).
|
|
5
|
+
|
|
6
|
+
Module **pur** : il ne lit aucun fichier, ne lance aucun ``ffmpeg`` et n'écrit
|
|
7
|
+
nulle part. Il fixe uniquement le contrat de configuration.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_FFMPEG_BIN",
|
|
17
|
+
"DEFAULT_FFPROBE_BIN",
|
|
18
|
+
"DEFAULT_STORAGE_ROOT",
|
|
19
|
+
"DEFAULT_MAX_UPLOAD_MB",
|
|
20
|
+
"DEFAULT_MAX_DURATION_SECONDS",
|
|
21
|
+
"ENV_FFMPEG_BIN",
|
|
22
|
+
"ENV_FFPROBE_BIN",
|
|
23
|
+
"ENV_STORAGE_ROOT",
|
|
24
|
+
"ENV_MAX_UPLOAD_MB",
|
|
25
|
+
"ENV_MAX_DURATION_SECONDS",
|
|
26
|
+
"ENV_API_TOKEN",
|
|
27
|
+
"AudioConfig",
|
|
28
|
+
"load_audio_config",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
DEFAULT_FFMPEG_BIN = "ffmpeg"
|
|
32
|
+
DEFAULT_FFPROBE_BIN = "ffprobe"
|
|
33
|
+
DEFAULT_STORAGE_ROOT = "storage/audio"
|
|
34
|
+
DEFAULT_MAX_UPLOAD_MB = 200
|
|
35
|
+
DEFAULT_MAX_DURATION_SECONDS = 7200
|
|
36
|
+
|
|
37
|
+
ENV_FFMPEG_BIN = "FORGE_AUDIO_FFMPEG_BIN"
|
|
38
|
+
ENV_FFPROBE_BIN = "FORGE_AUDIO_FFPROBE_BIN"
|
|
39
|
+
ENV_STORAGE_ROOT = "FORGE_AUDIO_STORAGE_ROOT"
|
|
40
|
+
ENV_MAX_UPLOAD_MB = "FORGE_AUDIO_MAX_UPLOAD_MB"
|
|
41
|
+
ENV_MAX_DURATION_SECONDS = "FORGE_AUDIO_MAX_DURATION_SECONDS"
|
|
42
|
+
ENV_API_TOKEN = "FORGE_AUDIO_API_TOKEN"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AudioConfig:
|
|
47
|
+
"""Contrat de configuration du module audio."""
|
|
48
|
+
|
|
49
|
+
ffmpeg_bin: str = DEFAULT_FFMPEG_BIN
|
|
50
|
+
ffprobe_bin: str = DEFAULT_FFPROBE_BIN
|
|
51
|
+
storage_root: str = DEFAULT_STORAGE_ROOT
|
|
52
|
+
max_upload_mb: int = DEFAULT_MAX_UPLOAD_MB
|
|
53
|
+
max_duration_seconds: int = DEFAULT_MAX_DURATION_SECONDS
|
|
54
|
+
# Protection optionnelle des routes de lecture : si défini, un en-tête
|
|
55
|
+
# ``Authorization: Bearer <token>`` est exigé ; sinon les routes sont
|
|
56
|
+
# ouvertes (mode local/pédagogique). None = pas de token.
|
|
57
|
+
api_token: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _int(source: Mapping[str, str], key: str, default: int) -> int:
|
|
61
|
+
raw = source.get(key)
|
|
62
|
+
if raw is None or str(raw).strip() == "":
|
|
63
|
+
return default
|
|
64
|
+
try:
|
|
65
|
+
value = int(str(raw).strip())
|
|
66
|
+
except ValueError:
|
|
67
|
+
return default
|
|
68
|
+
return value if value > 0 else default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_audio_config(source: Mapping[str, str] | None = None) -> AudioConfig:
|
|
72
|
+
"""Construit un ``AudioConfig`` depuis ``source`` (``os.environ`` par défaut)."""
|
|
73
|
+
env = source if source is not None else os.environ
|
|
74
|
+
return AudioConfig(
|
|
75
|
+
ffmpeg_bin=(env.get(ENV_FFMPEG_BIN) or DEFAULT_FFMPEG_BIN).strip(),
|
|
76
|
+
ffprobe_bin=(env.get(ENV_FFPROBE_BIN) or DEFAULT_FFPROBE_BIN).strip(),
|
|
77
|
+
storage_root=(env.get(ENV_STORAGE_ROOT) or DEFAULT_STORAGE_ROOT).strip(),
|
|
78
|
+
max_upload_mb=_int(env, ENV_MAX_UPLOAD_MB, DEFAULT_MAX_UPLOAD_MB),
|
|
79
|
+
max_duration_seconds=_int(
|
|
80
|
+
env, ENV_MAX_DURATION_SECONDS, DEFAULT_MAX_DURATION_SECONDS
|
|
81
|
+
),
|
|
82
|
+
api_token=env.get(ENV_API_TOKEN) or None,
|
|
83
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Lecture HTTP des fichiers audio — streaming Range, sans état.
|
|
2
|
+
|
|
3
|
+
Branche une route de **lecture en streaming** sur un ``Router`` Forge :
|
|
4
|
+
|
|
5
|
+
- ``GET /audio/{uuid}`` — sert le fichier audio en streaming avec support HTTP
|
|
6
|
+
**Range** (seek), via la primitive core ``Response.file``.
|
|
7
|
+
|
|
8
|
+
Le chemin servi est **retrouvé sur le disque** à partir de l'``uuid`` (préférence
|
|
9
|
+
au MP3 transcodé, sinon la source) — jamais depuis l'URL, et l'``uuid`` est
|
|
10
|
+
validé comme UUID canonique → aucun *path traversal* possible.
|
|
11
|
+
|
|
12
|
+
Sécurité (optionnelle, mirror Video/IoT) : si ``FORGE_AUDIO_API_TOKEN`` est
|
|
13
|
+
défini, la route exige ``Authorization: Bearer <token>`` ; sinon elle est
|
|
14
|
+
ouverte (mode local/pédagogique). L'auth vit dans ce module, **jamais** dans
|
|
15
|
+
Forge Core.
|
|
16
|
+
|
|
17
|
+
Le module reste **opt-in** : l'application appelle ``register_audio_routes``
|
|
18
|
+
explicitement. Aucune écriture dans ``mvc/routes.py`` (charte §9).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import secrets
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from core.http.response import Response
|
|
28
|
+
|
|
29
|
+
from forge_mvc_audio.config import AudioConfig, load_audio_config
|
|
30
|
+
from forge_mvc_audio.storage import resolve_playable_relpath
|
|
31
|
+
|
|
32
|
+
__all__ = ["AudioHttpController", "register_audio_routes", "ROUTE_PLAYBACK"]
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
ROUTE_PLAYBACK = "/audio/{uuid}"
|
|
37
|
+
|
|
38
|
+
_BEARER_PREFIX = "Bearer "
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_bearer_token(request: Any) -> str | None:
|
|
42
|
+
header = request.header("Authorization", None)
|
|
43
|
+
if not header or not header.startswith(_BEARER_PREFIX):
|
|
44
|
+
return None
|
|
45
|
+
return header[len(_BEARER_PREFIX):]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_authorized(request: Any, api_token: str | None) -> bool:
|
|
49
|
+
"""Ouvert si ``api_token is None`` ; sinon Bearer == token (temps constant)."""
|
|
50
|
+
if api_token is None:
|
|
51
|
+
return True
|
|
52
|
+
provided = _extract_bearer_token(request)
|
|
53
|
+
if provided is None:
|
|
54
|
+
return False
|
|
55
|
+
return secrets.compare_digest(provided, api_token)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _error(code: str, status: int) -> Response:
|
|
59
|
+
return Response.json({"error": code}, status=status)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AudioHttpController:
|
|
63
|
+
"""Handler HTTP de lecture audio, sans état (lookup disque par uuid)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, config: AudioConfig, *, api_token: str | None = None) -> None:
|
|
66
|
+
self._config = config
|
|
67
|
+
self._api_token = api_token
|
|
68
|
+
|
|
69
|
+
def stream(self, request: Any) -> Response:
|
|
70
|
+
if not _is_authorized(request, self._api_token):
|
|
71
|
+
return _error("unauthorized", 401)
|
|
72
|
+
|
|
73
|
+
uuid = request.route("uuid")
|
|
74
|
+
# Chemin retrouvé sur le disque (jamais depuis l'URL) ; uuid validé en
|
|
75
|
+
# interne par resolve_playable_relpath → pas de path traversal.
|
|
76
|
+
rel = resolve_playable_relpath(uuid, storage_root=self._config.storage_root)
|
|
77
|
+
if not rel:
|
|
78
|
+
return _error("not_found", 404)
|
|
79
|
+
|
|
80
|
+
path = Path(self._config.storage_root) / rel
|
|
81
|
+
if not path.is_file():
|
|
82
|
+
logger.warning("Forge Audio — fichier absent pour %s : %s", uuid, path)
|
|
83
|
+
return _error("file_missing", 404)
|
|
84
|
+
|
|
85
|
+
# Streaming + Range délégués à la primitive core.
|
|
86
|
+
return Response.file(path, request)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def register_audio_routes(
|
|
90
|
+
router: Any,
|
|
91
|
+
*,
|
|
92
|
+
config: AudioConfig | None = None,
|
|
93
|
+
) -> Any:
|
|
94
|
+
"""Enregistre la route de lecture audio sur un ``Router`` Forge.
|
|
95
|
+
|
|
96
|
+
Appelée **explicitement** par l'application. Si ``config.api_token`` est
|
|
97
|
+
défini, la route exige un Bearer token. Retourne le ``router`` (chaînable).
|
|
98
|
+
"""
|
|
99
|
+
if config is None:
|
|
100
|
+
config = load_audio_config()
|
|
101
|
+
controller = AudioHttpController(config, api_token=config.api_token)
|
|
102
|
+
|
|
103
|
+
router.add(
|
|
104
|
+
"GET", ROUTE_PLAYBACK, controller.stream,
|
|
105
|
+
name="audio_stream",
|
|
106
|
+
public=True, csrf=False, api=False,
|
|
107
|
+
)
|
|
108
|
+
return router
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Ingestion d'un fichier audio — sans état (aucune base de données).
|
|
2
|
+
|
|
3
|
+
``ingest_audio`` valide (taille, extension), stocke la source à un emplacement
|
|
4
|
+
**uuid-based**, et retourne un enregistrement. Fonction Python appelable depuis
|
|
5
|
+
un contrôleur ou un script.
|
|
6
|
+
|
|
7
|
+
Validation **basique** ici (taille + extension) ; la validation profonde par
|
|
8
|
+
``ffprobe`` (vrai flux audio, durée ≤ max) appartient à ``probe_audio``. Aucun
|
|
9
|
+
``ffmpeg`` n'est lancé : pas de traitement en ligne.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import mimetypes
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from forge_mvc_audio.config import AudioConfig, load_audio_config
|
|
17
|
+
from forge_mvc_audio.storage import (
|
|
18
|
+
ALLOWED_EXTENSIONS,
|
|
19
|
+
safe_extension,
|
|
20
|
+
store_original,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["AudioIngestError", "ingest_audio"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AudioIngestError(ValueError):
|
|
27
|
+
"""Upload refusé (taille, extension, fichier vide)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ingest_audio(
|
|
31
|
+
data: bytes,
|
|
32
|
+
filename: str,
|
|
33
|
+
*,
|
|
34
|
+
title: str | None = None,
|
|
35
|
+
config: AudioConfig | None = None,
|
|
36
|
+
uuid: str | None = None,
|
|
37
|
+
) -> dict:
|
|
38
|
+
"""Valide et stocke un fichier audio source.
|
|
39
|
+
|
|
40
|
+
Retourne un dict ``{uuid, title, original_path, size_bytes, mime_type}``.
|
|
41
|
+
Lève ``AudioIngestError`` si l'upload est refusé.
|
|
42
|
+
"""
|
|
43
|
+
cfg = config or load_audio_config()
|
|
44
|
+
|
|
45
|
+
size = len(data)
|
|
46
|
+
if size == 0:
|
|
47
|
+
raise AudioIngestError("fichier vide")
|
|
48
|
+
max_bytes = cfg.max_upload_mb * 1024 * 1024
|
|
49
|
+
if size > max_bytes:
|
|
50
|
+
raise AudioIngestError(
|
|
51
|
+
f"audio trop volumineux : {size} octets > {max_bytes} "
|
|
52
|
+
f"(FORGE_AUDIO_MAX_UPLOAD_MB={cfg.max_upload_mb})"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
ext = safe_extension(filename)
|
|
56
|
+
if ext not in ALLOWED_EXTENSIONS:
|
|
57
|
+
raise AudioIngestError(
|
|
58
|
+
f"extension non autorisée : {ext!r} "
|
|
59
|
+
f"(acceptées : {', '.join(sorted(ALLOWED_EXTENSIONS))})"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
audio_uuid = uuid or str(uuid4())
|
|
63
|
+
original_path = store_original(data, audio_uuid, ext, storage_root=cfg.storage_root)
|
|
64
|
+
mime_type = mimetypes.guess_type(filename)[0]
|
|
65
|
+
|
|
66
|
+
clean_title = title.strip() if title and title.strip() else None
|
|
67
|
+
return {
|
|
68
|
+
"uuid": audio_uuid,
|
|
69
|
+
"title": clean_title,
|
|
70
|
+
"original_path": original_path,
|
|
71
|
+
"size_bytes": size,
|
|
72
|
+
"mime_type": mime_type,
|
|
73
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Extraction de métadonnées audio via ffprobe.
|
|
2
|
+
|
|
3
|
+
``probe_audio`` lance ``ffprobe`` (lecture seule) sur une source et en extrait
|
|
4
|
+
durée, codec, bitrate, sample rate et nombre de canaux. Sert aussi de
|
|
5
|
+
**validation profonde** : un fichier sans flux audio (ou que ffprobe refuse) est
|
|
6
|
+
rejeté — bien plus fiable que l'extension validée à l'upload.
|
|
7
|
+
|
|
8
|
+
L'exécution de ffprobe est déléguée à un *runner* injectable → le parsing et la
|
|
9
|
+
validation sont testables **sans ffprobe réel**. Invocation sûre : arguments en
|
|
10
|
+
**liste** (jamais ``shell=True``), chemin en argument, timeout.
|
|
11
|
+
|
|
12
|
+
Aucun ffmpeg lancé : ffprobe lit, ne transcode pas.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from forge_mvc_audio.config import AudioConfig, load_audio_config
|
|
21
|
+
|
|
22
|
+
__all__ = ["AudioMetadata", "AudioProbeError", "probe_audio", "parse_probe_json"]
|
|
23
|
+
|
|
24
|
+
# Un runner prend (ffprobe_bin, path) et retourne la sortie JSON de ffprobe.
|
|
25
|
+
ProbeRunner = Callable[[str, str], str]
|
|
26
|
+
|
|
27
|
+
_PROBE_TIMEOUT_SECONDS = 30
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AudioProbeError(Exception):
|
|
31
|
+
"""ffprobe a échoué, ou la source n'est pas un audio exploitable."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class AudioMetadata:
|
|
36
|
+
duration_seconds: int | None
|
|
37
|
+
audio_codec: str | None
|
|
38
|
+
bitrate_kbps: int | None
|
|
39
|
+
sample_rate_hz: int | None
|
|
40
|
+
channels: int | None
|
|
41
|
+
container: str | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_runner(ffprobe_bin: str, path: str) -> str:
|
|
45
|
+
"""Lance ffprobe et retourne sa sortie JSON (stdout)."""
|
|
46
|
+
import subprocess
|
|
47
|
+
|
|
48
|
+
cmd = [
|
|
49
|
+
ffprobe_bin,
|
|
50
|
+
"-v", "error",
|
|
51
|
+
"-print_format", "json",
|
|
52
|
+
"-show_format",
|
|
53
|
+
"-show_streams",
|
|
54
|
+
path,
|
|
55
|
+
]
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
cmd, capture_output=True, text=True, timeout=_PROBE_TIMEOUT_SECONDS
|
|
59
|
+
)
|
|
60
|
+
except FileNotFoundError as exc:
|
|
61
|
+
raise AudioProbeError(f"ffprobe introuvable : {ffprobe_bin}") from exc
|
|
62
|
+
except subprocess.TimeoutExpired as exc:
|
|
63
|
+
raise AudioProbeError("ffprobe a dépassé le délai") from exc
|
|
64
|
+
if result.returncode != 0:
|
|
65
|
+
raise AudioProbeError(
|
|
66
|
+
f"ffprobe a échoué (code {result.returncode}) : {result.stderr.strip()}"
|
|
67
|
+
)
|
|
68
|
+
return result.stdout
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _int_or_none(value) -> int | None:
|
|
72
|
+
if value is None or str(value).strip() == "":
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
return int(float(value))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_probe_json(payload: str | dict) -> AudioMetadata:
|
|
81
|
+
"""Parse la sortie ffprobe (``-print_format json``) en ``AudioMetadata``.
|
|
82
|
+
|
|
83
|
+
Lève ``AudioProbeError`` si la sortie est illisible ou sans flux audio.
|
|
84
|
+
"""
|
|
85
|
+
data = json.loads(payload) if isinstance(payload, str) else payload
|
|
86
|
+
streams = data.get("streams", []) if isinstance(data, dict) else []
|
|
87
|
+
fmt = data.get("format", {}) if isinstance(data, dict) else {}
|
|
88
|
+
|
|
89
|
+
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
|
90
|
+
if audio is None:
|
|
91
|
+
raise AudioProbeError("aucun flux audio détecté")
|
|
92
|
+
|
|
93
|
+
duration = fmt.get("duration") or audio.get("duration")
|
|
94
|
+
bitrate = fmt.get("bit_rate") or audio.get("bit_rate")
|
|
95
|
+
bitrate_kbps = _int_or_none(bitrate)
|
|
96
|
+
if bitrate_kbps is not None:
|
|
97
|
+
bitrate_kbps = bitrate_kbps // 1000
|
|
98
|
+
|
|
99
|
+
return AudioMetadata(
|
|
100
|
+
duration_seconds=_int_or_none(duration),
|
|
101
|
+
audio_codec=audio.get("codec_name"),
|
|
102
|
+
bitrate_kbps=bitrate_kbps,
|
|
103
|
+
sample_rate_hz=_int_or_none(audio.get("sample_rate")),
|
|
104
|
+
channels=_int_or_none(audio.get("channels")),
|
|
105
|
+
container=fmt.get("format_name"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def probe_audio(
|
|
110
|
+
path: str,
|
|
111
|
+
*,
|
|
112
|
+
config: AudioConfig | None = None,
|
|
113
|
+
runner: ProbeRunner | None = None,
|
|
114
|
+
) -> AudioMetadata:
|
|
115
|
+
"""Sonde ``path`` et retourne ses métadonnées (valide le flux + la durée)."""
|
|
116
|
+
cfg = config or load_audio_config()
|
|
117
|
+
run: ProbeRunner = runner or _default_runner
|
|
118
|
+
|
|
119
|
+
meta = parse_probe_json(run(cfg.ffprobe_bin, str(path)))
|
|
120
|
+
|
|
121
|
+
if (
|
|
122
|
+
cfg.max_duration_seconds
|
|
123
|
+
and meta.duration_seconds is not None
|
|
124
|
+
and meta.duration_seconds > cfg.max_duration_seconds
|
|
125
|
+
):
|
|
126
|
+
raise AudioProbeError(
|
|
127
|
+
f"durée {meta.duration_seconds}s > maximum "
|
|
128
|
+
f"{cfg.max_duration_seconds}s (FORGE_AUDIO_MAX_DURATION_SECONDS)"
|
|
129
|
+
)
|
|
130
|
+
return meta
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Disposition des fichiers audio sur disque — sans état.
|
|
2
|
+
|
|
3
|
+
Le stockage est **uuid-based** : le nom de fichier fourni par l'utilisateur
|
|
4
|
+
n'apparaît **jamais** dans le chemin (anti-traversal par construction). Seule
|
|
5
|
+
l'extension, validée contre une liste blanche, est conservée.
|
|
6
|
+
|
|
7
|
+
Disposition (sous ``FORGE_AUDIO_STORAGE_ROOT``), **non partitionnée par date**
|
|
8
|
+
pour permettre le lookup par ``uuid`` seul (pas de base de données) :
|
|
9
|
+
|
|
10
|
+
originals/<uuid>/source<ext>
|
|
11
|
+
transcoded/<uuid>/audio.mp3
|
|
12
|
+
|
|
13
|
+
Le lookup de lecture (``resolve_playable_relpath``) cherche d'abord le MP3
|
|
14
|
+
transcodé, puis la source — uniquement à l'intérieur du dossier de l'``uuid``.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from uuid import UUID
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ALLOWED_EXTENSIONS",
|
|
23
|
+
"safe_extension",
|
|
24
|
+
"is_valid_uuid",
|
|
25
|
+
"original_relpath",
|
|
26
|
+
"transcoded_relpath",
|
|
27
|
+
"store_original",
|
|
28
|
+
"resolve_playable_relpath",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Conteneurs/formats audio acceptés en entrée. La sortie de transcodage est MP3.
|
|
32
|
+
ALLOWED_EXTENSIONS = frozenset({".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def safe_extension(filename: str) -> str:
|
|
36
|
+
"""Extension en minuscules du nom fourni (``""`` si aucune)."""
|
|
37
|
+
return Path(filename or "").suffix.lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_valid_uuid(value: str) -> bool:
|
|
41
|
+
"""Vrai si ``value`` est un UUID canonique (clé de lookup sûre)."""
|
|
42
|
+
try:
|
|
43
|
+
return str(UUID(str(value))) == str(value).lower()
|
|
44
|
+
except (ValueError, AttributeError, TypeError):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def original_relpath(uuid: str, ext: str) -> str:
|
|
49
|
+
"""Chemin relatif de la source, uuid-based."""
|
|
50
|
+
return f"originals/{uuid}/source{ext}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def transcoded_relpath(uuid: str) -> str:
|
|
54
|
+
"""Chemin relatif du MP3 transcodé, uuid-based."""
|
|
55
|
+
return f"transcoded/{uuid}/audio.mp3"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def store_original(
|
|
59
|
+
data: bytes, uuid: str, ext: str, *, storage_root: str
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Écrit la source à un emplacement uuid-based ; retourne le chemin relatif.
|
|
62
|
+
|
|
63
|
+
L'``uuid`` est validé avant toute construction de chemin : il apparaît dans
|
|
64
|
+
l'arborescence (``originals/<uuid>/``), donc un uuid non canonique fourni par
|
|
65
|
+
l'appelant serait un vecteur de traversal à l'écriture. La symétrie avec
|
|
66
|
+
``resolve_playable_relpath`` (qui valide en lecture) est ainsi garantie.
|
|
67
|
+
"""
|
|
68
|
+
if not is_valid_uuid(uuid):
|
|
69
|
+
raise ValueError(f"uuid audio invalide (anti-traversal) : {uuid!r}")
|
|
70
|
+
rel = original_relpath(uuid, ext)
|
|
71
|
+
target = Path(storage_root) / rel
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
target.write_bytes(data)
|
|
74
|
+
return rel
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_playable_relpath(uuid: str, *, storage_root: str) -> str | None:
|
|
78
|
+
"""Retrouve le fichier lisible pour ``uuid`` (sans base de données).
|
|
79
|
+
|
|
80
|
+
Préfère le MP3 transcodé s'il existe, sinon la source d'origine. Retourne le
|
|
81
|
+
chemin **relatif** (sous ``storage_root``) ou ``None`` si rien n'est trouvé.
|
|
82
|
+
Refuse tout ``uuid`` qui n'est pas un UUID canonique (anti-traversal).
|
|
83
|
+
"""
|
|
84
|
+
if not is_valid_uuid(uuid):
|
|
85
|
+
return None
|
|
86
|
+
root = Path(storage_root)
|
|
87
|
+
|
|
88
|
+
transcoded = transcoded_relpath(uuid)
|
|
89
|
+
if (root / transcoded).is_file():
|
|
90
|
+
return transcoded
|
|
91
|
+
|
|
92
|
+
source_dir = root / "originals" / uuid
|
|
93
|
+
if source_dir.is_dir():
|
|
94
|
+
for entry in sorted(source_dir.glob("source.*")):
|
|
95
|
+
if entry.is_file():
|
|
96
|
+
return f"originals/{uuid}/{entry.name}"
|
|
97
|
+
return None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Runner ffmpeg : transcodage MP3.
|
|
2
|
+
|
|
3
|
+
Construit et exécute la commande ffmpeg du profil MP3 standard Forge Audio. Le
|
|
4
|
+
constructeur de commande est **pur** (testable sans ffmpeg) ; l'exécution est
|
|
5
|
+
déléguée à un *runner* injectable.
|
|
6
|
+
|
|
7
|
+
Sécurité d'invocation :
|
|
8
|
+
- arguments en **liste** (jamais ``shell=True``) → aucune injection ;
|
|
9
|
+
- chemins passés en arguments (le caller fournit des chemins uuid-based,
|
|
10
|
+
jamais le nom de fichier utilisateur) ;
|
|
11
|
+
- ``timeout`` obligatoire (un ffmpeg sans borne peut tourner indéfiniment).
|
|
12
|
+
|
|
13
|
+
Profil MP3 standard :
|
|
14
|
+
|
|
15
|
+
-c:a libmp3lame -b:a 192k -ac 2
|
|
16
|
+
-map_metadata -1 (retire les métadonnées d'origine)
|
|
17
|
+
-vn (ignore toute piste vidéo/pochette → audio pur)
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"FfmpegError",
|
|
25
|
+
"DEFAULT_TRANSCODE_TIMEOUT",
|
|
26
|
+
"build_transcode_command",
|
|
27
|
+
"transcode_to_mp3",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Un runner prend (cmd, timeout) et retourne (returncode, stderr).
|
|
31
|
+
FfmpegRunner = Callable[[list[str], int], tuple[int, str]]
|
|
32
|
+
|
|
33
|
+
DEFAULT_TRANSCODE_TIMEOUT = 1800 # 30 min — borne haute d'un transcodage audio
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FfmpegError(Exception):
|
|
37
|
+
"""ffmpeg a échoué, est absent, ou a dépassé le délai."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_transcode_command(
|
|
41
|
+
ffmpeg_bin: str, input_path: str, output_path: str, *, bitrate_kbps: int = 192
|
|
42
|
+
) -> list[str]:
|
|
43
|
+
"""Commande de transcodage MP3 (libmp3lame, stéréo, sans métadonnées)."""
|
|
44
|
+
return [
|
|
45
|
+
ffmpeg_bin,
|
|
46
|
+
"-y",
|
|
47
|
+
"-i", input_path,
|
|
48
|
+
"-vn",
|
|
49
|
+
"-c:a", "libmp3lame", "-b:a", f"{bitrate_kbps}k", "-ac", "2",
|
|
50
|
+
"-map_metadata", "-1",
|
|
51
|
+
output_path,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _default_ffmpeg_runner(cmd: list[str], timeout: int) -> tuple[int, str]:
|
|
56
|
+
import subprocess
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
cmd, capture_output=True, text=True, timeout=timeout
|
|
61
|
+
)
|
|
62
|
+
except FileNotFoundError as exc:
|
|
63
|
+
raise FfmpegError(f"ffmpeg introuvable : {cmd[0]}") from exc
|
|
64
|
+
except subprocess.TimeoutExpired as exc:
|
|
65
|
+
raise FfmpegError(f"ffmpeg a dépassé le délai ({timeout}s)") from exc
|
|
66
|
+
return result.returncode, result.stderr
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def transcode_to_mp3(
|
|
70
|
+
input_path: str,
|
|
71
|
+
output_path: str,
|
|
72
|
+
*,
|
|
73
|
+
ffmpeg_bin: str = "ffmpeg",
|
|
74
|
+
bitrate_kbps: int = 192,
|
|
75
|
+
runner: FfmpegRunner | None = None,
|
|
76
|
+
timeout: int = DEFAULT_TRANSCODE_TIMEOUT,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Transcode ``input_path`` en MP3 standard à ``output_path``."""
|
|
79
|
+
cmd = build_transcode_command(
|
|
80
|
+
ffmpeg_bin, input_path, output_path, bitrate_kbps=bitrate_kbps
|
|
81
|
+
)
|
|
82
|
+
code, stderr = (runner or _default_ffmpeg_runner)(cmd, timeout)
|
|
83
|
+
if code != 0:
|
|
84
|
+
raise FfmpegError(f"ffmpeg a échoué (code {code}) : {stderr.strip()}")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mvc-audio
|
|
3
|
+
Version: 1.0.0b16
|
|
4
|
+
Summary: Forge Audio — module opt-in pour l'upload, le sondage de métadonnées (ffprobe), le transcodage MP3 (ffmpeg) et la lecture audio en streaming (HTTP Range). Sans état : aucune base de données. Worker CLI forge audio:doctor.
|
|
5
|
+
Author: Roger Lequette
|
|
6
|
+
License-Expression: LicenseRef-Forge-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/caucrogeGit/Forge
|
|
8
|
+
Project-URL: Repository, https://github.com/caucrogeGit/Forge
|
|
9
|
+
Project-URL: Documentation, https://forgemvc.com/docs/forge/audio/
|
|
10
|
+
Keywords: python,mvc,forge,audio,ffmpeg,mp3,streaming
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: forge-mvc<2,>=1.0.0b16
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# forge-mvc-audio
|
|
23
|
+
|
|
24
|
+
Module **opt-in** Forge pour la gestion audio : upload, sondage de métadonnées,
|
|
25
|
+
transcodage MP3 et lecture en streaming. **Sans état** (aucune base de données).
|
|
26
|
+
|
|
27
|
+
## Statut : Beta — opt-in officiel
|
|
28
|
+
|
|
29
|
+
`forge-mvc-audio` fournit une chaîne audio complète et **sobre**, calquée sur
|
|
30
|
+
`forge-mvc-video` mais **sans la machinerie à état** (pas de table SQL, pas de
|
|
31
|
+
suivi de jobs, pas de file de transcodage). C'est un choix délibéré : les
|
|
32
|
+
opérations audio sont synchrones et la lecture retrouve les fichiers par `uuid`
|
|
33
|
+
sur le disque.
|
|
34
|
+
|
|
35
|
+
> **ffmpeg / ffprobe** sont des binaires **système** (pas des dépendances pip).
|
|
36
|
+
> Le module se branche sans eux (le service de lecture fonctionne), mais le
|
|
37
|
+
> sondage exige `ffprobe` et le transcodage exige `ffmpeg`.
|
|
38
|
+
> `forge audio:doctor` signale leur absence.
|
|
39
|
+
|
|
40
|
+
Installation (mode éditable depuis les sources) :
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e packages/forge-mvc-audio/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Ce que contient le module
|
|
47
|
+
|
|
48
|
+
- `config` — `AudioConfig` + `load_audio_config()` (configuration depuis
|
|
49
|
+
`FORGE_AUDIO_*`, valeurs par défaut sûres).
|
|
50
|
+
- `storage` — disposition **uuid-based** des fichiers (le nom utilisateur
|
|
51
|
+
n'apparaît jamais dans le chemin → anti-traversal par construction).
|
|
52
|
+
- `probe` — `probe_audio()` via `ffprobe` : durée, codec, bitrate, sample rate,
|
|
53
|
+
nombre de canaux, conteneur. Sert aussi de validation profonde (rejet d'un
|
|
54
|
+
fichier sans flux audio).
|
|
55
|
+
- `ingest` — `ingest_audio(data, filename)` : valide (taille, extension), stocke
|
|
56
|
+
la source, retourne un enregistrement (`uuid`, chemin, taille, MIME).
|
|
57
|
+
- `transcode` — `transcode_to_mp3()` via `ffmpeg` (profil MP3 192 kbps, stéréo,
|
|
58
|
+
métadonnées d'origine retirées). Constructeurs de commande **purs**, runner
|
|
59
|
+
injectable (testable sans ffmpeg).
|
|
60
|
+
- `http` — `register_audio_routes(router)` : route `GET /audio/{uuid}` servie en
|
|
61
|
+
**streaming HTTP Range** (seek), Bearer token optionnel
|
|
62
|
+
(`FORGE_AUDIO_API_TOKEN`).
|
|
63
|
+
- `cli.doctor` — `forge audio:doctor` : diagnostic statique (package, config,
|
|
64
|
+
ffprobe, ffmpeg, routes).
|
|
65
|
+
|
|
66
|
+
## Formats acceptés à l'upload
|
|
67
|
+
|
|
68
|
+
`mp3`, `wav`, `ogg`, `flac`, `m4a`, `aac`. La sortie de transcodage est MP3.
|
|
69
|
+
|
|
70
|
+
## Sécurité
|
|
71
|
+
|
|
72
|
+
- Invocation `ffmpeg`/`ffprobe` en **liste d'arguments** (jamais `shell=True`).
|
|
73
|
+
- Chemins **uuid-based** : le `uuid` de l'URL n'est qu'une clé de lookup, validée
|
|
74
|
+
comme un UUID — aucun *path traversal*.
|
|
75
|
+
- Token Bearer optionnel sur la route de lecture (mode local ouvert par défaut).
|
|
76
|
+
- L'auth vit dans ce module, **jamais** dans Forge Core.
|
|
77
|
+
|
|
78
|
+
## Configuration (`FORGE_AUDIO_*`)
|
|
79
|
+
|
|
80
|
+
| Variable | Défaut | Rôle |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `FORGE_AUDIO_FFMPEG_BIN` | `ffmpeg` | binaire ffmpeg |
|
|
83
|
+
| `FORGE_AUDIO_FFPROBE_BIN` | `ffprobe` | binaire ffprobe |
|
|
84
|
+
| `FORGE_AUDIO_STORAGE_ROOT` | `storage/audio` | racine de stockage |
|
|
85
|
+
| `FORGE_AUDIO_MAX_UPLOAD_MB` | `200` | taille max d'upload |
|
|
86
|
+
| `FORGE_AUDIO_MAX_DURATION_SECONDS` | `7200` | durée max (2 h) |
|
|
87
|
+
| `FORGE_AUDIO_API_TOKEN` | *(absent)* | Bearer token de lecture (optionnel) |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
forge_mvc_audio/__init__.py
|
|
5
|
+
forge_mvc_audio/config.py
|
|
6
|
+
forge_mvc_audio/http.py
|
|
7
|
+
forge_mvc_audio/ingest.py
|
|
8
|
+
forge_mvc_audio/probe.py
|
|
9
|
+
forge_mvc_audio/storage.py
|
|
10
|
+
forge_mvc_audio/transcode.py
|
|
11
|
+
forge_mvc_audio.egg-info/PKG-INFO
|
|
12
|
+
forge_mvc_audio.egg-info/SOURCES.txt
|
|
13
|
+
forge_mvc_audio.egg-info/dependency_links.txt
|
|
14
|
+
forge_mvc_audio.egg-info/requires.txt
|
|
15
|
+
forge_mvc_audio.egg-info/top_level.txt
|
|
16
|
+
forge_mvc_audio/cli/__init__.py
|
|
17
|
+
forge_mvc_audio/cli/doctor.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge-mvc<2,>=1.0.0b16
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge_mvc_audio
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forge-mvc-audio"
|
|
7
|
+
version = "1.0.0b16"
|
|
8
|
+
description = "Forge Audio — module opt-in pour l'upload, le sondage de métadonnées (ffprobe), le transcodage MP3 (ffmpeg) et la lecture audio en streaming (HTTP Range). Sans état : aucune base de données. Worker CLI forge audio:doctor."
|
|
9
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Roger Lequette" },
|
|
13
|
+
]
|
|
14
|
+
license = "LicenseRef-Forge-Proprietary"
|
|
15
|
+
keywords = ["python", "mvc", "forge", "audio", "ffmpeg", "mp3", "streaming"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"forge-mvc>=1.0.0b16,<2",
|
|
18
|
+
]
|
|
19
|
+
# NB : ffmpeg/ffprobe sont des binaires SYSTÈME, pas des dépendances pip.
|
|
20
|
+
# Le module se branche sans eux (mode serveur de fichiers / probe indisponible),
|
|
21
|
+
# mais `forge audio:doctor` signale leur absence ; le transcodage les exige.
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/caucrogeGit/Forge"
|
|
32
|
+
Repository = "https://github.com/caucrogeGit/Forge"
|
|
33
|
+
Documentation = "https://forgemvc.com/docs/forge/audio/"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["."]
|
|
37
|
+
include = ["forge_mvc_audio*"]
|