Extrait de li.k3nny.fr et rendu indépendant du serveur : toute la configuration propre à l'hôte passe par li.env (URL publique, chemins, expiration, répertoires « HTML seulement », commande nginx). Écarts avec la version en production : - li-user del retire aussi la page .html, pas seulement le .xspf ; - li-gen ignore les répertoires cachés, pas seulement les fichiers ; - textes de la page HTML accentués. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
363 lines
15 KiB
Python
Executable File
363 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Engendre, pour chaque ayant droit, sa playlist .xspf et sa page .html.
|
||
|
||
Les deux portent EXACTEMENT les memes URL : la signature ne depend que du
|
||
chemin et du secret, jamais du document qui la transporte.
|
||
|
||
Idempotent : relance-le autant que tu veux, il reecrit les memes URL. C est
|
||
voulu — une URL stable, c est VLC qui retrouve la reprise de lecture et
|
||
l historique, tous deux indexes par URL.
|
||
|
||
Configuration : /etc/li/li.env (ou le fichier nomme par LI_CONF), que les
|
||
variables d environnement surchargent. Voir li.env.example.
|
||
"""
|
||
import base64
|
||
import hashlib
|
||
import html
|
||
import json
|
||
import os
|
||
import shlex
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from urllib.parse import quote
|
||
from xml.sax.saxutils import escape
|
||
|
||
# Ces deux prefixes sont ceux du vhost nginx/li.conf : les changer ici impose
|
||
# de les changer la-bas.
|
||
PREFIXE = "/library"
|
||
PLAYLISTS = "/playlists"
|
||
|
||
VIDEOS_DEFAUT = ".mkv .mp4 .m4v .avi .mov .webm .mpg .mpeg .ts"
|
||
|
||
|
||
# ----------------------------------------------------------- configuration
|
||
|
||
def lis_env(chemin: Path) -> dict:
|
||
"""Lit un fichier « CLE=valeur » compatible sh — celui que li-user source."""
|
||
valeurs = {}
|
||
if not chemin.exists():
|
||
return valeurs
|
||
for ligne in chemin.read_text(encoding="utf-8").splitlines():
|
||
ligne = ligne.strip()
|
||
if not ligne or ligne.startswith("#") or "=" not in ligne:
|
||
continue
|
||
cle, valeur = ligne.split("=", 1)
|
||
mots = shlex.split(valeur, comments=True)
|
||
valeurs[cle.strip()] = " ".join(mots)
|
||
return valeurs
|
||
|
||
|
||
class Config:
|
||
def __init__(self, env: dict):
|
||
base = env.get("LI_BASE_URL", "").rstrip("/")
|
||
if not base:
|
||
raise SystemExit("LI_BASE_URL n est pas defini (voir li.env.example).")
|
||
self.base = base
|
||
self.racine = Path(env.get("LI_LIBRARY", "/srv/media/library"))
|
||
donnees = Path(env.get("LI_DATA", "/srv/appdata/li"))
|
||
self.ayants = donnees / "users.json"
|
||
self.sortie = donnees / "playlists"
|
||
# La revocation, c est la table des ayants droit — pas l expiration.
|
||
# Une expiration courte obligerait a resigner regulierement, donc a
|
||
# changer les URL, donc a faire perdre a VLC la reprise de lecture de
|
||
# chaque film. D ou 2038 par defaut.
|
||
self.expire = int(env.get("LI_EXPIRES", "2145916800"))
|
||
self.titre = env.get("LI_TITLE", "Bibliothèque")
|
||
self.videos = {e.lower() for e in env.get("LI_VIDEO_EXT", VIDEOS_DEFAUT).split()}
|
||
# Repertoires de premier niveau dont le contenu ne figure QUE dans la
|
||
# page HTML, jamais dans la playlist — et dont TOUS les fichiers sont
|
||
# pris, quelle que soit leur extension.
|
||
#
|
||
# Une playlist VLC n a rien a faire d un .iso ou d un .exe : elle ne
|
||
# saurait pas les lire, et les mettre dedans donnerait une liste ou un
|
||
# titre sur deux echoue a l ouverture. La page HTML, elle, ne fait que
|
||
# proposer des telechargements — tout y a sa place.
|
||
#
|
||
# Le vhost nginx/li.conf force « Content-Disposition: attachment » sur
|
||
# ces memes chemins. Les deux doivent rester d accord.
|
||
self.html_seulement = set(env.get("LI_HTML_ONLY", "software").split())
|
||
|
||
|
||
def charge_config() -> Config:
|
||
env = lis_env(Path(os.environ.get("LI_CONF", "/etc/li/li.env")))
|
||
env.update({k: v for k, v in os.environ.items() if k.startswith("LI_")})
|
||
return Config(env)
|
||
|
||
|
||
# --------------------------------------------------------------- signature
|
||
|
||
def signe(chemin: str, expire: int, secret: str) -> str:
|
||
"""md5(expiration + chemin + secret), en base64url sans remplissage.
|
||
|
||
C est exactement ce que calcule « secure_link_md5 » de nginx. Le secret
|
||
est en FIN de chaine : c est l ordre de la documentation nginx, et le seul
|
||
qui ne prete pas le flanc a une extension de longueur.
|
||
"""
|
||
brut = f"{expire}{chemin}{secret}".encode("utf-8")
|
||
return base64.urlsafe_b64encode(hashlib.md5(brut).digest()).decode().rstrip("=")
|
||
|
||
|
||
def url(cfg: Config, chemin: str, nom: str, secret: str) -> str:
|
||
# On signe le chemin DECODE — c est ce que nginx met dans $uri — et on
|
||
# n encode qu ici, pour l ecriture. Inverser les deux donne un 403 sur
|
||
# tout titre contenant une espace ou un accent.
|
||
return (f"{cfg.base}{quote(chemin)}"
|
||
f"?u={quote(nom)}&e={cfg.expire}&s={signe(chemin, cfg.expire, secret)}")
|
||
|
||
|
||
def url_media(cfg: Config, fichier: Path, nom: str, secret: str) -> str:
|
||
return url(cfg, f"{PREFIXE}/{fichier.relative_to(cfg.racine).as_posix()}", nom, secret)
|
||
|
||
|
||
# ------------------------------------------------------------------ outils
|
||
|
||
def taille_lisible(octets: int) -> str:
|
||
for unite, seuil in (("To", 1 << 40), ("Go", 1 << 30), ("Mo", 1 << 20)):
|
||
if octets >= seuil:
|
||
return f"{octets / seuil:.1f} {unite}".replace(".", ",")
|
||
return f"{octets // 1024} ko"
|
||
|
||
|
||
def html_seulement(cfg: Config, chemin: Path) -> bool:
|
||
parties = chemin.relative_to(cfg.racine).parts
|
||
return len(parties) > 1 and parties[0] in cfg.html_seulement
|
||
|
||
|
||
def collecte(cfg: Config):
|
||
"""(medias, autres) — les premiers vont partout, les seconds en HTML seul."""
|
||
medias, autres = [], []
|
||
for f in cfg.racine.rglob("*"):
|
||
if not f.is_file():
|
||
continue
|
||
# Fichiers ET repertoires caches : .@__thumb, .Trash-1000 et consorts.
|
||
if any(p.startswith(".") for p in f.relative_to(cfg.racine).parts):
|
||
continue
|
||
if html_seulement(cfg, f):
|
||
autres.append(f)
|
||
elif f.suffix.lower() in cfg.videos:
|
||
medias.append(f)
|
||
ordre = lambda p: p.relative_to(cfg.racine).as_posix().lower()
|
||
return sorted(medias, key=ordre), sorted(autres, key=ordre)
|
||
|
||
|
||
def arbre(cfg: Config, fichiers):
|
||
"""Reconstruit l arborescence : {dossier: sous-arbre}, fichiers a plat."""
|
||
racine = {"dossiers": {}, "fichiers": []}
|
||
for f in fichiers:
|
||
parties = f.relative_to(cfg.racine).parts
|
||
noeud = racine
|
||
for dossier in parties[:-1]:
|
||
noeud = noeud["dossiers"].setdefault(
|
||
dossier, {"dossiers": {}, "fichiers": []})
|
||
noeud["fichiers"].append(f)
|
||
return racine
|
||
|
||
|
||
def compte(noeud):
|
||
"""(nombre de fichiers, octets) d un noeud et de toute sa descendance."""
|
||
n = len(noeud["fichiers"])
|
||
o = sum(f.stat().st_size for f in noeud["fichiers"])
|
||
for sous in noeud["dossiers"].values():
|
||
sn, so = compte(sous)
|
||
n += sn
|
||
o += so
|
||
return n, o
|
||
|
||
|
||
# -------------------------------------------------------------------- XSPF
|
||
|
||
def xspf(cfg: Config, fichiers, nom, secret, horodatage):
|
||
pistes = []
|
||
for f in fichiers:
|
||
rel = f.relative_to(cfg.racine)
|
||
pistes.append(
|
||
" <track>\n"
|
||
f" <location>{escape(url_media(cfg, f, nom, secret))}</location>\n"
|
||
f" <title>{escape(rel.stem)}</title>\n"
|
||
f" <album>{escape(rel.parent.as_posix().replace('/', ' · '))}</album>\n"
|
||
" </track>")
|
||
return ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||
'<playlist version="1" xmlns="http://xspf.org/ns/0/">\n'
|
||
f' <title>{escape(nom)} — {len(fichiers)} titres ({horodatage})</title>\n'
|
||
' <trackList>\n' + "\n".join(pistes) + '\n </trackList>\n'
|
||
'</playlist>\n')
|
||
|
||
|
||
# -------------------------------------------------------------------- HTML
|
||
|
||
STYLE = """
|
||
:root{--fond:#fbfaf8;--carte:#fff;--texte:#1b1b1b;--doux:#6b6b6b;
|
||
--trait:#e4e0d9;--accent:#2d6a4f;--survol:#f2efe9}
|
||
@media(prefers-color-scheme:dark){:root{--fond:#16181a;--carte:#1e2124;
|
||
--texte:#e8e6e3;--doux:#9a9793;--trait:#2e3236;--accent:#6ba583;--survol:#25292d}}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;padding:0 16px 64px;background:var(--fond);color:var(--texte);
|
||
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||
.enveloppe{max-width:920px;margin:0 auto}
|
||
header{padding:28px 0 16px;border-bottom:1px solid var(--trait);margin-bottom:18px}
|
||
h1{margin:0 0 6px;font-size:1.45rem;font-weight:650;letter-spacing:-.01em}
|
||
.sous{color:var(--doux);font-size:.875rem}
|
||
.actions{margin-top:14px;display:flex;gap:10px;flex-wrap:wrap}
|
||
.bouton{display:inline-block;padding:7px 13px;border:1px solid var(--trait);
|
||
border-radius:7px;background:var(--carte);color:var(--texte);text-decoration:none;
|
||
font-size:.85rem}
|
||
.bouton:hover{background:var(--survol)}
|
||
.bouton.fort{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
#filtre{width:100%;margin:16px 0 20px;padding:10px 13px;border:1px solid var(--trait);
|
||
border-radius:8px;background:var(--carte);color:var(--texte);font:inherit;font-size:.9rem}
|
||
#filtre:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
||
details{margin:0}
|
||
summary{cursor:pointer;padding:7px 8px;border-radius:6px;list-style:none;
|
||
display:flex;align-items:baseline;gap:9px;font-weight:550}
|
||
summary:hover{background:var(--survol)}
|
||
summary::-webkit-details-marker{display:none}
|
||
summary::before{content:"▸";color:var(--doux);font-size:.8em;flex:none;
|
||
transition:transform .12s;display:inline-block}
|
||
details[open]>summary::before{transform:rotate(90deg)}
|
||
.meta{color:var(--doux);font-weight:400;font-size:.8rem;margin-left:auto;
|
||
flex:none;white-space:nowrap}
|
||
.niveau{margin-left:15px;padding-left:9px;border-left:1px solid var(--trait)}
|
||
a.f{display:flex;align-items:baseline;gap:9px;padding:6px 8px;border-radius:6px;
|
||
color:var(--texte);text-decoration:none;font-size:.9rem}
|
||
a.f:hover{background:var(--survol)}
|
||
a.f::before{content:"↓";color:var(--accent);font-size:.85em;flex:none}
|
||
a.f .t{color:var(--doux);font-size:.8rem;margin-left:auto;flex:none;
|
||
white-space:nowrap;font-variant-numeric:tabular-nums}
|
||
.vide{display:none!important}
|
||
footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--trait);
|
||
color:var(--doux);font-size:.8rem}
|
||
"""
|
||
|
||
SCRIPT = """
|
||
const f=document.getElementById('filtre');
|
||
f.addEventListener('input',()=>{
|
||
const q=f.value.trim().toLowerCase();
|
||
document.querySelectorAll('a.f').forEach(a=>{
|
||
a.classList.toggle('vide', q!=='' && !a.dataset.n.includes(q));
|
||
});
|
||
document.querySelectorAll('details').forEach(d=>{
|
||
const vu=d.querySelector('a.f:not(.vide)')!==null;
|
||
d.classList.toggle('vide', q!=='' && !vu);
|
||
if(q!=='') d.open=true;
|
||
});
|
||
});
|
||
"""
|
||
|
||
|
||
def rendre(cfg: Config, noeud, nom, secret, profondeur=0):
|
||
morceaux = []
|
||
for dossier in sorted(noeud["dossiers"], key=str.lower):
|
||
sous = noeud["dossiers"][dossier]
|
||
n, o = compte(sous)
|
||
ouvert = " open" if profondeur == 0 else ""
|
||
morceaux.append(
|
||
f'<details{ouvert}><summary>{html.escape(dossier)}'
|
||
f'<span class="meta">{n} · {taille_lisible(o)}</span></summary>'
|
||
f'<div class="niveau">{rendre(cfg, sous, nom, secret, profondeur + 1)}</div>'
|
||
'</details>')
|
||
for f in sorted(noeud["fichiers"], key=lambda p: p.name.lower()):
|
||
# Pour un film, l extension n apprend rien — le nom de release dit
|
||
# deja tout. Pour un fichier « HTML seulement », elle est au contraire
|
||
# l information principale : un .iso ne se telecharge pas comme un
|
||
# .exe. D ou le nom entier dans un cas, le radical dans l autre.
|
||
affiche = f.name if html_seulement(cfg, f) else f.stem
|
||
morceaux.append(
|
||
f'<a class="f" href="{html.escape(url_media(cfg, f, nom, secret), quote=True)}" '
|
||
f'download="{html.escape(f.name, quote=True)}" '
|
||
f'data-n="{html.escape(affiche.lower(), quote=True)}">'
|
||
f'<span>{html.escape(affiche)}</span>'
|
||
f'<span class="t">{taille_lisible(f.stat().st_size)}</span></a>')
|
||
return "".join(morceaux)
|
||
|
||
|
||
def page(cfg: Config, racine, medias, autres, nom, secret, horodatage):
|
||
fichiers = medias + autres
|
||
total = sum(f.stat().st_size for f in fichiers)
|
||
decompte = f"{len(medias)} titres"
|
||
if autres:
|
||
decompte += f" et {len(autres)} fichiers"
|
||
lien_xspf = url(cfg, f"{PLAYLISTS}/{nom}.xspf", nom, secret)
|
||
titre = html.escape(cfg.titre)
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<!-- Aucune ressource externe, et aucun referent : les URL de cette page sont
|
||
des laissez-passer, elles n ont a fuir vers aucun tiers. -->
|
||
<meta name="referrer" content="no-referrer">
|
||
<meta name="robots" content="noindex,nofollow,noarchive">
|
||
<title>{titre} — {html.escape(nom)}</title>
|
||
<style>{STYLE}</style>
|
||
</head>
|
||
<body>
|
||
<div class="enveloppe">
|
||
<header>
|
||
<h1>{titre}</h1>
|
||
<div class="sous">Accès personnel de <b>{html.escape(nom)}</b> ·
|
||
{decompte} · {taille_lisible(total)} · mise à jour le {horodatage}</div>
|
||
<div class="actions">
|
||
<a class="bouton fort" href="{html.escape(lien_xspf, quote=True)}">Ouvrir dans VLC</a>
|
||
</div>
|
||
</header>
|
||
<input id="filtre" type="search" placeholder="Filtrer par titre…" autocomplete="off">
|
||
{rendre(cfg, racine, nom, secret)}
|
||
<footer>
|
||
Chaque lien de cette page t’identifie et n’est valable que pour toi.
|
||
Ne la transmets pas — demande plutôt qu’on ajoute la personne.
|
||
</footer>
|
||
</div>
|
||
<script>{SCRIPT}</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# -------------------------------------------------------------------- corps
|
||
|
||
def publie(chemin: Path, contenu: str):
|
||
tmp = chemin.with_name("." + chemin.name + ".tmp")
|
||
tmp.write_text(contenu, encoding="utf-8")
|
||
os.chmod(tmp, 0o644)
|
||
# Publication atomique : personne ne lit jamais un document a moitie
|
||
# ecrit, meme si la generation tombe en plein milieu.
|
||
os.replace(tmp, chemin)
|
||
|
||
|
||
def main() -> int:
|
||
cfg = charge_config()
|
||
if not cfg.ayants.exists():
|
||
print(f"{cfg.ayants} absent — rien a engendrer.", file=sys.stderr)
|
||
return 1
|
||
if not cfg.racine.is_dir():
|
||
print(f"{cfg.racine} n est pas un repertoire.", file=sys.stderr)
|
||
return 1
|
||
|
||
ayants = json.loads(cfg.ayants.read_text(encoding="utf-8"))
|
||
medias, autres = collecte(cfg)
|
||
# L arbre de la page porte les deux ; la playlist ne verra que « medias ».
|
||
racine = arbre(cfg, medias + autres)
|
||
cfg.sortie.mkdir(parents=True, exist_ok=True)
|
||
horodatage = time.strftime("%d/%m/%Y à %Hh%M")
|
||
|
||
for nom, secret in sorted(ayants.items()):
|
||
publie(cfg.sortie / f"{nom}.xspf", xspf(cfg, medias, nom, secret, horodatage))
|
||
publie(cfg.sortie / f"{nom}.html",
|
||
page(cfg, racine, medias, autres, nom, secret, horodatage))
|
||
print(f"{nom} : {len(medias)} pistes, {len(autres)} fichiers hors playlist")
|
||
|
||
# Les documents des personnes revoquees ne doivent pas survivre au retrait
|
||
# de leur secret : sans signature valide ils seraient refuses, mais un
|
||
# fichier qui traine est un fichier qu on oublie.
|
||
for orphelin in list(cfg.sortie.glob("*.xspf")) + list(cfg.sortie.glob("*.html")):
|
||
if orphelin.stem not in ayants:
|
||
orphelin.unlink()
|
||
print(f"{orphelin.name} : retire (plus d ayant droit)")
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|