feat: auto-téléchargement par catégorie avec souscription persistante
Docker / docker (push) Has been cancelled
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:
@@ -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
@@ -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"}))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user