Files
arte-dl/static/app.js
T
dev eadc242173
Docker / docker (push) Successful in 2m50s
feat: initial implementation — Arte Concert web GUI
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>
2026-04-25 18:36:00 +02:00

365 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();
})();