Files
arte-dl/static/app.js
T
dev 9a5e356238
Docker / docker (push) Successful in 1m21s
feat: intégration TMDB — poster, backdrop, lien fiche
- tmdb.py : recherche TMDB par title+subtitle, matching fuzzy,
  cache SQLite 30 jours (table tmdb_cache dans arte_dl.db)
- arte_api.py : enrichissement concurrent (5 workers) après résolution
  des IDs ; ajoute tmdb_id, tmdb_poster, tmdb_backdrop au concert
- app.js : backdrop TMDB utilisé comme thumbnail de carte quand dispo ;
  subtitle affiché sous le titre de carte ; poster dans la modal ;
  lien direct vers la fiche TMDB
- docker-compose.yml : passage de TMDB_API_KEY au container

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

414 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 imgSrc = c.tmdb_backdrop || c.thumbnail;
const thumb = imgSrc
? `<img class="card-thumb" src="${imgSrc}" 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);
const sub = c.subtitle ? `<div class="card-subtitle">${c.subtitle}</div>` : '';
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>
${sub}
${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;
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 ? '✓ 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();
})();