Initial commit: MKV → AVI web app (Docker + Keycloak OIDC)
- App FastAPI : recherche dans un dossier média, download original ou conversion en AVI Xvid (tone-mapping HDR→SDR, audio FR auto, MP3 stéréo) - Auth Keycloak OIDC (Authlib) optionnelle - 3 composes : app seule, stack local (keycloak.local), production (NPM) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
media/
|
||||
output/
|
||||
testmedia/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.prod.example
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copier en .env et adapter : cp .env.example .env
|
||||
|
||||
# Dossiers
|
||||
MEDIA_DIR=/Volumes/data/films # dossier des films (monté en lecture seule)
|
||||
OUTPUT_DIR=./output # où atterrissent les AVI convertis
|
||||
|
||||
# --- Authentification Keycloak (OIDC) ---
|
||||
# Laisser OIDC_ISSUER vide pour DÉSACTIVER l'auth (dev local).
|
||||
# Issuer = URL du realm (sans slash final)
|
||||
OIDC_ISSUER=https://keycloak.mondomaine.fr/realms/mon-realm
|
||||
OIDC_CLIENT_ID=mkv2avi
|
||||
OIDC_CLIENT_SECRET=colle-le-secret-du-client-ici
|
||||
|
||||
# Optionnel : forcer l'URL de callback (sinon déduite de la requête)
|
||||
# Doit être déclarée dans "Valid redirect URIs" du client Keycloak.
|
||||
OIDC_REDIRECT_URI=https://mkv2avi.mondomaine.fr/auth/callback
|
||||
# Optionnel : où revenir après déconnexion (à déclarer dans "Valid post logout redirect URIs")
|
||||
OIDC_POST_LOGOUT_REDIRECT_URI=https://mkv2avi.mondomaine.fr/
|
||||
|
||||
# Secret de signature des cookies (générer : openssl rand -hex 32)
|
||||
SESSION_SECRET=change-moi-avec-openssl-rand-hex-32
|
||||
# Mettre true si l'app est servie en HTTPS (derrière Nginx Proxy Manager en TLS)
|
||||
SESSION_HTTPS_ONLY=true
|
||||
@@ -0,0 +1,27 @@
|
||||
# Config PRODUCTION — copier en .env.prod puis remplir, JAMAIS committer .env.prod
|
||||
# cp .env.prod.example .env.prod
|
||||
# docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
|
||||
|
||||
# --- Dossiers ---
|
||||
MEDIA_DIR=/mnt/pool-r440/films # dataset des films (monté en lecture seule)
|
||||
OUTPUT_DIR=/mnt/pool-r440/mkv2avi-out # dataset de sortie des AVI
|
||||
|
||||
# --- Ports publiés (ce que NPM va cibler sur l'IP de l'hôte) ---
|
||||
KC_HTTP_PORT=8080
|
||||
APP_HTTP_PORT=8090
|
||||
|
||||
# --- Secrets à générer (openssl rand -hex 32) ---
|
||||
OIDC_CLIENT_SECRET=REMPLACER_par_le_secret_du_client_keycloak
|
||||
SESSION_SECRET=REMPLACER_par_openssl_rand_hex_32
|
||||
|
||||
# --- Comptes Keycloak / base ---
|
||||
KC_ADMIN=admin
|
||||
KC_ADMIN_PASSWORD=REMPLACER_mot_de_passe_admin_fort
|
||||
KC_DB_PASSWORD=REMPLACER_mot_de_passe_postgres_fort
|
||||
|
||||
# --- Conversion ---
|
||||
MAX_WORKERS=1
|
||||
|
||||
# ⚠️ IMPORTANT : le secret du client dans keycloak/realm-mkv2avi.json ("secret")
|
||||
# doit être identique à OIDC_CLIENT_SECRET ci-dessus. Change les deux ensemble
|
||||
# (ou régénère le secret dans la console Keycloak après le 1er démarrage).
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Données locales
|
||||
output/
|
||||
media/
|
||||
testmedia/
|
||||
|
||||
# Secrets / env
|
||||
.env
|
||||
.env.prod
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# ffmpeg/ffprobe statiques complets (incluent zscale/zimg pour le tone-mapping HDR)
|
||||
FROM mwader/static-ffmpeg:7.1 AS ffmpeg
|
||||
|
||||
FROM python:3.12-slim
|
||||
COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ /app/
|
||||
|
||||
ENV MEDIA_DIR=/media \
|
||||
OUTPUT_DIR=/output \
|
||||
MAX_WORKERS=1
|
||||
|
||||
EXPOSE 8000
|
||||
VOLUME ["/media", "/output"]
|
||||
|
||||
# --proxy-headers : respecte X-Forwarded-Proto/Host derrière Nginx Proxy Manager
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", \
|
||||
"--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
@@ -0,0 +1,174 @@
|
||||
# 🎬 MKV → AVI
|
||||
|
||||
Petite application web (Docker) pour parcourir un dossier média, **télécharger** un
|
||||
fichier d'origine ou le **convertir en AVI Xvid** lisible par les vieux lecteurs DivX/DVD.
|
||||
|
||||
La conversion reprend le pipeline qui a fait ses preuves :
|
||||
|
||||
- vidéo **Xvid** (`mpeg4 -vtag xvid`, qualité `qscale 4`) ;
|
||||
- audio **MP3 stéréo** (downmix 5.1 → 2.0) ;
|
||||
- sélection auto de la **piste française** en évitant l'audiodescription ;
|
||||
- **tone-mapping HDR → SDR** propre via `zscale` (les sources 4K HDR10/DV/HLG
|
||||
sont détectées automatiquement) ;
|
||||
- redimensionnement au choix (720 px « max compatibilité », 1280 px, ou original).
|
||||
|
||||
> ℹ️ Le décodage est **logiciel** : dans un conteneur, l'accélération matérielle
|
||||
> (VideoToolbox sur Mac, QSV/NVENC) n'est pas disponible. Comptez plusieurs minutes
|
||||
> à quelques dizaines de minutes par film selon le CPU.
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
```bash
|
||||
# Dossier contenant vos films (monté en lecture seule)
|
||||
export MEDIA_DIR=/chemin/vers/mes/films
|
||||
# Où écrire les AVI convertis (optionnel, défaut ./output)
|
||||
export OUTPUT_DIR=$PWD/output
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Puis ouvrir **http://localhost:8080**.
|
||||
|
||||
- Rechercher un fichier dans le champ de recherche.
|
||||
- **⬇ Télécharger** : récupère le fichier d'origine.
|
||||
- **🎬 Convertir en AVI** : choisir définition + piste audio → suivre la progression →
|
||||
**⬇ Télécharger l'AVI** une fois terminé.
|
||||
|
||||
Les fichiers convertis restent aussi dans le dossier `OUTPUT_DIR`.
|
||||
|
||||
## Authentification Keycloak (OIDC)
|
||||
|
||||
L'app peut exiger une connexion Keycloak (flow **Authorization Code**, session par
|
||||
cookie). Elle est **désactivée tant que `OIDC_ISSUER` est vide** — pratique en dev local.
|
||||
Une fois configurée, **tout utilisateur authentifié** du realm a accès ; login/logout
|
||||
et nom de l'utilisateur sont gérés dans l'app.
|
||||
|
||||
### 1. Créer le client dans Keycloak
|
||||
- **Clients → Create client** : type `OpenID Connect`, Client ID `mkv2avi`.
|
||||
- **Client authentication : On** (client confidentiel) → un *secret* est généré
|
||||
(onglet **Credentials**).
|
||||
- **Valid redirect URIs** : `https://mkv2avi.mondomaine.fr/auth/callback`
|
||||
- **Valid post logout redirect URIs** : `https://mkv2avi.mondomaine.fr/`
|
||||
- **Web origins** : `+` (ou l'URL exacte).
|
||||
|
||||
### 2. Renseigner les variables
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# puis éditer .env :
|
||||
# OIDC_ISSUER=https://keycloak.mondomaine.fr/realms/mon-realm
|
||||
# OIDC_CLIENT_ID=mkv2avi
|
||||
# OIDC_CLIENT_SECRET=...(onglet Credentials)
|
||||
# SESSION_SECRET=$(openssl rand -hex 32)
|
||||
# SESSION_HTTPS_ONLY=true # si servi en HTTPS via Nginx Proxy Manager
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
> Derrière **Nginx Proxy Manager** (TLS) : uvicorn tourne avec `--proxy-headers`, donc
|
||||
> l'URL de callback est déduite en `https`. En cas de souci, forcez-la avec
|
||||
> `OIDC_REDIRECT_URI`. Activez `SESSION_HTTPS_ONLY=true` pour des cookies `Secure`.
|
||||
|
||||
### Stack tout-en-un (Keycloak inclus)
|
||||
|
||||
Pour tester/déployer **Keycloak + PostgreSQL + l'app** d'un coup, un compose dédié
|
||||
avec un realm pré-importé (client `mkv2avi` + utilisateur de test) est fourni :
|
||||
|
||||
```bash
|
||||
# 1. Rendre Keycloak résolvable sous le même nom côté navigateur ET côté conteneur
|
||||
echo "127.0.0.1 keycloak.local" | sudo tee -a /etc/hosts
|
||||
|
||||
# 2. (optionnel) dossier de films à convertir
|
||||
export MEDIA_DIR=/Volumes/data/films
|
||||
|
||||
# 3. Démarrer le stack
|
||||
docker compose -f docker-compose.keycloak.yml up -d --build
|
||||
```
|
||||
|
||||
| Service | URL | Identifiants |
|
||||
|---------|-----|--------------|
|
||||
| App MKV → AVI | http://localhost:8090 | `alex` / `alex` |
|
||||
| Console Keycloak | http://localhost:8080 | `admin` / `admin` |
|
||||
|
||||
Le realm `mkv2avi` (`keycloak/realm-mkv2avi.json`) contient déjà le client confidentiel
|
||||
(`secret : mkv2avi-secret-change-me`) et un utilisateur. **Change les secrets/mots de
|
||||
passe avant tout usage réel** (variables `OIDC_CLIENT_SECRET`, `SESSION_SECRET`,
|
||||
`KC_ADMIN_PASSWORD`, `KC_DB_PASSWORD`, et le `secret` dans le realm JSON).
|
||||
|
||||
> **Pourquoi `keycloak.local` ?** L'URL de Keycloak (l'*issuer*) doit être **identique**
|
||||
> depuis le navigateur et depuis le conteneur de l'app. L'alias réseau Docker
|
||||
> `keycloak.local` + la ligne `/etc/hosts` garantissent cette cohérence. En production
|
||||
> derrière Nginx Proxy Manager, on remplace tout ça par le vrai domaine
|
||||
> (`KC_HOSTNAME` + `OIDC_ISSUER` = `https://auth.mondomaine.fr/realms/mkv2avi`).
|
||||
|
||||
## Production derrière Nginx Proxy Manager
|
||||
|
||||
Fichier `docker-compose.prod.yml` : Keycloak en **mode production** (`start`), TLS
|
||||
terminé par NPM. Domaines : app = `https://mkv2avi.maisongrondin.fr`,
|
||||
Keycloak = `https://auth.mkv2avi.maisongrondin.fr`.
|
||||
|
||||
### 1. Secrets
|
||||
```bash
|
||||
cp .env.prod.example .env.prod
|
||||
# générer : openssl rand -hex 32 (pour OIDC_CLIENT_SECRET et SESSION_SECRET)
|
||||
# OIDC_CLIENT_SECRET doit être identique au "secret" du client dans
|
||||
# keycloak/realm-mkv2avi.json (ou régénéré ensuite dans la console Keycloak).
|
||||
```
|
||||
|
||||
### 2. Démarrage
|
||||
```bash
|
||||
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
Les services publient sur l'hôte : Keycloak `:8080`, app `:8090`.
|
||||
|
||||
### 3. Deux Proxy Hosts dans NPM
|
||||
| Domaine | Forward vers | Options |
|
||||
|---------|--------------|---------|
|
||||
| `auth.mkv2avi.maisongrondin.fr` | `http` `<IP_HOTE>` `8080` | SSL (Let's Encrypt) · Force SSL · Websockets |
|
||||
| `mkv2avi.maisongrondin.fr` | `http` `<IP_HOTE>` `8090` | SSL · Force SSL · Websockets |
|
||||
|
||||
Pour l'app, ajoute cette **Advanced / Custom Nginx Configuration** (téléchargements de
|
||||
gros fichiers sans coupure — même logique que ton fix Nextcloud) :
|
||||
```nginx
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
client_max_body_size 0;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
```
|
||||
|
||||
### 4. Points de vigilance
|
||||
- **DNS interne** : le conteneur `mkv2avi` doit résoudre `auth.mkv2avi.maisongrondin.fr`
|
||||
(il valide les tokens via cette URL). Si ton réseau ne fait pas de *hairpin NAT*,
|
||||
décommente le bloc `extra_hosts` du compose en pointant vers l'IP de NPM/hôte.
|
||||
- **Keycloak** sert en HTTP interne (`KC_HTTP_ENABLED=true`) et fait confiance aux
|
||||
en-têtes `X-Forwarded-*` de NPM (`KC_PROXY_HEADERS=xforwarded`) ; l'issuer reste
|
||||
figé sur l'URL publique (`KC_HOSTNAME`).
|
||||
- Change **tous** les mots de passe/secrets par défaut avant exposition.
|
||||
|
||||
## Déploiement sur TrueNAS SCALE
|
||||
|
||||
Utilisable comme *Custom App* (Docker) : montez le dataset des films sur `/media`
|
||||
(en lecture seule) et un dataset de sortie sur `/output`, exposez le port `8000`.
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---------------|-----------|----------------------------------------------|
|
||||
| `MEDIA_DIR` | `/media` | Dossier parcouru (monté en lecture seule) |
|
||||
| `OUTPUT_DIR` | `/output` | Dossier des AVI convertis |
|
||||
| `MAX_WORKERS` | `1` | Conversions simultanées (1 = en série) |
|
||||
| `MAX_RESULTS` | `400` | Nb max de fichiers listés par recherche |
|
||||
| `OIDC_ISSUER` | *(vide)* | URL du realm Keycloak (vide = auth off) |
|
||||
| `OIDC_CLIENT_ID` | *(vide)* | Client ID Keycloak |
|
||||
| `OIDC_CLIENT_SECRET` | *(vide)* | Secret du client confidentiel |
|
||||
| `OIDC_REDIRECT_URI` | *(auto)* | Force l'URL de callback si besoin |
|
||||
| `SESSION_SECRET` | *(à changer)* | Clé de signature des cookies de session |
|
||||
| `SESSION_HTTPS_ONLY` | `false` | Cookies `Secure` (mettre `true` en HTTPS) |
|
||||
|
||||
## Développement local (sans Docker)
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# ffmpeg avec le filtre zscale requis pour le HDR (sinon HDR non géré)
|
||||
MEDIA_DIR=/chemin/films OUTPUT_DIR=./output \
|
||||
uvicorn main:app --app-dir app --reload --port 8000
|
||||
```
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
MKV -> AVI Web UI
|
||||
Petit service FastAPI : parcourir/rechercher un dossier média monté,
|
||||
télécharger un fichier d'origine ou le convertir en AVI (Xvid + MP3)
|
||||
avec tone-mapping HDR->SDR pour les vieux lecteurs.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Configuration (via variables d'environnement)
|
||||
# --------------------------------------------------------------------------- #
|
||||
MEDIA_DIR = Path(os.environ.get("MEDIA_DIR", "/media")).resolve()
|
||||
OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/output")).resolve()
|
||||
FFMPEG = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
|
||||
FFPROBE = os.environ.get("FFPROBE", shutil.which("ffprobe") or "ffprobe")
|
||||
MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "1"))
|
||||
MAX_RESULTS = int(os.environ.get("MAX_RESULTS", "400"))
|
||||
|
||||
# --- Authentification OIDC / Keycloak (activée seulement si configurée) ---
|
||||
OIDC_ISSUER = os.environ.get("OIDC_ISSUER", "").rstrip("/")
|
||||
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "")
|
||||
OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "")
|
||||
OIDC_REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "")
|
||||
OIDC_POST_LOGOUT = os.environ.get("OIDC_POST_LOGOUT_REDIRECT_URI", "")
|
||||
SESSION_SECRET = os.environ.get("SESSION_SECRET", "dev-insecure-change-me")
|
||||
SESSION_HTTPS_ONLY = os.environ.get("SESSION_HTTPS_ONLY", "false").lower() in ("1", "true", "yes")
|
||||
OIDC_ENABLED = bool(OIDC_ISSUER and OIDC_CLIENT_ID)
|
||||
|
||||
VIDEO_EXTS = {".mkv", ".mp4", ".m4v", ".avi", ".mov", ".ts", ".m2ts", ".wmv", ".webm", ".mpg", ".mpeg", ".flv"}
|
||||
HDR_TRANSFERS = {"smpte2084", "arib-std-b67"} # PQ (HDR10/DV) et HLG
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="MKV → AVI")
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
||||
_jobs: dict[str, dict] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Authentification OIDC (Keycloak) — optionnelle
|
||||
# --------------------------------------------------------------------------- #
|
||||
oauth = None
|
||||
if OIDC_ENABLED:
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="keycloak",
|
||||
server_metadata_url=f"{OIDC_ISSUER}/.well-known/openid-configuration",
|
||||
client_id=OIDC_CLIENT_ID,
|
||||
client_secret=OIDC_CLIENT_SECRET,
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
|
||||
AUTH_PUBLIC = {"/login", "/auth/callback", "/logout", "/health", "/favicon.ico"}
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _auth_guard(request: Request, call_next):
|
||||
"""Exige une session valide, sauf routes publiques et assets statiques."""
|
||||
if not OIDC_ENABLED:
|
||||
return await call_next(request)
|
||||
path = request.url.path
|
||||
if path in AUTH_PUBLIC or path.startswith("/static/"):
|
||||
return await call_next(request)
|
||||
if request.session.get("user"):
|
||||
return await call_next(request)
|
||||
if path.startswith("/api/"):
|
||||
return JSONResponse({"detail": "Non authentifié."}, status_code=401)
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
|
||||
# SessionMiddleware ajouté APRÈS le garde => il l'enveloppe (session dispo dans le garde)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=SESSION_SECRET,
|
||||
same_site="lax",
|
||||
https_only=SESSION_HTTPS_ONLY,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "auth": OIDC_ENABLED}
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
def me(request: Request):
|
||||
if not OIDC_ENABLED:
|
||||
return {"auth": False}
|
||||
return {"auth": True, "user": request.session.get("user")}
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login(request: Request):
|
||||
if not OIDC_ENABLED:
|
||||
return RedirectResponse("/")
|
||||
redirect_uri = OIDC_REDIRECT_URI or str(request.url_for("auth_callback"))
|
||||
return await oauth.keycloak.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@app.get("/auth/callback", name="auth_callback")
|
||||
async def auth_callback(request: Request):
|
||||
if not OIDC_ENABLED:
|
||||
return RedirectResponse("/")
|
||||
try:
|
||||
token = await oauth.keycloak.authorize_access_token(request)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=401, detail=f"Échec de l'authentification : {exc}")
|
||||
info = token.get("userinfo") or {}
|
||||
request.session["user"] = {
|
||||
"name": info.get("name") or info.get("preferred_username") or info.get("email") or "Utilisateur",
|
||||
"email": info.get("email"),
|
||||
}
|
||||
request.session["id_token"] = token.get("id_token")
|
||||
return RedirectResponse("/")
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(request: Request):
|
||||
id_token = request.session.get("id_token")
|
||||
request.session.clear()
|
||||
if OIDC_ENABLED:
|
||||
meta = await oauth.keycloak.load_server_metadata()
|
||||
end = meta.get("end_session_endpoint")
|
||||
if end:
|
||||
post = OIDC_POST_LOGOUT or str(request.base_url)
|
||||
url = f"{end}?post_logout_redirect_uri={quote(post, safe='')}"
|
||||
if id_token:
|
||||
url += f"&id_token_hint={id_token}"
|
||||
return RedirectResponse(url)
|
||||
return RedirectResponse("/")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Utilitaires
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _safe(base: Path, rel: str) -> Path:
|
||||
"""Résout un chemin relatif en interdisant de sortir de `base`."""
|
||||
p = (base / rel).resolve()
|
||||
if p != base and base not in p.parents:
|
||||
raise HTTPException(status_code=400, detail="Chemin en dehors du dossier autorisé.")
|
||||
return p
|
||||
|
||||
|
||||
def _sanitize(name: str) -> str:
|
||||
name = re.sub(r'[/\\:*?"<>|]+', "_", name).strip()
|
||||
return name or "sortie"
|
||||
|
||||
|
||||
def probe(path: Path) -> dict:
|
||||
"""Renvoie durée, HDR ?, dimensions et pistes audio d'un fichier."""
|
||||
cmd = [FFPROBE, "-v", "error", "-print_format", "json",
|
||||
"-show_format", "-show_streams", str(path)]
|
||||
out = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if out.returncode != 0:
|
||||
raise HTTPException(status_code=422, detail=f"Lecture impossible : {out.stderr.strip()[:300]}")
|
||||
data = json.loads(out.stdout or "{}")
|
||||
duration = float(data.get("format", {}).get("duration", 0) or 0)
|
||||
|
||||
hdr = False
|
||||
width = height = None
|
||||
audio = []
|
||||
a_index = 0
|
||||
for st in data.get("streams", []):
|
||||
if st.get("codec_type") == "video" and width is None:
|
||||
width = st.get("width")
|
||||
height = st.get("height")
|
||||
if st.get("color_transfer") in HDR_TRANSFERS:
|
||||
hdr = True
|
||||
elif st.get("codec_type") == "audio":
|
||||
tags = st.get("tags", {}) or {}
|
||||
audio.append({
|
||||
"a_index": a_index,
|
||||
"lang": tags.get("language", "und"),
|
||||
"title": tags.get("title", ""),
|
||||
"codec": st.get("codec_name", ""),
|
||||
"channels": st.get("channels"),
|
||||
})
|
||||
a_index += 1
|
||||
|
||||
return {"duration": duration, "hdr": hdr, "width": width, "height": height, "audio": audio}
|
||||
|
||||
|
||||
def _looks_like_ad(title: str) -> bool:
|
||||
t = title.lower()
|
||||
return "audiodescription" in t or "audio description" in t or re.search(r"\bad\b", t) is not None or "descript" in t
|
||||
|
||||
|
||||
def default_audio(audio: list[dict]) -> int:
|
||||
"""Choisit la 1re piste FR non-audiodescription, sinon 1re FR, sinon 1re piste."""
|
||||
fr = [a for a in audio if a["lang"] in ("fre", "fra", "fr")]
|
||||
for a in fr:
|
||||
if not _looks_like_ad(a["title"]):
|
||||
return a["a_index"]
|
||||
if fr:
|
||||
return fr[0]["a_index"]
|
||||
return audio[0]["a_index"] if audio else 0
|
||||
|
||||
|
||||
def build_vf(hdr: bool, resolution: str) -> str:
|
||||
"""Chaîne de filtres : redimensionnement + (tone-mapping HDR->SDR si besoin)."""
|
||||
parts: list[str] = []
|
||||
if resolution == "720":
|
||||
parts.append("scale=720:-2")
|
||||
elif resolution == "1280":
|
||||
parts.append("scale=1280:-2")
|
||||
# "original" : pas de scale
|
||||
|
||||
if hdr:
|
||||
parts += [
|
||||
"zscale=t=linear:npl=100",
|
||||
"format=gbrpf32le",
|
||||
"zscale=p=bt709",
|
||||
"tonemap=tonemap=hable:desat=0",
|
||||
"zscale=t=bt709:m=bt709:r=tv",
|
||||
"format=yuv420p",
|
||||
]
|
||||
else:
|
||||
parts.append("format=yuv420p")
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Conversion (exécutée dans un thread worker)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _set(job_id: str, **kw):
|
||||
with _jobs_lock:
|
||||
if job_id in _jobs:
|
||||
_jobs[job_id].update(kw)
|
||||
|
||||
|
||||
def run_conversion(job_id: str, src: Path, a_index: int, resolution: str, hdr: bool, duration: float):
|
||||
out_name = _sanitize(src.stem) + ".avi"
|
||||
out_path = OUTPUT_DIR / out_name
|
||||
# évite d'écraser une conversion précédente
|
||||
i = 1
|
||||
while out_path.exists():
|
||||
out_path = OUTPUT_DIR / f"{_sanitize(src.stem)} ({i}).avi"
|
||||
i += 1
|
||||
|
||||
vf = build_vf(hdr, resolution)
|
||||
log_path = OUTPUT_DIR / f".{job_id}.log"
|
||||
|
||||
cmd = [
|
||||
FFMPEG, "-y", "-hide_banner", "-nostats",
|
||||
"-i", str(src),
|
||||
"-map", "0:v:0", "-map", f"0:a:{a_index}",
|
||||
"-vf", vf,
|
||||
"-c:v", "mpeg4", "-vtag", "xvid", "-qscale:v", "4",
|
||||
"-c:a", "libmp3lame", "-q:a", "4", "-ac", "2",
|
||||
"-metadata", f"title={src.stem}",
|
||||
"-progress", "pipe:1",
|
||||
str(out_path),
|
||||
]
|
||||
|
||||
_set(job_id, status="running", output_name=out_path.name, started=time.time())
|
||||
try:
|
||||
with open(log_path, "w") as logf:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=logf, text=True)
|
||||
with _jobs_lock:
|
||||
_jobs[job_id]["_proc"] = proc
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if line.startswith("out_time_us=") and duration > 0:
|
||||
val = line.split("=", 1)[1]
|
||||
if val.isdigit():
|
||||
pct = min(99.0, (int(val) / 1_000_000) / duration * 100)
|
||||
_set(job_id, progress=round(pct, 1))
|
||||
elif line == "progress=end":
|
||||
_set(job_id, progress=100.0)
|
||||
proc.wait()
|
||||
|
||||
if proc.returncode == 0 and out_path.exists():
|
||||
_set(job_id, status="done", progress=100.0,
|
||||
size=out_path.stat().st_size, output_path=str(out_path), finished=time.time())
|
||||
else:
|
||||
tail = ""
|
||||
try:
|
||||
tail = log_path.read_text()[-500:]
|
||||
except OSError:
|
||||
pass
|
||||
if out_path.exists():
|
||||
out_path.unlink(missing_ok=True)
|
||||
_set(job_id, status="error", error=tail or f"ffmpeg code {proc.returncode}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_set(job_id, status="error", error=str(exc))
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs.get(job_id, {}).pop("_proc", None)
|
||||
log_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# API
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ConvertBody(BaseModel):
|
||||
path: str
|
||||
resolution: str = "720" # "720" | "1280" | "original"
|
||||
audio: int | None = None # index de piste audio (a:N)
|
||||
|
||||
|
||||
@app.get("/api/browse")
|
||||
def browse(q: str = ""):
|
||||
q = q.lower().strip()
|
||||
results = []
|
||||
for root, _dirs, files in os.walk(MEDIA_DIR):
|
||||
for fn in files:
|
||||
if Path(fn).suffix.lower() not in VIDEO_EXTS:
|
||||
continue
|
||||
full = Path(root) / fn
|
||||
rel = str(full.relative_to(MEDIA_DIR))
|
||||
if q and q not in rel.lower():
|
||||
continue
|
||||
try:
|
||||
stt = full.stat()
|
||||
except OSError:
|
||||
continue
|
||||
results.append({"path": rel, "name": fn, "dir": str(Path(rel).parent),
|
||||
"size": stt.st_size, "mtime": stt.st_mtime})
|
||||
if len(results) >= MAX_RESULTS:
|
||||
break
|
||||
if len(results) >= MAX_RESULTS:
|
||||
break
|
||||
results.sort(key=lambda r: r["name"].lower())
|
||||
return {"count": len(results), "truncated": len(results) >= MAX_RESULTS, "files": results}
|
||||
|
||||
|
||||
@app.get("/api/probe")
|
||||
def api_probe(path: str):
|
||||
src = _safe(MEDIA_DIR, path)
|
||||
if not src.is_file():
|
||||
raise HTTPException(status_code=404, detail="Fichier introuvable.")
|
||||
info = probe(src)
|
||||
info["default_audio"] = default_audio(info["audio"])
|
||||
return info
|
||||
|
||||
|
||||
@app.get("/api/download")
|
||||
def download(path: str):
|
||||
src = _safe(MEDIA_DIR, path)
|
||||
if not src.is_file():
|
||||
raise HTTPException(status_code=404, detail="Fichier introuvable.")
|
||||
return FileResponse(src, filename=src.name, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@app.post("/api/convert")
|
||||
def convert(body: ConvertBody):
|
||||
src = _safe(MEDIA_DIR, body.path)
|
||||
if not src.is_file():
|
||||
raise HTTPException(status_code=404, detail="Fichier introuvable.")
|
||||
if body.resolution not in ("720", "1280", "original"):
|
||||
raise HTTPException(status_code=400, detail="Résolution invalide.")
|
||||
|
||||
info = probe(src)
|
||||
a_index = body.audio if body.audio is not None else default_audio(info["audio"])
|
||||
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
with _jobs_lock:
|
||||
_jobs[job_id] = {
|
||||
"id": job_id, "source": src.name, "status": "queued",
|
||||
"progress": 0.0, "resolution": body.resolution, "hdr": info["hdr"],
|
||||
"created": time.time(),
|
||||
}
|
||||
_executor.submit(run_conversion, job_id, src, a_index, body.resolution, info["hdr"], info["duration"])
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
def _public_job(j: dict) -> dict:
|
||||
return {k: v for k, v in j.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def jobs():
|
||||
with _jobs_lock:
|
||||
items = [_public_job(j) for j in _jobs.values()]
|
||||
items.sort(key=lambda j: j.get("created", 0), reverse=True)
|
||||
return {"jobs": items}
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def job(job_id: str):
|
||||
with _jobs_lock:
|
||||
j = _jobs.get(job_id)
|
||||
if not j:
|
||||
raise HTTPException(status_code=404, detail="Job inconnu.")
|
||||
return _public_job(j)
|
||||
|
||||
|
||||
@app.delete("/api/jobs/{job_id}")
|
||||
def delete_job(job_id: str):
|
||||
with _jobs_lock:
|
||||
j = _jobs.get(job_id)
|
||||
if not j:
|
||||
raise HTTPException(status_code=404, detail="Job inconnu.")
|
||||
proc = j.get("_proc")
|
||||
if proc and j.get("status") == "running":
|
||||
proc.terminate()
|
||||
outp = j.get("output_path")
|
||||
_jobs.pop(job_id, None)
|
||||
if outp and Path(outp).exists() and j.get("status") != "done":
|
||||
Path(outp).unlink(missing_ok=True)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/result/{job_id}")
|
||||
def result(job_id: str):
|
||||
with _jobs_lock:
|
||||
j = _jobs.get(job_id)
|
||||
if not j or j.get("status") != "done" or not j.get("output_path"):
|
||||
raise HTTPException(status_code=404, detail="Résultat indisponible.")
|
||||
p = Path(j["output_path"])
|
||||
if not p.is_file():
|
||||
raise HTTPException(status_code=404, detail="Fichier de sortie introuvable.")
|
||||
return FileResponse(p, filename=p.name, media_type="video/x-msvideo")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
@@ -0,0 +1,187 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const filesEl = $("#files");
|
||||
const jobsEl = $("#jobs");
|
||||
const countEl = $("#count");
|
||||
const searchEl = $("#search");
|
||||
const modal = $("#modal");
|
||||
|
||||
let currentFile = null; // fichier sélectionné pour conversion
|
||||
let searchTimer = null;
|
||||
|
||||
// ---------- Helpers ----------
|
||||
function human(bytes) {
|
||||
if (!bytes && bytes !== 0) return "";
|
||||
const u = ["o", "Ko", "Mo", "Go", "To"];
|
||||
let i = 0, n = bytes;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
}
|
||||
const langName = { fre: "FR", fra: "FR", fr: "FR", eng: "EN", en: "EN", und: "?" };
|
||||
|
||||
// ---------- Recherche / listing ----------
|
||||
// Redirige vers la connexion si la session a expiré
|
||||
function checkAuth(r) {
|
||||
if (r.status === 401) { window.location = "/login"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadFiles(q = "") {
|
||||
const r = await fetch(`/api/browse?q=${encodeURIComponent(q)}`);
|
||||
if (!checkAuth(r)) return;
|
||||
const data = await r.json();
|
||||
countEl.textContent = data.count
|
||||
? `${data.count} fichier${data.count > 1 ? "s" : ""}${data.truncated ? "+" : ""}`
|
||||
: "aucun résultat";
|
||||
filesEl.innerHTML = "";
|
||||
if (!data.files.length) {
|
||||
filesEl.innerHTML = `<li class="empty">Rien trouvé.</li>`;
|
||||
return;
|
||||
}
|
||||
for (const f of data.files) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "file";
|
||||
const dir = f.dir && f.dir !== "." ? `<span class="dir">${f.dir}/</span>` : "";
|
||||
li.innerHTML = `
|
||||
<div class="file-main">
|
||||
<div class="file-name">${dir}${f.name}</div>
|
||||
<div class="file-meta">${human(f.size)}</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<a class="btn ghost" href="/api/download?path=${encodeURIComponent(f.path)}">⬇ Télécharger</a>
|
||||
<button class="btn primary conv">🎬 Convertir en AVI</button>
|
||||
</div>`;
|
||||
li.querySelector(".conv").addEventListener("click", () => openModal(f));
|
||||
filesEl.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => loadFiles(searchEl.value), 250);
|
||||
});
|
||||
|
||||
// ---------- Modale d'options ----------
|
||||
async function openModal(file) {
|
||||
currentFile = file;
|
||||
$("#modal-title").textContent = file.name;
|
||||
const info = $("#modal-info");
|
||||
const audioSel = $("#opt-audio");
|
||||
info.textContent = "Analyse du fichier…";
|
||||
audioSel.innerHTML = "";
|
||||
modal.classList.remove("hidden");
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/probe?path=${encodeURIComponent(file.path)}`);
|
||||
if (!r.ok) throw new Error((await r.json()).detail || "erreur");
|
||||
const p = await r.json();
|
||||
const badges = [];
|
||||
if (p.width) badges.push(`${p.width}×${p.height}`);
|
||||
if (p.hdr) badges.push(`<span class="tag hdr">HDR → tone-mapping</span>`);
|
||||
badges.push(`${Math.round(p.duration / 60)} min`);
|
||||
info.innerHTML = badges.join(" · ");
|
||||
|
||||
p.audio.forEach((a) => {
|
||||
const opt = document.createElement("option");
|
||||
const lang = langName[a.lang] || a.lang.toUpperCase();
|
||||
const ch = a.channels ? `${a.channels}ch` : "";
|
||||
opt.value = a.a_index;
|
||||
opt.textContent = `#${a.a_index} · ${lang} · ${a.codec} ${ch}${a.title ? " — " + a.title : ""}`;
|
||||
if (a.a_index === p.default_audio) opt.selected = true;
|
||||
audioSel.appendChild(opt);
|
||||
});
|
||||
if (!p.audio.length) {
|
||||
audioSel.innerHTML = `<option value="0">(aucune piste audio détectée)</option>`;
|
||||
}
|
||||
} catch (e) {
|
||||
info.innerHTML = `<span class="err">Impossible d'analyser : ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
$("#modal-cancel").addEventListener("click", () => modal.classList.add("hidden"));
|
||||
modal.addEventListener("click", (e) => { if (e.target === modal) modal.classList.add("hidden"); });
|
||||
|
||||
$("#modal-start").addEventListener("click", async () => {
|
||||
if (!currentFile) return;
|
||||
const body = {
|
||||
path: currentFile.path,
|
||||
resolution: $("#opt-res").value,
|
||||
audio: parseInt($("#opt-audio").value || "0", 10),
|
||||
};
|
||||
modal.classList.add("hidden");
|
||||
const r = await fetch("/api/convert", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) { await refreshJobs(); }
|
||||
else { alert("Erreur : " + ((await r.json()).detail || r.status)); }
|
||||
});
|
||||
|
||||
// ---------- Jobs / progression ----------
|
||||
function jobRow(j) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `job ${j.status}`;
|
||||
let right = "";
|
||||
if (j.status === "done") {
|
||||
right = `<a class="btn primary" href="/api/result/${j.id}">⬇ Télécharger l'AVI</a>
|
||||
<button class="btn ghost small del">✕</button>`;
|
||||
} else if (j.status === "error") {
|
||||
right = `<button class="btn ghost small del">✕</button>`;
|
||||
} else {
|
||||
right = `<button class="btn ghost small del">Annuler</button>`;
|
||||
}
|
||||
const statusTxt = {
|
||||
queued: "En attente…", running: "Conversion…", done: `Terminé · ${human(j.size)}`,
|
||||
error: "Échec", }[j.status] || j.status;
|
||||
li.innerHTML = `
|
||||
<div class="job-head">
|
||||
<span class="job-name">${j.source}</span>
|
||||
<span class="job-status">${statusTxt}${j.hdr ? " · HDR" : ""}</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill" style="width:${j.progress || 0}%"></div></div>
|
||||
<div class="job-actions">${right}</div>`;
|
||||
if (j.status === "error") {
|
||||
const d = document.createElement("div");
|
||||
d.className = "job-err";
|
||||
d.textContent = (j.error || "").split("\n").filter(Boolean).slice(-1)[0] || "Erreur inconnue";
|
||||
li.appendChild(d);
|
||||
}
|
||||
const del = li.querySelector(".del");
|
||||
if (del) del.addEventListener("click", async () => {
|
||||
await fetch(`/api/jobs/${j.id}`, { method: "DELETE" });
|
||||
refreshJobs();
|
||||
});
|
||||
return li;
|
||||
}
|
||||
|
||||
async function refreshJobs() {
|
||||
const r = await fetch("/api/jobs");
|
||||
if (!checkAuth(r)) return;
|
||||
const { jobs } = await r.json();
|
||||
jobsEl.innerHTML = "";
|
||||
if (!jobs.length) {
|
||||
jobsEl.innerHTML = `<li class="empty">Aucune conversion pour l'instant.</li>`;
|
||||
return;
|
||||
}
|
||||
jobs.forEach((j) => jobsEl.appendChild(jobRow(j)));
|
||||
}
|
||||
|
||||
// Poll régulier des jobs
|
||||
setInterval(refreshJobs, 1500);
|
||||
|
||||
// ---------- Utilisateur connecté ----------
|
||||
async function loadUser() {
|
||||
try {
|
||||
const r = await fetch("/api/me");
|
||||
const d = await r.json();
|
||||
if (d.auth && d.user) {
|
||||
$("#user-name").textContent = `👤 ${d.user.name}`;
|
||||
$("#user").classList.remove("hidden");
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Init
|
||||
loadUser();
|
||||
loadFiles();
|
||||
refreshJobs();
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MKV → AVI</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<div>
|
||||
<h1>🎬 MKV → AVI</h1>
|
||||
<p class="sub">Convertir des films (4K/HDR) en AVI Xvid pour vieux lecteurs</p>
|
||||
</div>
|
||||
<div id="user" class="user hidden">
|
||||
<span id="user-name" class="user-name"></span>
|
||||
<a class="btn ghost small" href="/logout">Déconnexion</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="panel">
|
||||
<div class="search">
|
||||
<input id="search" type="search" placeholder="Rechercher un fichier dans le dossier média…" autocomplete="off" />
|
||||
<span id="count" class="count"></span>
|
||||
</div>
|
||||
<ul id="files" class="files"></ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Conversions</h2>
|
||||
<ul id="jobs" class="jobs"><li class="empty">Aucune conversion pour l'instant.</li></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Modale d'options de conversion -->
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-box">
|
||||
<h3 id="modal-title">Convertir en AVI</h3>
|
||||
<div id="modal-info" class="info"></div>
|
||||
<label>Définition
|
||||
<select id="opt-res">
|
||||
<option value="720">Compatibilité max — 720 px (vieux lecteurs)</option>
|
||||
<option value="1280">720p — 1280 px</option>
|
||||
<option value="original">Originale (pas de redimensionnement)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Piste audio
|
||||
<select id="opt-audio"></select>
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<button id="modal-cancel" class="btn ghost">Annuler</button>
|
||||
<button id="modal-start" class="btn primary">🎬 Lancer la conversion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
:root {
|
||||
--bg: #0f1216;
|
||||
--panel: #171b21;
|
||||
--panel-2: #1e242c;
|
||||
--border: #2a323c;
|
||||
--text: #e6e9ee;
|
||||
--muted: #8a94a2;
|
||||
--accent: #e6483d;
|
||||
--accent-2: #3d7de6;
|
||||
--ok: #38b26a;
|
||||
--err: #e2544a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
header {
|
||||
padding: 22px 28px; border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, #191d24, #12151a);
|
||||
}
|
||||
header h1 { margin: 0; font-size: 22px; }
|
||||
header .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.header-inner {
|
||||
max-width: 960px; margin: 0 auto; display: flex;
|
||||
align-items: center; justify-content: space-between; gap: 16px;
|
||||
}
|
||||
.user { display: flex; align-items: center; gap: 10px; }
|
||||
.user.hidden { display: none; }
|
||||
.user-name { color: var(--text); font-size: 13px; white-space: nowrap; }
|
||||
main {
|
||||
max-width: 960px; margin: 0 auto; padding: 24px 20px;
|
||||
display: grid; gap: 22px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 18px;
|
||||
}
|
||||
.panel h2 { margin: 0 0 12px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
||||
|
||||
.search { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.search input {
|
||||
flex: 1; padding: 12px 14px; border-radius: 9px;
|
||||
border: 1px solid var(--border); background: var(--panel-2);
|
||||
color: var(--text); font-size: 15px; outline: none;
|
||||
}
|
||||
.search input:focus { border-color: var(--accent-2); }
|
||||
.count { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||
|
||||
.files, .jobs { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
.empty { color: var(--muted); padding: 14px; text-align: center; }
|
||||
|
||||
.file {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
padding: 12px 14px; background: var(--panel-2);
|
||||
border: 1px solid var(--border); border-radius: 9px;
|
||||
}
|
||||
.file-main { min-width: 0; }
|
||||
.file-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-name .dir { color: var(--muted); font-weight: 400; }
|
||||
.file-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.file-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 13px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--panel); color: var(--text); font-size: 13px;
|
||||
cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: #3a4552; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.small { padding: 5px 9px; font-size: 12px; }
|
||||
|
||||
/* Jobs */
|
||||
.job { padding: 12px 14px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 9px; }
|
||||
.job-head { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.job-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.job-status { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||
.job.done .job-status { color: var(--ok); }
|
||||
.job.error .job-status { color: var(--err); }
|
||||
.bar { height: 8px; background: #0d1116; border-radius: 6px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--ok)); transition: width .4s ease; }
|
||||
.job.done .bar-fill { background: var(--ok); }
|
||||
.job.error .bar-fill { background: var(--err); }
|
||||
.job-actions { margin-top: 10px; display: flex; gap: 8px; }
|
||||
.job-err { margin-top: 8px; color: var(--err); font-size: 12px; font-family: ui-monospace, monospace; word-break: break-word; }
|
||||
|
||||
/* Modale */
|
||||
.modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 50;
|
||||
}
|
||||
.modal.hidden { display: none; }
|
||||
.modal-box {
|
||||
width: 100%; max-width: 460px; background: var(--panel);
|
||||
border: 1px solid var(--border); border-radius: 14px; padding: 22px;
|
||||
}
|
||||
.modal-box h3 { margin: 0 0 6px; font-size: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.modal .info { color: var(--muted); font-size: 13px; margin-bottom: 16px; }
|
||||
.modal label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 14px; }
|
||||
.modal select {
|
||||
width: 100%; margin-top: 6px; padding: 10px; border-radius: 8px;
|
||||
background: var(--panel-2); border: 1px solid var(--border); color: var(--text); font-size: 14px;
|
||||
}
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
|
||||
.tag { padding: 2px 8px; border-radius: 20px; font-size: 11px; }
|
||||
.tag.hdr { background: rgba(61,125,230,.18); color: #7ea6f0; }
|
||||
.err { color: var(--err); }
|
||||
@@ -0,0 +1,87 @@
|
||||
# Stack tout-en-un : PostgreSQL + Keycloak (realm pré-importé) + l'app MKV→AVI.
|
||||
#
|
||||
# docker compose -f docker-compose.keycloak.yml up -d --build
|
||||
#
|
||||
# ⚠️ Ajouter d'abord cette ligne à /etc/hosts (pour que le navigateur ET le
|
||||
# conteneur voient Keycloak sous le même nom) :
|
||||
# 127.0.0.1 keycloak.local
|
||||
#
|
||||
# Accès : app -> http://localhost:8090 (login: alex / alex)
|
||||
# admin Keycloak -> http://localhost:8080 (admin / admin)
|
||||
|
||||
name: mkv2avi-stack
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: mkv2avi-db
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: ${KC_DB_PASSWORD:-keycloak}
|
||||
volumes:
|
||||
- kc-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.0
|
||||
container_name: mkv2avi-keycloak
|
||||
command: ["start-dev", "--import-realm"]
|
||||
environment:
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
|
||||
KC_DB_USERNAME: keycloak
|
||||
KC_DB_PASSWORD: ${KC_DB_PASSWORD:-keycloak}
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN:-admin}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin}
|
||||
# URL fixe : identique côté navigateur (via /etc/hosts) et côté app (via alias réseau)
|
||||
KC_HOSTNAME: http://keycloak.local:8080
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
volumes:
|
||||
- ./keycloak/realm-mkv2avi.json:/opt/keycloak/data/import/realm-mkv2avi.json:ro
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- keycloak.local
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
|
||||
mkv2avi:
|
||||
build: .
|
||||
image: mkv2avi:latest
|
||||
container_name: mkv2avi
|
||||
ports:
|
||||
- "8090:8000"
|
||||
volumes:
|
||||
- ${MEDIA_DIR:-./media}:/media:ro
|
||||
- ${OUTPUT_DIR:-./output}:/output
|
||||
environment:
|
||||
- MAX_WORKERS=1
|
||||
- OIDC_ISSUER=http://keycloak.local:8080/realms/mkv2avi
|
||||
- OIDC_CLIENT_ID=mkv2avi
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-mkv2avi-secret-change-me}
|
||||
- SESSION_SECRET=${SESSION_SECRET:-dev-insecure-change-me}
|
||||
- SESSION_HTTPS_ONLY=false
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
kc-db:
|
||||
@@ -0,0 +1,90 @@
|
||||
# Variante PRODUCTION : PostgreSQL + Keycloak (mode production) + app,
|
||||
# derrière Nginx Proxy Manager (TLS terminé par NPM).
|
||||
#
|
||||
# cp .env.prod.example .env.prod # puis renseigner les secrets
|
||||
# docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
|
||||
#
|
||||
# NPM (2 Proxy Hosts, forward en http vers l'IP de l'hôte) :
|
||||
# auth.mkv2avi.maisongrondin.fr -> http://<IP_HOTE>:8080
|
||||
# mkv2avi.maisongrondin.fr -> http://<IP_HOTE>:8090
|
||||
# → SSL Let's Encrypt + Force SSL + Websockets Support activés sur les deux.
|
||||
|
||||
name: mkv2avi
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: mkv2avi-db
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: ${KC_DB_PASSWORD:?KC_DB_PASSWORD manquant}
|
||||
volumes:
|
||||
- kc-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.0
|
||||
container_name: mkv2avi-keycloak
|
||||
command: ["start", "--import-realm"]
|
||||
environment:
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
|
||||
KC_DB_USERNAME: keycloak
|
||||
KC_DB_PASSWORD: ${KC_DB_PASSWORD:?KC_DB_PASSWORD manquant}
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN:-admin}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:?KC_ADMIN_PASSWORD manquant}
|
||||
# URL publique (issuer) — NPM termine le TLS, Keycloak sert en HTTP en interne
|
||||
KC_HOSTNAME: https://auth.mkv2avi.maisongrondin.fr
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_PROXY_HEADERS: xforwarded
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
volumes:
|
||||
- ./keycloak/realm-mkv2avi.json:/opt/keycloak/data/import/realm-mkv2avi.json:ro
|
||||
ports:
|
||||
- "${KC_HTTP_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
mkv2avi:
|
||||
build: .
|
||||
image: mkv2avi:latest
|
||||
container_name: mkv2avi
|
||||
environment:
|
||||
- MAX_WORKERS=${MAX_WORKERS:-1}
|
||||
- OIDC_ISSUER=https://auth.mkv2avi.maisongrondin.fr/realms/mkv2avi
|
||||
- OIDC_CLIENT_ID=mkv2avi
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:?OIDC_CLIENT_SECRET manquant}
|
||||
- OIDC_REDIRECT_URI=https://mkv2avi.maisongrondin.fr/auth/callback
|
||||
- OIDC_POST_LOGOUT_REDIRECT_URI=https://mkv2avi.maisongrondin.fr/
|
||||
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET manquant}
|
||||
- SESSION_HTTPS_ONLY=true
|
||||
volumes:
|
||||
- ${MEDIA_DIR:?MEDIA_DIR manquant}:/media:ro
|
||||
- ${OUTPUT_DIR:-./output}:/output
|
||||
ports:
|
||||
- "${APP_HTTP_PORT:-8090}:8000"
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
# Repli si le conteneur ne résout pas le domaine public (hairpin NAT) :
|
||||
# décommente en remplaçant par l'IP de NPM/hôte.
|
||||
# extra_hosts:
|
||||
# - "auth.mkv2avi.maisongrondin.fr:192.168.1.200"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
kc-db:
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
mkv2avi:
|
||||
build: .
|
||||
image: mkv2avi:latest
|
||||
container_name: mkv2avi
|
||||
ports:
|
||||
- "8080:8000"
|
||||
volumes:
|
||||
# Dossier média à parcourir (monté en lecture seule = originaux protégés)
|
||||
- ${MEDIA_DIR:-./media}:/media:ro
|
||||
# Dossier de sortie des AVI convertis
|
||||
- ${OUTPUT_DIR:-./output}:/output
|
||||
environment:
|
||||
# Nombre de conversions simultanées (1 = en série, recommandé sans GPU)
|
||||
- MAX_WORKERS=1
|
||||
# --- Authentification Keycloak (laisser vide = auth désactivée) ---
|
||||
- OIDC_ISSUER=${OIDC_ISSUER:-}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||
# URL de retour ; laisser vide pour la déduire automatiquement
|
||||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
||||
- OIDC_POST_LOGOUT_REDIRECT_URI=${OIDC_POST_LOGOUT_REDIRECT_URI:-}
|
||||
# Secret de signature des cookies de session (À CHANGER)
|
||||
- SESSION_SECRET=${SESSION_SECRET:-dev-insecure-change-me}
|
||||
# true si servi en HTTPS (derrière NPM en TLS)
|
||||
- SESSION_HTTPS_ONLY=${SESSION_HTTPS_ONLY:-false}
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"realm": "mkv2avi",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": false,
|
||||
"loginTheme": "keycloak",
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "mkv2avi",
|
||||
"name": "MKV → AVI",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "mkv2avi-secret-change-me",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": false,
|
||||
"redirectUris": [
|
||||
"http://localhost:8090/*",
|
||||
"https://mkv2avi.maisongrondin.fr/*"
|
||||
],
|
||||
"webOrigins": ["+"],
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "http://localhost:8090/*##https://mkv2avi.maisongrondin.fr/*"
|
||||
},
|
||||
"defaultClientScopes": ["web-origins", "profile", "roles", "email"],
|
||||
"fullScopeAllowed": true
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "alex",
|
||||
"enabled": true,
|
||||
"emailVerified": true,
|
||||
"email": "alex@example.com",
|
||||
"firstName": "Alex",
|
||||
"lastName": "Grondin",
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "alex", "temporary": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
Authlib==1.3.2
|
||||
httpx==0.28.1
|
||||
itsdangerous==2.2.0
|
||||
Reference in New Issue
Block a user