Compare commits

..

3 Commits

Author SHA1 Message Date
dev 189b65bd6d feat: auto-téléchargement par catégorie avec souscription persistante
Docker / docker (push) Has been cancelled
- Bouton ⬇ sur chaque pill de catégorie pour activer/désactiver l'auto-DL
- Souscriptions sauvegardées en SQLite (table auto_dl_categories)
- Boucle background toutes les AUTO_DL_INTERVAL secondes (défaut 1h)
- Déduplication via already_enqueued() (évite re-queue si déjà queued/done)
- POST /api/auto-dl/check pour déclencher un check immédiat
- GET/POST/DELETE /api/auto-dl/{category} pour gérer les souscriptions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:23:57 +02:00
dev f07352bd04 feat: téléchargement dans sous-dossiers par catégorie
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:16:36 +02:00
dev 978a54a25f fix: WEBRip → WEB-DL (HLS direct download, no re-encode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:15:40 +02:00
5 changed files with 231 additions and 18 deletions
+9
View File
@@ -144,6 +144,15 @@ def _search_sync(query: str) -> set[str]:
# ── public API ────────────────────────────────────────────────────────────────
async def _ensure_cache() -> list[dict]:
return await get_all_concerts()
async def get_concerts_by_category(category: str) -> list[dict]:
data = await _ensure_cache()
return [c for c in data if category in (c.get("categories") or [])]
async def get_all_concerts() -> list[dict]:
now = time.time()
if _cache["data"] and now - _cache["ts"] < CACHE_TTL:
+58 -7
View File
@@ -1,3 +1,4 @@
import asyncio
import re
import sqlite3
import threading
@@ -75,7 +76,7 @@ def build_release_name(title: str, subtitle: str, year: int | None, info: dict)
else:
vc = "x264"
parts = [name, year_str, res, "WEBRip", vc, "AAC"]
parts = [name, year_str, res, "WEB-DL", vc, "AAC"]
base = ".".join(p for p in parts if p)
return f"{base}-ReMoRa.mp4"
@@ -103,11 +104,48 @@ class DownloadManager:
error TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS auto_dl_categories (
category TEXT PRIMARY KEY,
added_at TEXT NOT NULL
)
""")
# ------------------------------------------------------------------ public
def enqueue(self, url: str, title: str, subtitle: str, year: int | None,
bg: BackgroundTasks) -> str:
def get_watched_categories(self) -> list[str]:
with _db() as conn:
rows = conn.execute(
"SELECT category FROM auto_dl_categories ORDER BY added_at"
).fetchall()
return [r["category"] for r in rows]
def watch_category(self, category: str):
with _db() as conn:
conn.execute(
"INSERT OR IGNORE INTO auto_dl_categories (category, added_at) VALUES (?,?)",
(category, datetime.now().isoformat()),
)
def unwatch_category(self, category: str):
with _db() as conn:
conn.execute("DELETE FROM auto_dl_categories WHERE category=?", (category,))
def already_enqueued(self, url: str) -> bool:
with _db() as conn:
row = conn.execute(
"SELECT id FROM downloads WHERE url=? AND state != 'error' LIMIT 1", (url,)
).fetchone()
return row is not None
def already_downloaded(self, url: str) -> bool:
with _db() as conn:
row = conn.execute(
"SELECT id FROM downloads WHERE url=? AND state='done' LIMIT 1", (url,)
).fetchone()
return row is not None
def _insert_queued(self, url: str, title: str) -> str:
dl_id = str(uuid.uuid4())
now = datetime.now().isoformat()
with _db() as conn:
@@ -117,7 +155,19 @@ class DownloadManager:
)
with self._lock:
self._active[dl_id] = {"state": "queued", "progress": 0, "title": title}
bg.add_task(self._run, dl_id, url, title, subtitle, year)
return dl_id
def enqueue(self, url: str, title: str, subtitle: str, year: int | None,
category: str, bg: BackgroundTasks) -> str:
dl_id = self._insert_queued(url, title)
bg.add_task(self._run, dl_id, url, title, subtitle, year, category)
return dl_id
async def enqueue_direct(self, url: str, title: str, subtitle: str,
year: int | None, category: str) -> str:
dl_id = self._insert_queued(url, title)
loop = asyncio.get_running_loop()
loop.run_in_executor(None, self._run, dl_id, url, title, subtitle, year, category)
return dl_id
def status(self, dl_id: str) -> dict:
@@ -144,8 +194,9 @@ class DownloadManager:
with self._lock:
self._active.setdefault(dl_id, {}).update(kw)
def _run(self, dl_id: str, url: str, title: str, subtitle: str, year: int | None):
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
def _run(self, dl_id: str, url: str, title: str, subtitle: str, year: int | None, category: str = ""):
out_dir = f"{OUTPUT_DIR}/{category}" if category else OUTPUT_DIR
Path(out_dir).mkdir(parents=True, exist_ok=True)
self._set(dl_id, state="downloading")
with _db() as conn:
conn.execute("UPDATE downloads SET state='downloading' WHERE id=?", (dl_id,))
@@ -172,7 +223,7 @@ class DownloadManager:
self._set(dl_id, state="processing", progress=100)
ydl_opts = {
"outtmpl": f"{OUTPUT_DIR}/%(title)s.%(ext)s",
"outtmpl": f"{out_dir}/%(title)s.%(ext)s",
"format": "bestvideo[vcodec^=avc1]+bestaudio/bestvideo+bestaudio/best",
"merge_output_format": "mp4",
"progress_hooks": [hook],
+80 -5
View File
@@ -1,6 +1,9 @@
import asyncio
import json
import logging
import os
import re
from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
@@ -8,18 +11,61 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from arte_api import fetch_concerts, invalidate_cache, CATEGORIES
from arte_api import fetch_concerts, get_concerts_by_category, invalidate_cache, CATEGORIES
from downloader import DownloadManager
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Arte-dl")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
AUTO_DL_INTERVAL = int(os.getenv("AUTO_DL_INTERVAL", "3600"))
dm = DownloadManager()
async def _run_auto_dl_check() -> int:
cats = dm.get_watched_categories()
if not cats:
return 0
total = 0
for cat in cats:
concerts = await get_concerts_by_category(cat)
for c in concerts:
if not dm.already_enqueued(c["url"]):
m = re.search(r"\b(20\d{2})\b", c.get("subtitle", ""))
year = int(m.group(1)) if m else None
await dm.enqueue_direct(c["url"], c["title"], c.get("subtitle", ""), year, cat)
total += 1
return total
async def _auto_dl_loop():
await asyncio.sleep(60)
while True:
try:
n = await _run_auto_dl_check()
if n:
logger.info("Auto-DL: enqueued %d new concert(s)", n)
except Exception as e:
logger.warning("Auto-DL check failed: %s", e)
await asyncio.sleep(AUTO_DL_INTERVAL)
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(_auto_dl_loop())
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="Arte-dl", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# ------------------------------------------------------------------ pages
@@ -47,6 +93,34 @@ async def api_refresh():
return {"count": count}
# ------------------------------------------------------------------ API: auto-download
@app.get("/api/auto-dl")
async def api_auto_dl_list():
return dm.get_watched_categories()
@app.post("/api/auto-dl/check")
async def api_auto_dl_check():
n = await _run_auto_dl_check()
return {"enqueued": n}
@app.post("/api/auto-dl/{category}")
async def api_auto_dl_watch(category: str):
if category not in CATEGORIES:
raise HTTPException(status_code=404, detail="Unknown category")
dm.watch_category(category)
return {"watched": True}
@app.delete("/api/auto-dl/{category}")
async def api_auto_dl_unwatch(category: str):
dm.unwatch_category(category)
return {"watched": False}
# ------------------------------------------------------------------ API: downloads
@@ -55,13 +129,14 @@ class DownloadRequest(BaseModel):
title: str
subtitle: str = ""
year: int | None = None
category: str = ""
@app.post("/api/download")
async def api_download(req: DownloadRequest, bg: BackgroundTasks):
if not req.url:
raise HTTPException(status_code=400, detail="url required")
dl_id = dm.enqueue(req.url, req.title, req.subtitle, req.year, bg)
dl_id = dm.enqueue(req.url, req.title, req.subtitle, req.year, req.category, bg)
return {"id": dl_id}
+35 -4
View File
@@ -50,18 +50,48 @@ function debounce(fn, ms) {
// ── Categories ───────────────────────────────────────────────────────────────
async function loadCategories() {
try {
const cats = await fetch('/api/categories').then(r => r.json());
const [cats, watched] = await Promise.all([
fetch('/api/categories').then(r => r.json()),
fetch('/api/auto-dl').then(r => r.json()),
]);
const watchedSet = new Set(watched);
cats.forEach(cat => {
const btn = document.createElement('button');
btn.className = 'cat-pill';
btn.dataset.cat = cat;
btn.textContent = cat;
const label = document.createElement('span');
label.textContent = cat;
btn.appendChild(label);
const icon = document.createElement('span');
icon.className = 'auto-icon' + (watchedSet.has(cat) ? ' active' : '');
icon.title = watchedSet.has(cat) ? 'Auto-DL actif — cliquer pour désactiver' : 'Activer le téléchargement automatique';
icon.dataset.cat = cat;
icon.textContent = '⬇';
btn.appendChild(icon);
catBar.appendChild(btn);
});
} catch {}
}
async function toggleAutoDl(cat, iconEl) {
const isActive = iconEl.classList.contains('active');
try {
await fetch(`/api/auto-dl/${encodeURIComponent(cat)}`, { method: isActive ? 'DELETE' : 'POST' });
iconEl.classList.toggle('active', !isActive);
iconEl.title = !isActive ? 'Auto-DL actif — cliquer pour désactiver' : 'Activer le téléchargement automatique';
} catch {}
}
catBar.addEventListener('click', e => {
const icon = e.target.closest('.auto-icon');
if (icon) {
e.stopPropagation();
toggleAutoDl(icon.dataset.cat, icon);
return;
}
const pill = e.target.closest('.cat-pill');
if (!pill) return;
catBar.querySelectorAll('.cat-pill').forEach(p => p.classList.remove('active'));
@@ -110,9 +140,10 @@ function renderConcerts(data) {
const dl = state.downloadedUrls.has(c.url) ? `<span class="card-downloaded">✓ Téléchargé</span>` : '';
const date = fmtDate(c.upload_date);
const sub = c.subtitle ? `<div class="card-subtitle">${c.subtitle}</div>` : '';
const downloadedClass = state.downloadedUrls.has(c.url) ? 'downloaded' : '';
return `
<div class="card" data-id="${c.id}" tabindex="0" role="button" aria-label="${c.title}">
<div class="card ${downloadedClass}" data-id="${c.id}" tabindex="0" role="button" aria-label="${c.title}">
<div class="card-thumb-wrap">
${thumb}${dur}${dl}
</div>
@@ -270,7 +301,7 @@ $('btn-download').addEventListener('click', async () => {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: c.url, title: c.title, subtitle: c.subtitle || '', year }),
body: JSON.stringify({ url: c.url, title: c.title, subtitle: c.subtitle || '', year, category: state.category }),
});
const { id } = await res.json();
trackDownload(id, c.title, c.url);
+49 -2
View File
@@ -216,10 +216,13 @@ body {
cursor: pointer;
font-family: 'Inter', sans-serif;
font-size: 13px;
padding: 6px 16px;
padding: 6px 6px 6px 14px;
white-space: nowrap;
transition: color var(--transition), border-color var(--transition), background var(--transition);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
}
.cat-pill:hover {
@@ -234,6 +237,37 @@ body {
font-weight: 600;
}
.auto-icon {
font-size: 11px;
line-height: 1;
opacity: 0.3;
padding: 2px 5px;
border-radius: 10px;
transition: opacity var(--transition), background var(--transition), color var(--transition);
cursor: pointer;
}
.auto-icon:hover {
opacity: 0.8;
background: rgba(255,255,255,0.1);
}
.auto-icon.active {
opacity: 1;
color: var(--gold);
}
.cat-pill.active .auto-icon {
opacity: 0.5;
color: #000;
}
.cat-pill.active .auto-icon.active {
opacity: 1;
background: rgba(0,0,0,0.18);
color: #000;
}
/* ══ GRID ══════════════════════════════════════════════════════════════════ */
.grid {
display: grid;
@@ -252,7 +286,7 @@ body {
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition);
transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition), opacity var(--transition), filter var(--transition);
position: relative;
}
@@ -262,6 +296,19 @@ body {
border-color: var(--gold-dim);
}
.card.downloaded {
opacity: 0.45;
filter: grayscale(0.6);
}
.card.downloaded:hover {
opacity: 0.65;
filter: grayscale(0.4);
transform: none;
box-shadow: none;
border-color: var(--border);
}
.card-thumb-wrap {
position: relative;
aspect-ratio: 16/9;