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.
@@ -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
+ 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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+