Administration native sur le Mac, réencodage sélectif et aide intégrée

- admin.sh : RTG lancé directement sur le Mac (plus de conteneur), nginx
  joint l'interface par host.docker.internal:8765 ; variante conteneur
  conservée (admin-docker.sh, docker-compose-conteneur.yml)
- Compression : réglages lus dans les fichiers (CRF, codec), refaire
  seulement le 1080p ou le 720p, seulement si les réglages ont changé ;
  réglages vidéo propres à chaque voyage ; « Compression actuelle » affichée
- Plus d'arrêt automatique après 2 h d'inactivité (--arret-inactif, 0 par défaut) ;
  arrêt propre qui attend la fin des encodages en cours
- Aide contextuelle dans l'interface (rtg/interface/aide) ; README centré
  sur l'installation et la mise en place
- Cœurs attribués au conteneur détectés (cgroup) et signalés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
This commit is contained in:
FabriceandClaude Opus 5.5 committed 2026-10-19 05:32:05 +00:00
1 parent b54ae69b19
commit ada3d76f50
38 files changed
+1067 -264

No files matched your search

+103 -11
View File
@@ -36,8 +36,8 @@ from urllib.parse import parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, FileSystemLoader, select_autoescape
from rtg import __version__, acces, accueil, corrections, site, sources, video
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, modifier_toml, parametres_video,
reglages_site, reglages_voyage)
from rtg.config import (QUALITES, SITE_TOML, VOYAGE_TOML, lire_toml, modifier_toml,
parametres_video, reglages_site, reglages_voyage)
from rtg.interface import taches
from rtg.interface.securite import Gardien
from rtg.outils import EXT_PHOTOS, EXT_VIDEOS, est_cache, lister_fichiers, taille_lisible
@@ -49,6 +49,7 @@ MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août
JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
COULEURS = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#46a0b4", "#f032e6",
"#7a9a01", "#c47f00", "#008080", "#9a6324", "#800000", "#2f4b7c", "#808000"]
PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower"]
CLES_SOURCES = {"traces": ("Traces GPS", (".gpx",), "traces GPS"),
"videos": ("Vidéos", EXT_VIDEOS, "vidéos"),
"photos": ("Photos", EXT_PHOTOS, "photos")}
@@ -74,6 +75,7 @@ ENV.filters["date_longue"] = _date_longue
ENV.filters["date_courte"] = lambda d: f"{d.day} {MOIS[d.month - 1][:4].rstrip('.')}" if d else ""
ENV.filters["taille"] = taille_lisible
ENV.filters["url"] = lambda s: quote(str(s), safe="")
ENV.filters["crf"] = lambda x: f"{x:g}"
ENV.tests["commence_par"] = lambda s, debut: str(s).startswith(debut)
ENV.globals.update(version=__version__, periode=_periode, qualites=QUALITES,
noms_qualites={"econome": "économe", "equilibree": "équilibrée", "haute": "haute"})
@@ -124,12 +126,15 @@ class Application:
("POST", r"/voyage/dates-suite", self.voyage_dates_suite),
("GET", r"/voyage/infos-utiles", self.voyage_infos_utiles),
("GET", r"/voyage/carte.json", self.voyage_carte),
("GET", r"/voyage/compression", self.voyage_compression),
("POST", r"/voyage/video", self.voyage_video),
("POST", r"/voyage/position", self.voyage_position),
("POST", r"/voyage/masque", self.voyage_masque),
("GET", r"/dossiers", self.dossiers),
("GET", r"/reglages", self.reglages),
("POST", r"/reglages", self.reglages_enregistrer),
("GET", r"/systeme", self.systeme),
("GET", r"/aide(?:/[a-z]+)?", self.aide),
("POST", r"/tache/lancer", self.tache_lancer),
("POST", r"/tache/arreter", self.tache_arreter),
("GET", r"/tache", self.tache),
@@ -361,6 +366,41 @@ class Application:
infos["a_encoder_octets"] = sum(os.path.getsize(f) for f in reste if os.path.exists(f))
return page("_infos_utiles.html", i=infos, cfg=cfg)
# --- Compression des vidéos
def voyage_compression(self, r, message=""):
rel = r.q("v") or r.f("v")
chemin = self.chemin_voyage(rel)
cfg = self.cfg_voyage(chemin)
site_cfg = reglages_site(self.racine)
propre = lire_toml(os.path.join(chemin, VOYAGE_TOML)).get("video", {})
p = parametres_video(cfg)
return page("_compression.html", v={"chemin": rel}, bilan=video.bilan_compression(chemin),
p=p, cibles={"1080p": video.cible("hd", p), "720p": video.cible("sd", p)},
cfg=cfg, site=site_cfg, site_p=parametres_video(site_cfg), propre=propre,
presets=PRESETS, message=message)
def voyage_video(self, r):
chemin = self.chemin_voyage(r.f("v"))
changements = {}
q = r.f("qualite")
changements["qualite"] = q if q in QUALITES else None
pr = r.f("preset")
changements["preset"] = pr if pr in PRESETS else None
for k in ("crf_hevc", "crf_h264"):
val = r.f(k).strip()
if not val:
changements[k] = None
continue
try:
n = int(val)
except ValueError:
raise Erreur(400, f"CRF invalide : {val}")
if not 0 <= n <= 51:
raise Erreur(400, "Le CRF va de 0 (sans perte) à 51")
changements[k] = n
self.toml_voyage(chemin, {"video": changements})
return self.voyage_compression(r, message="✓ Réglages vidéo du voyage enregistrés.")
# --- Carte et corrections
def _media_json(self, chemin, rel, m):
nom = m["fichier"]
@@ -447,6 +487,18 @@ class Application:
self.toml_voyage(chemin, {"corrections": {"masques": liste}})
return json_rep(self._masques_json(self.cfg_voyage(chemin)))
# --- Aide (pages Markdown de aide/, aussi lisibles sur git)
def aide(self, r):
sujet = r.chemin.rstrip("/").rsplit("/", 1)[-1]
sujet = "index" if sujet == "aide" else sujet
panneau = r.q("panneau") == "1"
sujets = _sujets_aide()
if sujet not in sujets:
raise Erreur(404, "Page d'aide introuvable")
html = _aide_html(sujet, self.prefixe, panneau)
modele = "_aide.html" if panneau else "aide.html"
return page(modele, **self.contexte(sujet=sujet, sujets=sujets, contenu=html))
# --- Navigateur de dossiers
def dossiers(self, r):
chemin = r.q("chemin") or os.path.expanduser("~")
@@ -527,23 +579,28 @@ class Application:
quoi, rel = r.f("quoi"), r.f("v")
compression = r.f("sans_compression") != "on"
force = r.f("force") == "on"
paliers = {"1080p": ("hd",), "720p": ("sd",)}.get(r.f("versions"), ("hd", "sd"))
refaire = r.f("refaire") if r.f("refaire") in video.REFAIRE else "manquantes"
nom_versions = {"1080p": " (1080p seulement)", "720p": " (720p seulement)"}.get(r.f("versions"), "")
racine = self.racine
if quoi in ("generer", "compresser"):
chemin = self.chemin_voyage(rel)
if quoi == "generer":
libelle = f"Génération de {rel}" + ("" if compression else " (sans les vidéos)")
libelle = f"Génération de {rel}" + (nom_versions if compression else " (sans les vidéos)")
def fn(suivi):
return site.generer(racine, [chemin], compression=compression, force=force, suivi=suivi)
return site.generer(racine, [chemin], compression=compression, force=force, suivi=suivi,
paliers=paliers, refaire=refaire, force_videos=False)
elif quoi == "compresser":
libelle = f"Compression des vidéos de {rel}"
libelle = f"Compression des vidéos de {rel}{nom_versions}"
def fn(suivi):
cfg = reglages_voyage(chemin, reglages_site(racine))
suivi({"etape": "Vidéos", "voyage": rel})
return video.compresser(chemin, cfg, sources.analyser(chemin, cfg), force=force,
return video.compresser(chemin, cfg, sources.analyser(chemin, cfg), force=False,
progression=lambda n, e, p, rg: suivi(
{"video": n, "etat": e, "pct": p, "rang": rg}))
{"video": n, "etat": e, "pct": p, "rang": rg}),
paliers=paliers, refaire=refaire)
elif quoi == "tout":
libelle = "Génération de tout le site" + ("" if compression else " (sans les vidéos)")
@@ -603,6 +660,36 @@ def _couches(a, jours, maxi=1500):
return couches
AIDE = os.path.join(ICI, "aide")
def _sujets_aide():
"""{sujet: titre} des pages d'aide, dans l'ordre du sommaire (index.md)."""
titres = {}
for nom in os.listdir(AIDE):
if nom.endswith(".md"):
with open(os.path.join(AIDE, nom), encoding="utf-8") as f:
titres[nom[:-3]] = f.readline().lstrip("# ").strip()
with open(os.path.join(AIDE, "index.md"), encoding="utf-8") as f:
ordre = ["index"] + [s for s in re.findall(r"\]\(([a-z]+)\.md\)", f.read())]
ordre += sorted(set(titres) - set(ordre))
return {s: titres[s] for s in dict.fromkeys(ordre) if s in titres}
def _aide_html(sujet, base, panneau):
import markdown2
with open(os.path.join(AIDE, sujet + ".md"), encoding="utf-8") as f:
html = markdown2.markdown(f.read(), extras=["tables", "fenced-code-blocks", "cuddled-lists"])
def lien(m):
cible = m.group(1)
if panneau:
return (f'href="{base}/aide/{cible}" hx-get="{base}/aide/{cible}?panneau=1" '
f'hx-target="#aide-contenu"')
return f'href="{base}/aide/{cible}"'
return re.sub(r'href="([a-z]+)\.md"', lien, html)
def _valeur(d, k, defaut):
v = d.get(k, defaut)
if isinstance(v, list):
@@ -611,8 +698,8 @@ def _valeur(d, k, defaut):
class Requete:
def __init__(self, query, form):
self.query, self.form = query, form
def __init__(self, query, form, chemin=""):
self.query, self.form, self.chemin = query, form, chemin
def q(self, k, defaut=""):
return _valeur(self.query, k, defaut)
@@ -727,7 +814,7 @@ def creer_gestionnaire(app, port, gardien=None):
"Set-Cookie": gardien.entete_cookie("", self.https(), effacer=True)}), tete)
if methode == "GET" and chemin.startswith("/site/"):
return self.fichier(app.racine, chemin[len("/site/"):], tete)
req = Requete(query, form)
req = Requete(query, form, chemin)
for m, motif, fn in app.routes:
if m == methode and motif.match(chemin):
rep = fn(req)
@@ -940,7 +1027,12 @@ def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=N
try:
serveur.serve_forever()
except KeyboardInterrupt:
taches.arreter()
# Un encodage en cours : on arrête proprement ffmpeg (sinon il continuerait
# seul et laisserait des fichiers .tmp-).
if taches.arreter():
fin = time.time() + 15
while taches.COURANTE.en_cours and time.time() < fin:
time.sleep(0.2)
print("\nInterface arrêtée.")
finally:
serveur.server_close()