'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, watched] = await Promise.all([ fetch('/api/categories').then(r => r.json()), fetch('/api/auto-dl').then(r => r.json()), ]); const watchedSet = new Set(watched); cats.forEach(cat => { const btn = document.createElement('button'); btn.className = 'cat-pill'; btn.dataset.cat = cat; const label = document.createElement('span'); label.textContent = cat; btn.appendChild(label); const icon = document.createElement('span'); icon.className = 'auto-icon' + (watchedSet.has(cat) ? ' active' : ''); icon.title = watchedSet.has(cat) ? 'Auto-DL actif — cliquer pour désactiver' : 'Activer le téléchargement automatique'; icon.dataset.cat = cat; icon.textContent = '⬇'; btn.appendChild(icon); catBar.appendChild(btn); }); } catch {} } async function toggleAutoDl(cat, iconEl) { const isActive = iconEl.classList.contains('active'); try { await fetch(`/api/auto-dl/${encodeURIComponent(cat)}`, { method: isActive ? 'DELETE' : 'POST' }); iconEl.classList.toggle('active', !isActive); iconEl.title = !isActive ? 'Auto-DL actif — cliquer pour désactiver' : 'Activer le téléchargement automatique'; if (!isActive) { statusText.textContent = `Auto-DL ${cat} : vérification en cours…`; const { enqueued } = await fetch('/api/auto-dl/check', { method: 'POST' }).then(r => r.json()); statusText.textContent = enqueued > 0 ? `Auto-DL ${cat} : ${enqueued} concert${enqueued > 1 ? 's' : ''} ajouté${enqueued > 1 ? 's' : ''} à la file.` : `Auto-DL ${cat} activé — aucun nouveau concert pour l'instant.`; } else { statusText.textContent = `Auto-DL ${cat} désactivé.`; } } catch {} } catBar.addEventListener('click', e => { const icon = e.target.closest('.auto-icon'); if (icon) { e.stopPropagation(); toggleAutoDl(icon.dataset.cat, icon); return; } const pill = e.target.closest('.cat-pill'); 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('
').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 imgSrc = c.tmdb_backdrop || c.thumbnail; const thumb = imgSrc ? `` : `
`; const dur = c.duration ? `${fmtDuration(c.duration)}` : ''; const dl = state.downloadedUrls.has(c.url) ? `✓ Téléchargé` : ''; const date = fmtDate(c.upload_date); const sub = c.subtitle ? `
${c.subtitle}
` : ''; const downloadedClass = state.downloadedUrls.has(c.url) ? 'downloaded' : ''; return `
${thumb}${dur}${dl}
${c.title}
${sub} ${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; const modalThumbEl = $('modal-thumb'); modalThumbEl.src = concert.tmdb_backdrop || concert.thumbnail || ''; const posterEl = $('modal-poster'); if (concert.tmdb_poster) { posterEl.src = concert.tmdb_poster; posterEl.hidden = false; } else { posterEl.hidden = true; } $('modal-title').textContent = concert.title; $('modal-meta').textContent = [ concert.subtitle || '', 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 btnTmdb = $('btn-tmdb'); if (concert.tmdb_id) { btnTmdb.href = `https://www.themoviedb.org/movie/${concert.tmdb_id}`; btnTmdb.hidden = false; } else { btnTmdb.hidden = true; } const btnDl = $('btn-download'); const alreadyDone = state.downloadedUrls.has(concert.url); btnDl.textContent = alreadyDone ? 'Re-télécharger' : 'Télécharger'; btnDl.classList.toggle('btn-redownload', alreadyDone); const dlIcon = document.createElementNS('http://www.w3.org/2000/svg','svg'); dlIcon.setAttribute('viewBox','0 0 20 20'); dlIcon.setAttribute('fill','none'); dlIcon.setAttribute('width','16'); dlIcon.setAttribute('height','16'); dlIcon.innerHTML = ` `; btnDl.prepend(dlIcon); btnDl.disabled = false; $('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 yearMatch = (c.subtitle || '').match(/\b(20\d{2})\b/); const year = yearMatch ? parseInt(yearMatch[1]) : (c.tmdb_year || null); const res = await fetch('/api/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: c.url, title: c.title, subtitle: c.subtitle || '', year, category: state.category }), }); 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é'; const b = $('btn-download'); b.textContent = 'Re-télécharger'; b.classList.add('btn-redownload'); b.disabled = false; } } 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(); } }); // ── Cache auto-update polling ───────────────────────────────────────────────── let _cacheTs = 0; async function pollCacheTs() { try { const { ts, count } = await fetch('/api/cache-ts').then(r => r.json()); if (_cacheTs && ts > _cacheTs && count > 0) { _cacheTs = ts; await refresh(); } else if (!_cacheTs) { _cacheTs = ts; } } catch {} } // ── Init ───────────────────────────────────────────────────────────────────── (async () => { await Promise.all([loadCategories(), refreshDlHistory()]); await refresh(); _cacheTs = (await fetch('/api/cache-ts').then(r => r.json()).catch(() => ({ts:0}))).ts; setInterval(pollCacheTs, 30_000); })();