From 189b65bd6df61331da7cfd0250ce258deb8674ab Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 2 May 2026 19:23:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20auto-t=C3=A9l=C3=A9chargement=20par=20c?= =?UTF-8?q?at=C3=A9gorie=20avec=20souscription=20persistante?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- arte_api.py | 9 ++++++ downloader.py | 54 +++++++++++++++++++++++++++++-- main.py | 82 +++++++++++++++++++++++++++++++++++++++++++++--- static/app.js | 34 ++++++++++++++++++-- static/style.css | 51 ++++++++++++++++++++++++++++-- 5 files changed, 220 insertions(+), 10 deletions(-) diff --git a/arte_api.py b/arte_api.py index 3a44137..1acde61 100644 --- a/arte_api.py +++ b/arte_api.py @@ -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: diff --git a/downloader.py b/downloader.py index 621aa33..ae2d399 100644 --- a/downloader.py +++ b/downloader.py @@ -1,3 +1,4 @@ +import asyncio import re import sqlite3 import threading @@ -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, - category: str, 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,9 +155,21 @@ class DownloadManager: ) with self._lock: self._active[dl_id] = {"state": "queued", "progress": 0, "title": title} + 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: with self._lock: return dict(self._active.get(dl_id, {"state": "unknown"})) diff --git a/main.py b/main.py index 2dc2b0f..e9d0e92 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/static/app.js b/static/app.js index 0024138..a77d968 100644 --- a/static/app.js +++ b/static/app.js @@ -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')); diff --git a/static/style.css b/static/style.css index ea6fb9c..d4bdfdb 100644 --- a/static/style.css +++ b/static/style.css @@ -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;