Files
arte-dl/static/app.js
T
dev 16736e2e7a
Docker / docker (push) Successful in 1m15s
feat: filtre par catégorie dans la GUI
- arte_api.py : GENRE_PAGES devient une liste de (nom, url), chaque
  concert reçoit un champ "categories" avec ses genres d'appartenance
- main.py : endpoint /api/categories + param ?category= sur /api/concerts
- index.html : barre de pills catégories (Tout + 10 genres)
- style.css : styles .cat-bar / .cat-pill avec pill active en or
- app.js : chargement dynamique des pills, filtre catégorie dans le state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 13:03:52 +02:00

392 lines
14 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: '',
category: '',
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');
const catBar = $('cat-bar');
// ── 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); };
}
// ── Categories ───────────────────────────────────────────────────────────────
async function loadCategories() {
try {
const cats = await fetch('/api/categories').then(r => r.json());
cats.forEach(cat => {
const btn = document.createElement('button');
btn.className = 'cat-pill';
btn.dataset.cat = cat;
btn.textContent = cat;
catBar.appendChild(btn);
});
} catch {}
}
catBar.addEventListener('click', e => {
const pill = e.target.closest('.cat-pill');
if (!pill) return;
catBar.querySelectorAll('.cat-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
state.category = pill.dataset.cat;
state.page = 1;
refresh();
});
// ── Concerts ─────────────────────────────────────────────────────────────────
async function loadConcerts() {
const params = new URLSearchParams({
page: state.page,
search: state.search,
page_size: state.pageSize,
category: state.category,
});
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 Promise.all([loadCategories(), refreshDlHistory()]);
await refresh();
})();