feat: nommage UNFR -ReMoRa automatique après téléchargement
Docker / docker (push) Has been cancelled
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:
+80
-6
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user