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>
This commit is contained in:
dev
2026-05-02 19:23:57 +02:00
parent f07352bd04
commit 189b65bd6d
5 changed files with 220 additions and 10 deletions
+9
View File
@@ -144,6 +144,15 @@ def _search_sync(query: str) -> set[str]:
# ── public API ──────────────────────────────────────────────────────────────── # ── 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]: async def get_all_concerts() -> list[dict]:
now = time.time() now = time.time()
if _cache["data"] and now - _cache["ts"] < CACHE_TTL: if _cache["data"] and now - _cache["ts"] < CACHE_TTL:
+52 -2
View File
@@ -1,3 +1,4 @@
import asyncio
import re import re
import sqlite3 import sqlite3
import threading import threading
@@ -103,11 +104,48 @@ class DownloadManager:
error TEXT error TEXT
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS auto_dl_categories (
category TEXT PRIMARY KEY,
added_at TEXT NOT NULL
)
""")
# ------------------------------------------------------------------ public # ------------------------------------------------------------------ public
def enqueue(self, url: str, title: str, subtitle: str, year: int | None, def get_watched_categories(self) -> list[str]:
category: str, bg: BackgroundTasks) -> 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()) dl_id = str(uuid.uuid4())
now = datetime.now().isoformat() now = datetime.now().isoformat()
with _db() as conn: with _db() as conn:
@@ -117,9 +155,21 @@ 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}
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) bg.add_task(self._run, dl_id, url, title, subtitle, year, category)
return dl_id 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: def status(self, dl_id: str) -> dict:
with self._lock: with self._lock:
return dict(self._active.get(dl_id, {"state": "unknown"})) return dict(self._active.get(dl_id, {"state": "unknown"}))
+78 -4
View File
@@ -1,6 +1,9 @@
import asyncio import asyncio
import json import json
import logging import logging
import os
import re
from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
@@ -8,18 +11,61 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel 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 from downloader import DownloadManager
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Arte-dl") AUTO_DL_INTERVAL = int(os.getenv("AUTO_DL_INTERVAL", "3600"))
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
dm = DownloadManager() 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 # ------------------------------------------------------------------ pages
@@ -47,6 +93,34 @@ async def api_refresh():
return {"count": count} 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 # ------------------------------------------------------------------ API: downloads
+32 -2
View File
@@ -50,18 +50,48 @@ function debounce(fn, ms) {
// ── Categories ─────────────────────────────────────────────────────────────── // ── Categories ───────────────────────────────────────────────────────────────
async function loadCategories() { async function loadCategories() {
try { 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 => { cats.forEach(cat => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'cat-pill'; btn.className = 'cat-pill';
btn.dataset.cat = cat; 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); catBar.appendChild(btn);
}); });
} catch {} } 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 => { 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'); const pill = e.target.closest('.cat-pill');
if (!pill) return; if (!pill) return;
catBar.querySelectorAll('.cat-pill').forEach(p => p.classList.remove('active')); catBar.querySelectorAll('.cat-pill').forEach(p => p.classList.remove('active'));
+49 -2
View File
@@ -216,10 +216,13 @@ body {
cursor: pointer; cursor: pointer;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 13px; font-size: 13px;
padding: 6px 16px; padding: 6px 6px 6px 14px;
white-space: nowrap; white-space: nowrap;
transition: color var(--transition), border-color var(--transition), background var(--transition); transition: color var(--transition), border-color var(--transition), background var(--transition);
flex-shrink: 0; flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
} }
.cat-pill:hover { .cat-pill:hover {
@@ -234,6 +237,37 @@ body {
font-weight: 600; 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 ══════════════════════════════════════════════════════════════════ */
.grid { .grid {
display: grid; display: grid;
@@ -252,7 +286,7 @@ body {
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
cursor: pointer; 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; position: relative;
} }
@@ -262,6 +296,19 @@ body {
border-color: var(--gold-dim); 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 { .card-thumb-wrap {
position: relative; position: relative;
aspect-ratio: 16/9; aspect-ratio: 16/9;