From eadc242173273513e887e28cbd641215cb812f4b Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 25 Apr 2026 18:36:00 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20implementation=20=E2=80=94=20?= =?UTF-8?q?Arte=20Concert=20web=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI backend + HTML/JS frontend pour parcourir et télécharger les concerts Arte Concert. Cache 6h, recherche live, historique SQLite, suivi de progression SSE, design sombre Playfair Display + Inter. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/docker.yml | 38 +++ Dockerfile | 14 + arte_api.py | 119 +++++++ docker-compose.yml | 12 + downloader.py | 131 +++++++ main.py | 76 +++++ requirements.txt | 5 + static/app.js | 364 ++++++++++++++++++++ static/style.css | 661 ++++++++++++++++++++++++++++++++++++ templates/index.html | 126 +++++++ 10 files changed, 1546 insertions(+) create mode 100644 .gitea/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 arte_api.py create mode 100644 docker-compose.yml create mode 100644 downloader.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..d006b09 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,38 @@ +name: Docker + +on: + push: + branches: [main] + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Install docker CLI + run: | + apt-get update -qq && apt-get install -y -qq ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bullseye stable" > /etc/apt/sources.list.d/docker.list + apt-get update -qq && apt-get install -y -qq docker-ce-cli + + - uses: actions/checkout@v3 + + - uses: docker/setup-buildx-action@v2 + with: + driver: docker + + - uses: docker/login-action@v2 + with: + registry: forge.dilain.com + username: laurent + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build & push + run: | + docker build \ + -t forge.dilain.com/laurent/arte-dl:main \ + -t forge.dilain.com/laurent/arte-dl:latest \ + . + docker push forge.dilain.com/laurent/arte-dl:main + docker push forge.dilain.com/laurent/arte-dl:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd1c99a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update -qq && apt-get install -y -qq ffmpeg && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/arte_api.py b/arte_api.py new file mode 100644 index 0000000..a865e12 --- /dev/null +++ b/arte_api.py @@ -0,0 +1,119 @@ +import asyncio +import logging +import time +import yt_dlp + +logger = logging.getLogger(__name__) + +CACHE_TTL = 6 * 3600 +_cache: dict = {"data": [], "ts": 0} + +ARTE_CONCERT_URL = "https://www.arte.tv/fr/videos/RC-014034/arte-concert/" + + +def _best_thumbnail(entry: dict) -> str: + thumbs = entry.get("thumbnails") or [] + if thumbs: + # prefer largest + sorted_thumbs = sorted(thumbs, key=lambda t: t.get("width", 0), reverse=True) + return sorted_thumbs[0].get("url", "") + return entry.get("thumbnail", "") + + +def _normalize(e: dict) -> dict | None: + if not e or not e.get("id"): + return None + video_id = e.get("id", "") + url = ( + e.get("url") + or e.get("webpage_url") + or f"https://www.arte.tv/fr/videos/{video_id}/" + ) + return { + "id": video_id, + "title": e.get("title", ""), + "url": url, + "thumbnail": _best_thumbnail(e), + "duration": e.get("duration"), + "description": e.get("description", ""), + "upload_date": e.get("upload_date", ""), + "release_timestamp": e.get("release_timestamp"), + } + + +def _fetch_sync() -> list: + concerts: list = [] + seen: set = set() + + ydl_opts = { + "quiet": True, + "no_warnings": True, + "extract_flat": True, + "ignoreerrors": True, + } + + def _collect(entries: list, ydl, depth: int = 0): + for e in entries or []: + if not e: + continue + etype = e.get("_type", "") + # sub-collection → recurse one level + if etype in ("playlist", "url_transparent") and depth < 1: + sub_url = e.get("url") or e.get("webpage_url") + if sub_url: + try: + info = ydl.extract_info(sub_url, download=False) + if info: + _collect(info.get("entries", []), ydl, depth + 1) + except Exception as ex: + logger.debug("sub-collection error: %s", ex) + continue + entry = _normalize(e) + if entry and entry["id"] not in seen: + seen.add(entry["id"]) + concerts.append(entry) + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + try: + info = ydl.extract_info(ARTE_CONCERT_URL, download=False) + if info: + _collect(info.get("entries", []), ydl) + except Exception as ex: + logger.error("fetch error: %s", ex) + + return concerts + + +async def get_all_concerts() -> list: + now = time.time() + if _cache["data"] and now - _cache["ts"] < CACHE_TTL: + return _cache["data"] + loop = asyncio.get_event_loop() + data = await loop.run_in_executor(None, _fetch_sync) + if data: + _cache["data"] = data + _cache["ts"] = now + return _cache["data"] + + +async def fetch_concerts(page: int = 1, search: str = "", page_size: int = 24) -> dict: + all_c = await get_all_concerts() + filtered = all_c + if search: + q = search.lower() + filtered = [c for c in all_c if q in c["title"].lower() or q in c["description"].lower()] + start = (page - 1) * page_size + page_data = filtered[start : start + page_size] + return { + "concerts": page_data, + "total": len(filtered), + "page": page, + "page_size": page_size, + "pages": max(1, (len(filtered) + page_size - 1) // page_size), + } + + +async def invalidate_cache() -> int: + _cache["ts"] = 0 + data = await get_all_concerts() + return len(data) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2af43fe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + arte-dl: + image: forge.dilain.com/laurent/arte-dl:latest + container_name: arte-dl + restart: unless-stopped + ports: + - "8085:8080" + volumes: + - /mnt/user/data/Arte:/data/Arte + - /mnt/user/appdata/arte-dl:/app/data + environment: + - TZ=Europe/Paris diff --git a/downloader.py b/downloader.py new file mode 100644 index 0000000..ccbaa2b --- /dev/null +++ b/downloader.py @@ -0,0 +1,131 @@ +import sqlite3 +import threading +import uuid +from datetime import datetime +from pathlib import Path + +import yt_dlp +from fastapi import BackgroundTasks + +OUTPUT_DIR = "/data/Arte" +DB_PATH = "arte_dl.db" + + +def _db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +class DownloadManager: + def __init__(self): + self._active: dict[str, dict] = {} + self._lock = threading.Lock() + self._init_db() + + def _init_db(self): + with _db() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS downloads ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + title TEXT NOT NULL, + filename TEXT, + state TEXT NOT NULL DEFAULT 'queued', + progress REAL DEFAULT 0, + speed TEXT DEFAULT '', + eta INTEGER, + started_at TEXT, + finished_at TEXT, + error TEXT + ) + """) + + # ------------------------------------------------------------------ public + + def enqueue(self, url: str, title: str, bg: BackgroundTasks) -> str: + dl_id = str(uuid.uuid4()) + now = datetime.now().isoformat() + with _db() as conn: + conn.execute( + "INSERT INTO downloads (id, url, title, state, started_at) VALUES (?,?,?,'queued',?)", + (dl_id, url, title, now), + ) + with self._lock: + self._active[dl_id] = {"state": "queued", "progress": 0, "title": title} + bg.add_task(self._run, dl_id, url) + return dl_id + + def status(self, dl_id: str) -> dict: + with self._lock: + return dict(self._active.get(dl_id, {"state": "unknown"})) + + def history(self) -> list[dict]: + with _db() as conn: + rows = conn.execute( + "SELECT * FROM downloads ORDER BY started_at DESC LIMIT 200" + ).fetchall() + return [dict(r) for r in rows] + + 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 + + # ----------------------------------------------------------------- private + + def _set(self, dl_id: str, **kw): + with self._lock: + self._active.setdefault(dl_id, {}).update(kw) + + def _run(self, dl_id: str, url: str): + Path(OUTPUT_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,)) + + def hook(d): + if d["status"] == "downloading": + raw = d.get("_percent_str", "0%").strip().rstrip("%") + try: + pct = float(raw) + except ValueError: + pct = 0.0 + self._set( + dl_id, + state="downloading", + progress=pct, + speed=d.get("_speed_str", ""), + eta=d.get("eta"), + ) + elif d["status"] == "finished": + self._set(dl_id, state="processing", progress=100) + + ydl_opts = { + "outtmpl": f"{OUTPUT_DIR}/%(title)s.%(ext)s", + "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", + "merge_output_format": "mp4", + "progress_hooks": [hook], + "quiet": True, + "no_warnings": True, + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=True) + filename = ydl.prepare_filename(info) + self._set(dl_id, state="done", progress=100) + with _db() as conn: + conn.execute( + "UPDATE downloads SET state='done', progress=100, filename=?, finished_at=? WHERE id=?", + (filename, datetime.now().isoformat(), dl_id), + ) + except Exception as exc: + self._set(dl_id, state="error", error=str(exc)) + with _db() as conn: + conn.execute( + "UPDATE downloads SET state='error', error=?, finished_at=? WHERE id=?", + (str(exc), datetime.now().isoformat(), dl_id), + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..9070b43 --- /dev/null +++ b/main.py @@ -0,0 +1,76 @@ +import asyncio +import json +import logging + +from fastapi import BackgroundTasks, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +from arte_api import fetch_concerts, invalidate_cache +from downloader import DownloadManager + +logging.basicConfig(level=logging.INFO) + +app = FastAPI(title="Arte-dl") +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +dm = DownloadManager() + + +# ------------------------------------------------------------------ pages + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + + +# ------------------------------------------------------------------ API: concerts + + +@app.get("/api/concerts") +async def api_concerts(page: int = 1, search: str = "", page_size: int = 24): + return await fetch_concerts(page=page, search=search, page_size=page_size) + + +@app.post("/api/refresh") +async def api_refresh(): + count = await invalidate_cache() + return {"count": count} + + +# ------------------------------------------------------------------ API: downloads + + +class DownloadRequest(BaseModel): + url: str + title: 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, bg) + return {"id": dl_id} + + +@app.get("/api/downloads") +async def api_downloads(): + return dm.history() + + +@app.get("/api/progress/{dl_id}") +async def api_progress(dl_id: str): + async def stream(): + while True: + s = dm.status(dl_id) + yield f"data: {json.dumps(s)}\n\n" + if s.get("state") in ("done", "error", "unknown"): + break + await asyncio.sleep(0.8) + + return StreamingResponse(stream(), media_type="text/event-stream") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b0935bd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +yt-dlp==2024.10.7 +jinja2==3.1.4 +python-multipart==0.0.12 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..99571e4 --- /dev/null +++ b/static/app.js @@ -0,0 +1,364 @@ +'use strict'; + +// ── State ──────────────────────────────────────────────────────────────────── +const state = { + page: 1, + search: '', + pageSize: 24, + totalPages: 1, + current: null, // concert object shown in modal + activeDls: {}, // dl_id → { title, state, progress } + downloadedUrls: new Set(), +}; + +// ── DOM refs ───────────────────────────────────────────────────────────────── +const $ = id => document.getElementById(id); +const grid = $('grid'); +const pagination = $('pagination'); +const statusText = $('status-text'); +const searchInput = $('search'); +const modalOverlay = $('modal-overlay'); +const dlPanel = $('dl-panel'); +const dlPanelBody = $('dl-panel-body'); +const dlBadge = $('dl-badge'); + +// ── Helpers ────────────────────────────────────────────────────────────────── +function fmtDuration(secs) { + if (!secs) return ''; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = Math.floor(secs % 60); + return h + ? `${h}h ${String(m).padStart(2,'0')}min` + : `${m}min ${String(s).padStart(2,'0')}s`; +} + +function fmtDate(raw) { + if (!raw || raw.length < 8) return ''; + const y = raw.slice(0, 4), mo = raw.slice(4, 6), d = raw.slice(6, 8); + return new Date(`${y}-${mo}-${d}`).toLocaleDateString('fr-FR', { + day: 'numeric', month: 'long', year: 'numeric' + }); +} + +function debounce(fn, ms) { + let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; +} + +// ── Concerts ───────────────────────────────────────────────────────────────── +async function loadConcerts() { + const params = new URLSearchParams({ + page: state.page, + search: state.search, + page_size: state.pageSize, + }); + const res = await fetch(`/api/concerts?${params}`); + if (!res.ok) throw new Error(res.statusText); + return res.json(); +} + +function renderSkeletons() { + grid.innerHTML = Array(8).fill('
').join(''); +} + +function renderConcerts(data) { + const { concerts, total, pages } = data; + state.totalPages = pages; + + if (!concerts.length) { + grid.innerHTML = '

