feat: nommage UNFR -ReMoRa automatique après téléchargement
Docker / docker (push) Has been cancelled

Format : Title.Event.Year.FRENCH.Resolution.WEBRip.x264|HEVC.AAC-ReMoRa.mp4

- build_release_name() : slugify avec strip accents, apostrophe→point,
  déduplique l'année si présente dans le titre ET passée séparément,
  détecte la résolution et le codec depuis les infos yt-dlp
- enqueue() : reçoit subtitle + year depuis l'API
- _run() : renomme le fichier après download, met à jour le filename en DB
- DownloadRequest : subtitle + year ajoutés
- app.js : extrait l'année du subtitle via regex avant d'envoyer la requête

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dev
2026-04-26 13:48:56 +02:00
parent 9a5e356238
commit 124afb6d20
3 changed files with 86 additions and 8 deletions
+80 -6
View File
@@ -1,5 +1,7 @@
import re
import sqlite3 import sqlite3
import threading import threading
import unicodedata
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -17,6 +19,67 @@ def _db():
return conn return conn
# ── Release naming ─────────────────────────────────────────────────────────────
def _slugify(s: str) -> str:
"""Normalize a string to dot-separated scene-style slug."""
# Strip accents (NFD decompose then drop combining marks)
s = unicodedata.normalize("NFD", s)
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
# Apostrophe before letter → .Letter (L'Amour → .L.Amour)
s = re.sub(r"[']([A-Za-z])", lambda m: "." + m.group(1).upper(), s)
# Spaces / underscores → dot
s = re.sub(r"[\s_]+", ".", s)
# Keep only alphanumeric, dot, hyphen
s = re.sub(r"[^A-Za-z0-9.\-]", "", s)
# Collapse multiple dots
s = re.sub(r"\.{2,}", ".", s)
return s.strip(".")
def build_release_name(title: str, subtitle: str, year: int | None, info: dict) -> str:
"""
Build a proper UNFR/scene release name.
Format: Title.Event.Year.FRENCH.Resolution.WEBRip.x264.AAC-ReMoRa.mp4
"""
# Strip year from both title and subtitle to avoid duplication
t = re.sub(r"\b" + str(year) + r"\b", "", title).strip() if year else title
name = _slugify(t)
sub = subtitle or ""
if year:
sub = re.sub(r"\b" + str(year) + r"\b", "", sub).strip()
sub_slug = _slugify(sub)
if sub_slug:
name = f"{name}.{sub_slug}"
year_str = str(year) if year else ""
# Resolution from yt-dlp info
height = info.get("height") or 0
if height >= 2160:
res = "2160p"
elif height >= 1080:
res = "1080p"
elif height >= 720:
res = "720p"
else:
res = f"{height}p" if height else "1080p"
# Video codec (avc1 = H.264, hev1/hvc1/hevc = H.265)
vcodec = (info.get("vcodec") or "").lower()
if "hevc" in vcodec or "h265" in vcodec or "hev1" in vcodec or "hvc1" in vcodec:
vc = "HEVC"
elif "avc" in vcodec or "h264" in vcodec:
vc = "x264"
else:
vc = "x264"
parts = [name, year_str, "FRENCH", res, "WEBRip", vc, "AAC"]
base = ".".join(p for p in parts if p)
return f"{base}-ReMoRa.mp4"
class DownloadManager: class DownloadManager:
def __init__(self): def __init__(self):
self._active: dict[str, dict] = {} self._active: dict[str, dict] = {}
@@ -43,7 +106,8 @@ class DownloadManager:
# ------------------------------------------------------------------ public # ------------------------------------------------------------------ public
def enqueue(self, url: str, title: str, bg: BackgroundTasks) -> str: def enqueue(self, url: str, title: str, subtitle: str, year: int | None,
bg: BackgroundTasks) -> str:
dl_id = str(uuid.uuid4()) dl_id = str(uuid.uuid4())
now = datetime.now().isoformat() now = datetime.now().isoformat()
with _db() as conn: with _db() as conn:
@@ -53,7 +117,7 @@ class DownloadManager:
) )
with self._lock: with self._lock:
self._active[dl_id] = {"state": "queued", "progress": 0, "title": title} self._active[dl_id] = {"state": "queued", "progress": 0, "title": title}
bg.add_task(self._run, dl_id, url) bg.add_task(self._run, dl_id, url, title, subtitle, year)
return dl_id return dl_id
def status(self, dl_id: str) -> dict: def status(self, dl_id: str) -> dict:
@@ -80,15 +144,15 @@ class DownloadManager:
with self._lock: with self._lock:
self._active.setdefault(dl_id, {}).update(kw) self._active.setdefault(dl_id, {}).update(kw)
def _run(self, dl_id: str, url: str): def _run(self, dl_id: str, url: str, title: str, subtitle: str, year: int | None):
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
self._set(dl_id, state="downloading") self._set(dl_id, state="downloading")
with _db() as conn: with _db() as conn:
conn.execute("UPDATE downloads SET state='downloading' WHERE id=?", (dl_id,)) conn.execute("UPDATE downloads SET state='downloading' WHERE id=?", (dl_id,))
# For HLS, yt-dlp downloads video then audio separately. # For HLS, yt-dlp downloads video then audio separately.
# After the first stream finishes, stay in "processing" — don't reset # After the first stream finishes, stay in "processing" to avoid
# to "downloading" when the audio stream starts. # resetting progress to 0% when the audio stream starts.
finished_once = [False] finished_once = [False]
def hook(d): def hook(d):
@@ -119,7 +183,17 @@ class DownloadManager:
try: try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True) info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info) orig_path = Path(ydl.prepare_filename(info))
# Rename to proper release name
release_name = build_release_name(title, subtitle, year, info)
dest_path = orig_path.parent / release_name
if orig_path.exists() and orig_path != dest_path:
if dest_path.exists():
dest_path.unlink()
orig_path.rename(dest_path)
filename = str(dest_path)
self._set(dl_id, state="done", progress=100) self._set(dl_id, state="done", progress=100)
with _db() as conn: with _db() as conn:
conn.execute( conn.execute(
+3 -1
View File
@@ -53,13 +53,15 @@ async def api_refresh():
class DownloadRequest(BaseModel): class DownloadRequest(BaseModel):
url: str url: str
title: str title: str
subtitle: str = ""
year: int | None = None
@app.post("/api/download") @app.post("/api/download")
async def api_download(req: DownloadRequest, bg: BackgroundTasks): async def api_download(req: DownloadRequest, bg: BackgroundTasks):
if not req.url: if not req.url:
raise HTTPException(status_code=400, detail="url required") raise HTTPException(status_code=400, detail="url required")
dl_id = dm.enqueue(req.url, req.title, bg) dl_id = dm.enqueue(req.url, req.title, req.subtitle, req.year, bg)
return {"id": dl_id} return {"id": dl_id}
+3 -1
View File
@@ -265,10 +265,12 @@ $('btn-download').addEventListener('click', async () => {
btnDl.textContent = 'Démarrage…'; btnDl.textContent = 'Démarrage…';
try { try {
const yearMatch = (c.subtitle || '').match(/\b(20\d{2})\b/);
const year = yearMatch ? parseInt(yearMatch[1]) : null;
const res = await fetch('/api/download', { const res = await fetch('/api/download', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: c.url, title: c.title }), body: JSON.stringify({ url: c.url, title: c.title, subtitle: c.subtitle || '', year }),
}); });
const { id } = await res.json(); const { id } = await res.json();
trackDownload(id, c.title, c.url); trackDownload(id, c.title, c.url);