feat: initial implementation — Arte Concert web GUI
Docker / docker (push) Successful in 2m50s

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 <noreply@anthropic.com>
This commit is contained in:
dev
2026-04-25 18:36:00 +02:00
parent 8b841950b4
commit eadc242173
10 changed files with 1546 additions and 0 deletions
+38
View File
@@ -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
+14
View File
@@ -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"]
+119
View File
@@ -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)
+12
View File
@@ -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
+131
View File
@@ -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),
)
+76
View File
@@ -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")
+5
View File
@@ -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
+364
View File
@@ -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('<div class="skeleton"></div>').join('');
}
function renderConcerts(data) {
const { concerts, total, pages } = data;
state.totalPages = pages;
if (!concerts.length) {
grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1;padding:40px 0">Aucun résultat.</p>';
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
? `<img class="card-thumb" src="${c.thumbnail}" alt="" loading="lazy" />`
: `<div class="card-thumb" style="background:#1a1a1a"></div>`;
const dur = c.duration ? `<span class="card-duration">${fmtDuration(c.duration)}</span>` : '';
const dl = state.downloadedUrls.has(c.url) ? `<span class="card-downloaded">✓ Téléchargé</span>` : '';
const date = fmtDate(c.upload_date);
return `
<div class="card" data-id="${c.id}" tabindex="0" role="button" aria-label="${c.title}">
<div class="card-thumb-wrap">
${thumb}${dur}${dl}
</div>
<div class="card-info">
<div class="card-title">${c.title}</div>
${date ? `<div class="card-date">${date}</div>` : ''}
</div>
</div>`;
}).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 += `<button class="page-btn" data-p="${p-1}" ${p===1?'disabled':''}></button>`;
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 += `<span style="color:var(--text-muted);padding:0 4px">…</span>`;
btns += `<button class="page-btn ${n===p?'active':''}" data-p="${n}">${n}</button>`;
prev = n;
}
btns += `<button class="page-btn" data-p="${p+1}" ${p===pages?'disabled':''}></button>`;
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 = `<path d="M10 3v9M7 9l3 3 3-3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 15h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>`;
}
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 = '<p class="dl-empty">Aucun téléchargement.</p>';
return;
}
dlPanelBody.innerHTML = items.map(([, d]) => {
const pct = Math.round(d.progress || 0);
const showBar = ['downloading','processing'].includes(d.state);
return `
<div class="dl-item">
<div class="dl-item-title">${d.title}</div>
<div class="dl-item-state ${d.state}">${stateLabel(d.state)}</div>
${showBar ? `
<div class="dl-item-bar-wrap">
<div class="dl-item-bar-fill" style="width:${pct}%"></div>
</div>` : ''}
</div>`;
}).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();
})();
+661
View File
@@ -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); }
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arte Concert</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<!-- ══ HEADER ═════════════════════════════════════════════════════════════ -->
<header class="header">
<div class="header-inner">
<div class="brand">
<span class="brand-logo"></span>
<div class="brand-text">
<span class="brand-name">ARTE</span>
<span class="brand-sub">CONCERT</span>
</div>
</div>
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 20 20" fill="none">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M13 13l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input id="search" class="search-input" type="text" placeholder="Rechercher un concert, un artiste…" autocomplete="off" />
<kbd class="search-kbd">⌘K</kbd>
</div>
<div class="header-actions">
<button class="btn-ghost" id="btn-refresh" title="Actualiser le catalogue">
<svg viewBox="0 0 20 20" fill="none" width="16" height="16">
<path d="M3 10a7 7 0 0 1 12-4.9M17 10a7 7 0 0 1-12 4.9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M15 5.5V3M3 14.5V17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button class="btn-ghost btn-dl-toggle" id="btn-dl-toggle" title="Téléchargements">
<svg viewBox="0 0 20 20" fill="none" width="16" height="16">
<path d="M10 3v9M7 9l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 15h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="dl-badge" id="dl-badge" hidden>0</span>
</button>
</div>
</div>
</header>
<!-- ══ MAIN ═══════════════════════════════════════════════════════════════ -->
<main class="main">
<!-- status bar -->
<div class="status-bar" id="status-bar">
<span id="status-text">Chargement du catalogue…</span>
</div>
<!-- grid -->
<div class="grid" id="grid">
<!-- skeleton placeholders -->
<div class="skeleton"></div><div class="skeleton"></div>
<div class="skeleton"></div><div class="skeleton"></div>
<div class="skeleton"></div><div class="skeleton"></div>
<div class="skeleton"></div><div class="skeleton"></div>
</div>
<!-- pagination -->
<nav class="pagination" id="pagination" hidden></nav>
</main>
<!-- ══ MODAL ══════════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modal-overlay" hidden>
<div class="modal" id="modal">
<button class="modal-close" id="modal-close"></button>
<div class="modal-thumb-wrap">
<img class="modal-thumb" id="modal-thumb" src="" alt="" />
<div class="modal-thumb-gradient"></div>
<span class="modal-duration-badge" id="modal-dur-badge"></span>
</div>
<div class="modal-body">
<h2 class="modal-title" id="modal-title"></h2>
<p class="modal-meta" id="modal-meta"></p>
<p class="modal-desc" id="modal-desc"></p>
<div class="modal-actions">
<button class="btn-download" id="btn-download">
<svg viewBox="0 0 20 20" fill="none" width="16" height="16">
<path d="M10 3v9M7 9l3 3 3-3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 15h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
Télécharger
</button>
<a class="btn-watch" id="btn-watch" href="#" target="_blank" rel="noopener">
<svg viewBox="0 0 20 20" fill="none" width="14" height="14">
<circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7.5l5 2.5-5 2.5V7.5z" fill="currentColor"/>
</svg>
Voir sur Arte
</a>
</div>
<div class="dl-progress-wrap" id="dl-progress-wrap" hidden>
<div class="dl-progress-bar">
<div class="dl-progress-fill" id="dl-progress-fill"></div>
</div>
<span class="dl-progress-label" id="dl-progress-label">0%</span>
</div>
</div>
</div>
</div>
<!-- ══ DOWNLOADS PANEL ════════════════════════════════════════════════════ -->
<div class="dl-panel" id="dl-panel" hidden>
<div class="dl-panel-header">
<span class="dl-panel-title">Téléchargements</span>
<button class="btn-ghost" id="dl-panel-close"></button>
</div>
<div class="dl-panel-body" id="dl-panel-body">
<p class="dl-empty">Aucun téléchargement.</p>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>