Aucun résultat.

'; + pagination.hidden = true; + statusText.textContent = 'Aucun résultat.'; + return; + } + + statusText.textContent = `${total} concert${total > 1 ? 's' : ''} · page ${state.page} / ${pages}`; + + grid.innerHTML = concerts.map(c => { + const thumb = c.thumbnail + ? `` + : `
`; + const dur = c.duration ? `${fmtDuration(c.duration)}` : ''; + const dl = state.downloadedUrls.has(c.url) ? `✓ Téléchargé` : ''; + const date = fmtDate(c.upload_date); + + return ` +
+
+ ${thumb}${dur}${dl} +
+
+
${c.title}
+ ${date ? `
${date}
` : ''} +
+
`; + }).join(''); + + // store concerts for modal access + grid._concerts = concerts; + + renderPagination(pages); +} + +async function refresh() { + renderSkeletons(); + pagination.hidden = true; + statusText.textContent = 'Chargement…'; + try { + const data = await loadConcerts(); + renderConcerts(data); + } catch (e) { + statusText.textContent = `Erreur : ${e.message}`; + grid.innerHTML = ''; + } +} + +// ── Pagination ─────────────────────────────────────────────────────────────── +function renderPagination(pages) { + if (pages <= 1) { pagination.hidden = true; return; } + pagination.hidden = false; + const p = state.page; + + let btns = ''; + btns += ``; + + const range = [...new Set([1, p-1, p, p+1, pages])].filter(n => n>=1 && n<=pages).sort((a,b)=>a-b); + let prev = null; + for (const n of range) { + if (prev && n - prev > 1) btns += ``; + btns += ``; + prev = n; + } + + btns += ``; + pagination.innerHTML = btns; +} + +pagination.addEventListener('click', e => { + const btn = e.target.closest('[data-p]'); + if (!btn || btn.disabled) return; + const p = +btn.dataset.p; + if (p < 1 || p > state.totalPages) return; + state.page = p; + window.scrollTo({ top: 0, behavior: 'smooth' }); + refresh(); +}); + +// ── Modal ──────────────────────────────────────────────────────────────────── +function openModal(concert) { + state.current = concert; + + $('modal-thumb').src = concert.thumbnail || ''; + $('modal-title').textContent = concert.title; + $('modal-meta').textContent = [ + concert.duration ? fmtDuration(concert.duration) : '', + concert.upload_date ? fmtDate(concert.upload_date) : '', + ].filter(Boolean).join(' · '); + $('modal-desc').textContent = concert.description || ''; + $('modal-dur-badge').textContent = concert.duration ? fmtDuration(concert.duration) : ''; + $('btn-watch').href = concert.url || '#'; + + const btnDl = $('btn-download'); + const alreadyDone = state.downloadedUrls.has(concert.url); + btnDl.textContent = alreadyDone ? '✓ Déjà téléchargé' : 'Télécharger'; + btnDl.prepend((() => { + const s = document.createElementNS('http://www.w3.org/2000/svg','svg'); + s.setAttribute('viewBox','0 0 20 20'); s.setAttribute('fill','none'); + s.setAttribute('width','16'); s.setAttribute('height','16'); + if (!alreadyDone) { + s.innerHTML = ` + `; + } + return s; + })()); + btnDl.disabled = alreadyDone; + + $('dl-progress-wrap').hidden = true; + $('dl-progress-fill').style.width = '0%'; + $('dl-progress-label').textContent = '0%'; + + modalOverlay.hidden = false; + document.body.style.overflow = 'hidden'; +} + +function closeModal() { + modalOverlay.hidden = true; + document.body.style.overflow = ''; + state.current = null; +} + +$('modal-close').addEventListener('click', closeModal); +modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeModal(); }); +document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); + +grid.addEventListener('click', e => { + const card = e.target.closest('.card'); + if (!card) return; + const concerts = grid._concerts || []; + const c = concerts.find(x => x.id === card.dataset.id); + if (c) openModal(c); +}); + +grid.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { + const card = e.target.closest('.card'); + if (card) card.click(); + } +}); + +// ── Download ───────────────────────────────────────────────────────────────── +$('btn-download').addEventListener('click', async () => { + const c = state.current; + if (!c) return; + + const btnDl = $('btn-download'); + btnDl.disabled = true; + btnDl.textContent = 'Démarrage…'; + + try { + const res = await fetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: c.url, title: c.title }), + }); + const { id } = await res.json(); + trackDownload(id, c.title, c.url); + } catch (e) { + btnDl.disabled = false; + btnDl.textContent = 'Erreur — réessayer'; + } +}); + +function trackDownload(id, title, url) { + state.activeDls[id] = { title, state: 'queued', progress: 0 }; + updateDlBadge(); + renderDlPanel(); + + $('dl-progress-wrap').hidden = false; + + const es = new EventSource(`/api/progress/${id}`); + es.onmessage = ev => { + const s = JSON.parse(ev.data); + state.activeDls[id] = { ...state.activeDls[id], ...s }; + updateDlBadge(); + renderDlPanel(); + + // update modal progress if it's still open for this url + if (state.current?.url === url) { + const pct = s.progress || 0; + $('dl-progress-fill').style.width = `${pct}%`; + $('dl-progress-label').textContent = `${Math.round(pct)}%`; + if (s.state === 'done') { + $('dl-progress-label').textContent = '✓ Terminé'; + $('btn-download').textContent = '✓ Téléchargé'; + } + } + + if (s.state === 'done') { + state.downloadedUrls.add(url); + es.close(); + } else if (s.state === 'error') { + es.close(); + } + }; + es.onerror = () => es.close(); +} + +function updateDlBadge() { + const active = Object.values(state.activeDls).filter( + d => d.state === 'downloading' || d.state === 'queued' || d.state === 'processing' + ).length; + if (active > 0) { + dlBadge.hidden = false; + dlBadge.textContent = active; + } else { + dlBadge.hidden = true; + } +} + +function renderDlPanel() { + const items = Object.entries(state.activeDls); + if (!items.length) { + dlPanelBody.innerHTML = '

Aucun téléchargement.

'; + return; + } + dlPanelBody.innerHTML = items.map(([, d]) => { + const pct = Math.round(d.progress || 0); + const showBar = ['downloading','processing'].includes(d.state); + return ` +
+
${d.title}
+
${stateLabel(d.state)}
+ ${showBar ? ` +
+
+
` : ''} +
`; + }).join(''); +} + +function stateLabel(s) { + return { queued:'En attente', downloading:'Téléchargement', processing:'Finalisation', done:'Terminé', error:'Erreur' }[s] || s; +} + +// ── Downloads panel toggle ──────────────────────────────────────────────────── +$('btn-dl-toggle').addEventListener('click', () => { + dlPanel.hidden = !dlPanel.hidden; + if (!dlPanel.hidden) refreshDlHistory(); +}); +$('dl-panel-close').addEventListener('click', () => { dlPanel.hidden = true; }); + +async function refreshDlHistory() { + try { + const history = await fetch('/api/downloads').then(r => r.json()); + for (const h of history) { + if (!state.activeDls[h.id]) { + state.activeDls[h.id] = { title: h.title, state: h.state, progress: h.progress || 0 }; + } + if (h.state === 'done') state.downloadedUrls.add(h.url); + } + renderDlPanel(); + updateDlBadge(); + } catch {} +} + +// ── Refresh button ───────────────────────────────────────────────────────── +$('btn-refresh').addEventListener('click', async () => { + const btn = $('btn-refresh'); + btn.style.opacity = '0.4'; + btn.disabled = true; + statusText.textContent = 'Rafraîchissement du catalogue…'; + try { + const r = await fetch('/api/refresh', { method: 'POST' }); + const { count } = await r.json(); + statusText.textContent = `Catalogue mis à jour — ${count} concerts.`; + state.page = 1; + await refresh(); + } catch { + statusText.textContent = 'Erreur lors du rafraîchissement.'; + } + btn.style.opacity = ''; + btn.disabled = false; +}); + +// ── Search ──────────────────────────────────────────────────────────────────── +const doSearch = debounce(() => { + state.search = searchInput.value.trim(); + state.page = 1; + refresh(); +}, 380); + +searchInput.addEventListener('input', doSearch); + +document.addEventListener('keydown', e => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + searchInput.focus(); + } +}); + +// ── Init ───────────────────────────────────────────────────────────────────── +(async () => { + await refreshDlHistory(); + await refresh(); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..36a3f99 --- /dev/null +++ b/static/style.css @@ -0,0 +1,661 @@ +/* ══ RESET & BASE ══════════════════════════════════════════════════════════ */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #080808; + --surface: #131313; + --surface-2: #1c1c1c; + --border: rgba(255,255,255,0.07); + --gold: #d4a853; + --gold-dim: rgba(212,168,83,0.45); + --gold-glow: rgba(212,168,83,0.12); + --text: #e8e8e8; + --text-dim: #888; + --text-muted: #555; + --radius: 10px; + --header-h: 64px; + --transition: 0.2s ease; +} + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'Inter', system-ui, sans-serif; + font-size: 14px; + min-height: 100vh; + overflow-x: hidden; +} + +/* ══ HEADER ════════════════════════════════════════════════════════════════ */ +.header { + position: fixed; + top: 0; left: 0; right: 0; + height: var(--header-h); + background: rgba(8,8,8,0.92); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + z-index: 100; +} + +.header-inner { + max-width: 1440px; + margin: 0 auto; + height: 100%; + padding: 0 24px; + display: flex; + align-items: center; + gap: 24px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + text-decoration: none; +} + +.brand-logo { + color: var(--gold); + font-size: 22px; + line-height: 1; +} + +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.1; +} + +.brand-name { + font-family: 'Playfair Display', serif; + font-size: 17px; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text); +} + +.brand-sub { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.22em; + color: var(--gold); + text-transform: uppercase; +} + +.search-wrap { + flex: 1; + max-width: 520px; + position: relative; + margin: 0 auto; +} + +.search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 15px; + height: 15px; + color: var(--text-muted); + pointer-events: none; +} + +.search-input { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 40px 9px 36px; + color: var(--text); + font-family: 'Inter', sans-serif; + font-size: 13.5px; + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.search-input::placeholder { color: var(--text-muted); } + +.search-input:focus { + border-color: var(--gold-dim); + box-shadow: 0 0 0 3px var(--gold-glow); +} + +.search-kbd { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 11px; + color: var(--text-muted); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 5px; + pointer-events: none; +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.btn-ghost { + background: none; + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text-dim); + cursor: pointer; + padding: 7px 10px; + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + transition: color var(--transition), border-color var(--transition), background var(--transition); + position: relative; +} + +.btn-ghost:hover { + color: var(--text); + border-color: rgba(255,255,255,0.15); + background: var(--surface); +} + +.dl-badge { + position: absolute; + top: -5px; right: -5px; + background: var(--gold); + color: #000; + font-size: 10px; + font-weight: 700; + min-width: 17px; + height: 17px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +/* ══ MAIN ══════════════════════════════════════════════════════════════════ */ +.main { + max-width: 1440px; + margin: 0 auto; + padding: calc(var(--header-h) + 28px) 24px 80px; +} + +.status-bar { + font-size: 12.5px; + color: var(--text-muted); + margin-bottom: 20px; + letter-spacing: 0.02em; +} + +/* ══ GRID ══════════════════════════════════════════════════════════════════ */ +.grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 18px; +} + +@media (max-width: 1100px) { .grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 720px) { .grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 480px) { .grid { grid-template-columns: 1fr; } } + +/* ══ CARD ══════════════════════════════════════════════════════════════════ */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); + position: relative; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 16px 48px rgba(0,0,0,0.6), 0 0 0 1px var(--gold-dim); + border-color: var(--gold-dim); +} + +.card-thumb-wrap { + position: relative; + aspect-ratio: 16/9; + overflow: hidden; + background: #111; +} + +.card-thumb { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.35s ease; + display: block; +} + +.card:hover .card-thumb { transform: scale(1.04); } + +.card-thumb-wrap::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(to bottom, + transparent 40%, + rgba(0,0,0,0.55) 75%, + rgba(0,0,0,0.85) 100% + ); +} + +.card-duration { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0,0,0,0.75); + border: 1px solid rgba(255,255,255,0.1); + color: var(--text); + font-size: 11px; + font-weight: 500; + padding: 2px 6px; + border-radius: 4px; + z-index: 2; + letter-spacing: 0.02em; +} + +.card-downloaded { + position: absolute; + top: 8px; + left: 8px; + background: rgba(0,0,0,0.7); + border: 1px solid var(--gold-dim); + color: var(--gold); + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: 4px; + z-index: 2; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.card-info { + padding: 12px 14px 14px; +} + +.card-title { + font-family: 'Playfair Display', serif; + font-size: 15px; + font-weight: 400; + line-height: 1.35; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-date { + margin-top: 6px; + font-size: 11.5px; + color: var(--text-muted); + letter-spacing: 0.03em; +} + +/* ══ SKELETON ══════════════════════════════════════════════════════════════ */ +.skeleton { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + aspect-ratio: 16/11; + overflow: hidden; + position: relative; +} + +.skeleton::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, + transparent 0%, + rgba(255,255,255,0.04) 50%, + transparent 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* ══ PAGINATION ════════════════════════════════════════════════════════════ */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 40px; +} + +.page-btn { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text-dim); + cursor: pointer; + min-width: 36px; + height: 36px; + padding: 0 10px; + font-size: 13px; + font-family: 'Inter', sans-serif; + transition: all var(--transition); +} + +.page-btn:hover { + border-color: var(--gold-dim); + color: var(--text); +} + +.page-btn.active { + background: var(--gold); + border-color: var(--gold); + color: #000; + font-weight: 600; +} + +.page-btn:disabled { + opacity: 0.3; + cursor: default; +} + +/* ══ MODAL ═════════════════════════════════════════════════════════════════ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.82); + backdrop-filter: blur(6px); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.modal-overlay[hidden] { display: none; } + +.modal { + background: var(--surface); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 14px; + width: 100%; + max-width: 640px; + max-height: 90vh; + overflow-y: auto; + position: relative; + box-shadow: 0 40px 100px rgba(0,0,0,0.8); + animation: modal-in 0.22s ease; +} + +@keyframes modal-in { + from { opacity: 0; transform: scale(0.95) translateY(12px); } + to { opacity: 1; transform: none; } +} + +.modal-close { + position: absolute; + top: 12px; right: 12px; + background: rgba(0,0,0,0.5); + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-dim); + cursor: pointer; + width: 30px; height: 30px; + display: flex; align-items: center; justify-content: center; + font-size: 13px; + z-index: 2; + transition: color var(--transition); +} + +.modal-close:hover { color: var(--text); } + +.modal-thumb-wrap { + position: relative; + aspect-ratio: 16/9; + overflow: hidden; + border-radius: 14px 14px 0 0; + background: #111; +} + +.modal-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.modal-thumb-gradient { + position: absolute; + inset: 0; + background: linear-gradient(to bottom, transparent 50%, var(--surface) 100%); +} + +.modal-duration-badge { + position: absolute; + bottom: 12px; right: 12px; + background: rgba(0,0,0,0.75); + border: 1px solid rgba(255,255,255,0.12); + color: var(--text); + font-size: 12px; + font-weight: 500; + padding: 3px 8px; + border-radius: 5px; + letter-spacing: 0.02em; +} + +.modal-body { + padding: 20px 28px 28px; +} + +.modal-title { + font-family: 'Playfair Display', serif; + font-size: 22px; + font-weight: 700; + line-height: 1.3; + margin-bottom: 8px; +} + +.modal-meta { + font-size: 12.5px; + color: var(--gold); + letter-spacing: 0.04em; + margin-bottom: 14px; +} + +.modal-desc { + font-size: 13.5px; + color: var(--text-dim); + line-height: 1.65; + max-height: 120px; + overflow-y: auto; + margin-bottom: 24px; +} + +.modal-desc::-webkit-scrollbar { width: 4px; } +.modal-desc::-webkit-scrollbar-track { background: transparent; } +.modal-desc::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +.modal-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.btn-download { + background: var(--gold); + color: #000; + border: none; + border-radius: 8px; + padding: 10px 22px; + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: opacity var(--transition), transform var(--transition); +} + +.btn-download:hover { opacity: 0.88; transform: translateY(-1px); } +.btn-download:active { transform: none; } +.btn-download:disabled { opacity: 0.5; cursor: default; transform: none; } + +.btn-watch { + color: var(--text-dim); + text-decoration: none; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; + transition: color var(--transition); +} + +.btn-watch:hover { color: var(--text); } + +.dl-progress-wrap { + margin-top: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.dl-progress-wrap[hidden] { display: none; } + +.dl-progress-bar { + flex: 1; + height: 4px; + background: var(--surface-2); + border-radius: 2px; + overflow: hidden; +} + +.dl-progress-fill { + height: 100%; + background: var(--gold); + border-radius: 2px; + transition: width 0.5s ease; + width: 0%; +} + +.dl-progress-label { + font-size: 12px; + color: var(--text-dim); + min-width: 36px; + text-align: right; +} + +/* ══ DOWNLOADS PANEL ═══════════════════════════════════════════════════════ */ +.dl-panel { + position: fixed; + bottom: 0; left: 0; right: 0; + max-height: 360px; + background: var(--surface); + border-top: 1px solid var(--border); + z-index: 150; + box-shadow: 0 -16px 48px rgba(0,0,0,0.5); + animation: slide-up 0.25s ease; +} + +.dl-panel[hidden] { display: none; } + +@keyframes slide-up { + from { transform: translateY(100%); } + to { transform: none; } +} + +.dl-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + border-bottom: 1px solid var(--border); +} + +.dl-panel-title { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-dim); +} + +.dl-panel-body { + overflow-y: auto; + max-height: 300px; + padding: 8px 0; +} + +.dl-empty { + padding: 20px 24px; + color: var(--text-muted); + font-size: 13px; +} + +.dl-item { + padding: 10px 24px; + display: grid; + grid-template-columns: 1fr auto; + gap: 4px 12px; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.dl-item:last-child { border-bottom: none; } + +.dl-item-title { + font-family: 'Playfair Display', serif; + font-size: 13.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dl-item-state { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: right; +} + +.dl-item-state.done { color: #5cb85c; } +.dl-item-state.error { color: #e55; } +.dl-item-state.queued { color: var(--text-muted); } +.dl-item-state.downloading, +.dl-item-state.processing { color: var(--gold); } + +.dl-item-bar-wrap { + grid-column: 1 / -1; + height: 3px; + background: var(--surface-2); + border-radius: 2px; + overflow: hidden; +} + +.dl-item-bar-fill { + height: 100%; + background: var(--gold); + border-radius: 2px; + transition: width 0.5s ease; +} + +/* scrollbars */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f64132f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,126 @@ + + + + + + Arte Concert + + + + + + + + +
+
+
+ +
+ ARTE + CONCERT +
+
+ +
+ + + + + + ⌘K +
+ +
+ + +
+
+
+ + +
+ + +
+ Chargement du catalogue… +
+ + +
+ +
+
+
+
+
+ + + + +
+ + + + + + + + + +