Initial release — MediaTracker v1.0

Dockerized media library scanner for NAS drives.
Identifies movies and series from filenames, fetches posters
via the public IMDB suggestion API (no key required), and
exposes a dark-themed web UI with multilingual search support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-05-19 11:40:44 +00:00
commit 6f551ba439
21 changed files with 2770 additions and 0 deletions

711
frontend/index.html Normal file
View File

@@ -0,0 +1,711 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MediaTracker</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=Bebas+Neue&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f0f;
--surface: #1a1a1a;
--surface2: #222222;
--border: #2e2e2e;
--accent: #e50914;
--accent-dim:#9e0a0f;
--text: #f0f0f0;
--text-muted:#888;
--green: #22c55e;
--red: #ef4444;
--radius: 10px;
--poster-w: 90px;
--poster-h: 135px;
}
html, body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 15px;
min-height: 100vh;
}
/* ── HEADER ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 32px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 100;
}
.logo { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; letter-spacing: 2px; color: var(--accent); }
.header-stats { display: flex; gap: 24px; font-size: 0.8rem; color: var(--text-muted); }
.header-stats span strong { color: var(--text); }
/* ── MAIN ── */
main { max-width: 1100px; margin: 0 auto; padding: 40px 24px 80px; }
/* ── SEARCH ── */
.search-section { margin-bottom: 40px; }
.search-row { display: flex; gap: 12px; align-items: center; }
.search-input-wrap { flex: 1; position: relative; }
.search-input-wrap svg { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; }
#searchInput {
width: 100%; padding: 14px 16px 14px 48px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-family: 'DM Sans', sans-serif; font-size: 1rem;
transition: border-color .2s; outline: none;
}
#searchInput:focus { border-color: var(--accent); }
#searchInput::placeholder { color: var(--text-muted); }
.type-filter { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.type-filter button {
padding: 10px 18px; background: var(--surface); border: none;
color: var(--text-muted); font-family: 'DM Sans', sans-serif; font-size: 0.85rem;
cursor: pointer; transition: background .15s, color .15s;
}
.type-filter button:not(:last-child) { border-right: 1px solid var(--border); }
.type-filter button.active { background: var(--accent); color: #fff; }
/* ── RESULTS ── */
#resultsSection { display: none; }
.results-header { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 20px; }
.results-header strong { color: var(--text); }
.section-label { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; letter-spacing: 1px; margin-bottom: 14px; color: var(--text-muted); }
/* ── MEDIA CARDS (poster layout) ── */
.media-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 36px; }
.media-card {
display: flex;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: border-color .2s, transform .15s, box-shadow .2s;
}
.media-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(229,9,20,.12);
}
/* Poster column */
.card-poster {
width: var(--poster-w);
min-width: var(--poster-w);
height: var(--poster-h);
position: relative;
background: var(--surface2);
flex-shrink: 0;
}
.card-poster img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
.card-poster .no-poster {
width: 100%; height: 100%;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 6px; color: var(--border);
}
.card-poster .no-poster svg { opacity: .5; }
.card-poster .no-poster span { font-size: 0.6rem; opacity: .4; letter-spacing: .5px; }
/* Info column */
.card-info {
flex: 1;
padding: 14px 16px;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.card-title {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.card-year { font-size: 0.78rem; color: var(--text-muted); margin-bottom: 8px; }
.card-meta { display: flex; flex-wrap: wrap; gap: 5px; }
.badge {
font-size: 0.7rem; font-weight: 500;
padding: 2px 7px; border-radius: 4px;
background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border);
}
.badge.accent { background: var(--accent-dim); color: #fff; border-color: var(--accent); }
.badge.nas { background: #1e2a3a; color: #60a5fa; border-color: #2c4060; }
.badge.series { background: #1a2e1a; color: #4ade80; border-color: #2a4a2a; }
/* ── MODAL ── */
#modal {
display: none;
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,.75);
align-items: center; justify-content: center;
padding: 24px;
}
#modal.open { display: flex; }
.modal-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
max-width: 680px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-close {
position: absolute; top: 14px; right: 14px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; color: var(--text-muted);
cursor: pointer; padding: 4px 8px; font-size: 1.1rem; line-height: 1;
transition: color .15s;
}
.modal-close:hover { color: var(--text); }
.modal-hero {
display: flex; gap: 24px; padding: 24px;
border-bottom: 1px solid var(--border);
}
.modal-poster {
width: 140px; min-width: 140px; height: 210px;
border-radius: 8px; overflow: hidden;
background: var(--surface2);
flex-shrink: 0;
}
.modal-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
.modal-poster .no-poster {
width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--border); gap: 8px;
}
.modal-poster .no-poster svg { opacity: .4; }
.modal-info { flex: 1; }
.modal-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; letter-spacing: 1px; margin-bottom: 4px; }
.modal-year { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 14px; }
.modal-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.modal-path { font-size: 0.72rem; color: var(--text-muted); word-break: break-all; margin-top: 10px; }
.modal-size { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
.modal-seasons { padding: 20px 24px; }
.modal-seasons-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; letter-spacing: 1px; color: var(--text-muted); margin-bottom: 12px; }
.seasons-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.season-chip {
background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;
padding: 6px 12px; font-size: 0.78rem; color: var(--text-muted);
}
.season-chip strong { color: var(--text); }
/* ── DASHBOARD ── */
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px;
}
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; text-align: center; }
.stat-card .stat-number { font-family: 'Bebas Neue', sans-serif; font-size: 2.5rem; color: var(--accent); line-height: 1; }
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: .5px; }
.dashboard-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
.panel-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; letter-spacing: 1px; margin-bottom: 16px; color: var(--text-muted); }
.nas-list { display: flex; flex-direction: column; gap: 10px; }
.nas-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: var(--surface2); border-radius: 6px; border: 1px solid var(--border); }
.nas-name { font-weight: 500; font-size: 0.9rem; }
.nas-path { font-size: 0.72rem; color: var(--text-muted); margin-top: 2px; }
.nas-last-scan { font-size: 0.7rem; color: var(--text-muted); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.err { background: var(--red); box-shadow: 0 0 6px var(--red); }
.scan-btns { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-family: 'DM Sans', sans-serif; font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: background .15s, opacity .15s; }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:not(:disabled):hover { background: #c4070f; }
.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:not(:disabled):hover { border-color: var(--accent); color: var(--accent); }
.scan-status-bar { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 16px; display: none; }
.scan-status-bar.visible { display: block; }
.scan-status-bar .status-line { display: flex; justify-content: space-between; margin-bottom: 6px; }
.scan-progress-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.scan-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width .4s; }
.scan-history { display: flex; flex-direction: column; gap: 8px; }
.scan-history-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 12px; background: var(--surface2); border-radius: 6px; font-size: 0.78rem; border: 1px solid var(--border); }
.scan-history-item .sh-left { color: var(--text); }
.scan-history-item .sh-right { color: var(--text-muted); text-align: right; }
.sh-status { display: inline-block; padding: 1px 7px; border-radius: 3px; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; }
.sh-status.completed { background: #14532d; color: #4ade80; }
.sh-status.running { background: #1e3a5f; color: #60a5fa; }
.sh-status.failed { background: #450a0a; color: #f87171; }
#syncFab {
position: fixed; bottom: 28px; right: 28px;
background: var(--accent); color: #fff; border: none; border-radius: 50px;
padding: 12px 22px; font-family: 'DM Sans', sans-serif; font-size: 0.85rem; font-weight: 600;
cursor: pointer; box-shadow: 0 4px 20px rgba(229,9,20,.4);
transition: background .15s, transform .15s; z-index: 50;
}
#syncFab:hover { background: #c4070f; transform: scale(1.04); }
#syncFab.scanning { background: var(--accent-dim); animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.6} }
.empty-state { text-align: center; color: var(--text-muted); padding: 40px 0; font-size: 0.9rem; }
.last-sync { font-size: 0.75rem; color: var(--text-muted); margin-top: 12px; }
@media (max-width: 700px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.dashboard-columns { grid-template-columns: 1fr; }
header { flex-direction: column; gap: 12px; align-items: flex-start; }
.search-row { flex-direction: column; align-items: stretch; }
.modal-hero { flex-direction: column; }
.modal-poster { width: 100%; height: 200px; }
}
</style>
</head>
<body>
<header>
<div class="logo">MediaTracker</div>
<div class="header-stats">
<span><strong id="hMovies"></strong> films</span>
<span><strong id="hSeries"></strong> séries</span>
<span><strong id="hEpisodes"></strong> épisodes</span>
<span class="last-sync" id="hLastSync">Chargement…</span>
</div>
</header>
<main>
<section class="search-section">
<div class="search-row">
<div class="search-input-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Rechercher un film, une série…" autocomplete="off" />
</div>
<div class="type-filter" id="typeFilter">
<button class="active" data-type="all">Tout</button>
<button data-type="movie">Films</button>
<button data-type="series">Séries</button>
</div>
</div>
</section>
<section id="resultsSection">
<div class="results-header" id="resultsHeader"></div>
<div id="resultsContainer"></div>
</section>
<section id="dashboard">
<div class="stats-grid">
<div class="stat-card"><div class="stat-number" id="statMovies"></div><div class="stat-label">Films</div></div>
<div class="stat-card"><div class="stat-number" id="statSeries"></div><div class="stat-label">Séries</div></div>
<div class="stat-card"><div class="stat-number" id="statEpisodes"></div><div class="stat-label">Épisodes</div></div>
<div class="stat-card"><div class="stat-number" id="statNas"></div><div class="stat-label">NAS</div></div>
</div>
<div class="dashboard-columns">
<div class="panel">
<div class="panel-title">Sources NAS</div>
<div class="nas-list" id="nasList"><div class="empty-state">Chargement…</div></div>
</div>
<div class="panel">
<div class="panel-title">Synchronisation</div>
<div class="scan-btns">
<button class="btn btn-secondary" id="btnIncremental" onclick="startScan('incremental')">Scan incrémental</button>
<button class="btn btn-primary" id="btnFull" onclick="confirmFullScan()">Scan complet</button>
</div>
<div class="scan-status-bar" id="scanStatusBar">
<div class="status-line">
<span id="scanStatusText"></span>
<span id="scanStatusType"></span>
</div>
<div class="scan-progress-track"><div class="scan-progress-fill" id="scanProgressFill" style="width:0%"></div></div>
</div>
<div class="panel-title" style="margin-top:20px">Historique</div>
<div class="scan-history" id="scanHistory"><div class="empty-state">Aucun scan effectué</div></div>
</div>
</div>
</section>
</main>
<!-- Modal détail -->
<div id="modal">
<div class="modal-box" id="modalBox">
<button class="modal-close" onclick="closeModal()"></button>
<div class="modal-hero">
<div class="modal-poster" id="modalPoster"></div>
<div class="modal-info">
<div class="modal-title" id="modalTitle"></div>
<div class="modal-year" id="modalYear"></div>
<div class="modal-badges" id="modalBadges"></div>
<div class="modal-path" id="modalPath"></div>
<div class="modal-size" id="modalSize"></div>
</div>
</div>
<div class="modal-seasons" id="modalSeasons" style="display:none">
<div class="modal-seasons-title">Saisons</div>
<div class="seasons-grid" id="modalSeasonsGrid"></div>
</div>
</div>
</div>
<button id="syncFab" onclick="startScan('incremental')" title="Scan incrémental rapide">Synchroniser</button>
<script>
'use strict';
let searchType = 'all';
let debounceTimer = null;
let pollInterval = null;
// ─── Init ────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadNas();
loadScanHistory();
checkActiveScans();
document.getElementById('searchInput').addEventListener('input', onSearchInput);
document.querySelectorAll('#typeFilter button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#typeFilter button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
searchType = btn.dataset.type;
const q = document.getElementById('searchInput').value.trim();
if (q.length > 0) performSearch(q);
});
});
// Close modal on backdrop click
document.getElementById('modal').addEventListener('click', e => {
if (e.target === document.getElementById('modal')) closeModal();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
});
// ─── Helpers ─────────────────────────────
async function api(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function apiPost(path) {
const res = await fetch(path, { method: 'POST' });
if (!res.ok) { const b = await res.json().catch(()=>({})); throw new Error(b.detail || `HTTP ${res.status}`); }
return res.json();
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}) + ' ' +
d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
}
function fmtSize(bytes) {
if (!bytes) return '';
const gb = bytes / 1e9;
return gb >= 1 ? gb.toFixed(1) + ' Go' : (bytes/1e6).toFixed(0) + ' Mo';
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Poster helpers ───────────────────────
const POSTER_PLACEHOLDER = `
<div class="no-poster">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="3"/>
<path d="m2 12 5-5 4 4 3-3 8 8"/>
<circle cx="8" cy="7" r="1.5"/>
</svg>
<span>SANS JAQUETTE</span>
</div>`;
function resolvePostUrl(raw) {
if (!raw) return null;
if (raw.startsWith('local:')) return '/api/local-poster?path=' + encodeURIComponent(raw.slice(6));
return raw;
}
function showPlaceholder(el) {
el.innerHTML = POSTER_PLACEHOLDER;
}
function posterHtml(url, cls='card-poster') {
const src = resolvePostUrl(url);
if (src) {
return `<div class="${cls}"><img src="${escHtml(src)}" alt="" loading="lazy" onerror="showPlaceholder(this.parentElement)" /></div>`;
}
return `<div class="${cls}">${POSTER_PLACEHOLDER}</div>`;
}
// ─── Stats ───────────────────────────────
async function loadStats() {
try {
const s = await api('/api/stats');
document.getElementById('statMovies').textContent = s.movies.toLocaleString('fr');
document.getElementById('statSeries').textContent = s.series.toLocaleString('fr');
document.getElementById('statEpisodes').textContent = s.episodes.toLocaleString('fr');
document.getElementById('statNas').textContent = s.nas_count;
document.getElementById('hMovies').textContent = s.movies.toLocaleString('fr');
document.getElementById('hSeries').textContent = s.series.toLocaleString('fr');
document.getElementById('hEpisodes').textContent = s.episodes.toLocaleString('fr');
document.getElementById('hLastSync').textContent = s.last_scan ? 'Sync ' + fmtDate(s.last_scan) : 'Jamais synchronisé';
} catch(e) { console.error('Stats error:', e); }
}
// ─── NAS ─────────────────────────────────
async function loadNas() {
try {
const list = await api('/api/nas');
const el = document.getElementById('nasList');
if (!list.length) { el.innerHTML = '<div class="empty-state">Aucun NAS configuré</div>'; return; }
el.innerHTML = list.map(n => `
<div class="nas-item">
<div>
<div class="nas-name">${escHtml(n.name)}</div>
<div class="nas-path">${escHtml(n.path)}</div>
<div class="nas-last-scan">Dernier scan : ${fmtDate(n.last_scan)}</div>
</div>
<div class="status-dot ${n.accessible?'ok':'err'}" title="${n.accessible?'Accessible':'Inaccessible'}"></div>
</div>`).join('');
} catch(e) { document.getElementById('nasList').innerHTML='<div class="empty-state">Erreur de chargement</div>'; }
}
// ─── Search ──────────────────────────────
function onSearchInput(e) {
const q = e.target.value.trim();
clearTimeout(debounceTimer);
if (q.length === 0) { showDashboard(); return; }
debounceTimer = setTimeout(() => performSearch(q), 300);
}
async function performSearch(q) {
try {
const data = await api(`/api/search?q=${encodeURIComponent(q)}&type=${searchType}`);
renderResults(data, q);
} catch(e) {
document.getElementById('resultsContainer').innerHTML =
`<div class="empty-state">Erreur lors de la recherche : ${escHtml(e.message)}</div>`;
}
}
function renderResults(data, q) {
document.getElementById('dashboard').style.display = 'none';
document.getElementById('resultsSection').style.display = 'block';
document.getElementById('resultsHeader').innerHTML =
`<strong>${data.total}</strong> résultat${data.total!==1?'s':''} pour « ${escHtml(q)} »`;
let html = '';
if (data.results.movies.length) {
html += `<div class="section-label">Films (${data.results.movies.length})</div><div class="media-list">`;
html += data.results.movies.map((m,i) => `
<div class="media-card" onclick="openMovieModal(${i})" data-idx="${i}">
${posterHtml(m.poster_url)}
<div class="card-info">
<div class="card-title" title="${escHtml(m.title)}">${escHtml(m.title)}</div>
${m.year ? `<div class="card-year">${m.year}</div>` : '<div class="card-year">Année inconnue</div>'}
<div class="card-meta">
${m.quality ? `<span class="badge accent">${escHtml(m.quality)}</span>` : ''}
${m.codec ? `<span class="badge">${escHtml(m.codec)}</span>` : ''}
${m.nas_name? `<span class="badge nas">${escHtml(m.nas_name)}</span>` : ''}
${m.file_size ? `<span class="badge">${fmtSize(m.file_size)}</span>` : ''}
</div>
</div>
</div>`).join('');
html += '</div>';
}
if (data.results.series.length) {
html += `<div class="section-label">Séries (${data.results.series.length})</div><div class="media-list">`;
html += data.results.series.map((s,i) => `
<div class="media-card" onclick="openSeriesModal(${i})">
${posterHtml(s.poster_url)}
<div class="card-info">
<div class="card-title" title="${escHtml(s.title)}">${escHtml(s.title)}</div>
${s.year ? `<div class="card-year">${s.year}</div>` : '<div class="card-year">Année inconnue</div>'}
<div class="card-meta">
<span class="badge series">${s.seasons_count} saison${s.seasons_count!==1?'s':''}</span>
${s.seasons.map(sn=>`<span class="badge">S${String(sn.season_number).padStart(2,'0')} · ${sn.episodes_count} ép.</span>`).join('')}
</div>
</div>
</div>`).join('');
html += '</div>';
}
if (!html) html = '<div class="empty-state">Aucun résultat trouvé</div>';
document.getElementById('resultsContainer').innerHTML = html;
// Store results for modal access
window._lastMovies = data.results.movies;
window._lastSeries = data.results.series;
}
function showDashboard() {
document.getElementById('resultsSection').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
}
// ─── Modal ───────────────────────────────
function openMovieModal(idx) {
const m = window._lastMovies[idx];
if (!m) return;
const mSrc = resolvePostUrl(m.poster_url);
document.getElementById('modalPoster').innerHTML = mSrc
? `<img src="${escHtml(mSrc)}" alt="" onerror="showPlaceholder(this.parentElement)" />`
: POSTER_PLACEHOLDER;
document.getElementById('modalTitle').textContent = m.title;
document.getElementById('modalYear').textContent = m.year || 'Année inconnue';
document.getElementById('modalPath').textContent = m.file_path;
document.getElementById('modalSize').textContent = m.file_size ? fmtSize(m.file_size) : '';
const badges = [
m.quality ? `<span class="badge accent">${escHtml(m.quality)}</span>` : '',
m.codec ? `<span class="badge">${escHtml(m.codec)}</span>` : '',
m.nas_name ? `<span class="badge nas">${escHtml(m.nas_name)}</span>` : '',
].filter(Boolean).join('');
document.getElementById('modalBadges').innerHTML = badges;
document.getElementById('modalSeasons').style.display = 'none';
document.getElementById('modal').classList.add('open');
document.body.style.overflow = 'hidden';
}
function openSeriesModal(idx) {
const s = window._lastSeries[idx];
if (!s) return;
const sSrc = resolvePostUrl(s.poster_url);
document.getElementById('modalPoster').innerHTML = sSrc
? `<img src="${escHtml(sSrc)}" alt="" onerror="showPlaceholder(this.parentElement)" />`
: POSTER_PLACEHOLDER;
document.getElementById('modalTitle').textContent = s.title;
document.getElementById('modalYear').textContent = s.year || 'Année inconnue';
document.getElementById('modalPath').textContent = '';
document.getElementById('modalSize').textContent = '';
const badges = [
`<span class="badge series">${s.seasons_count} saison${s.seasons_count!==1?'s':''}</span>`,
].join('');
document.getElementById('modalBadges').innerHTML = badges;
// Seasons grid
const grid = document.getElementById('modalSeasonsGrid');
grid.innerHTML = s.seasons.map(sn => `
<div class="season-chip">
<strong>Saison ${sn.season_number}</strong> — ${sn.episodes_count} épisode${sn.episodes_count!==1?'s':''}
<span style="color:#60a5fa;font-size:.7rem;margin-left:4px">${escHtml(sn.nas_name||'')}</span>
</div>`).join('');
document.getElementById('modalSeasons').style.display = 'block';
document.getElementById('modal').classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
document.body.style.overflow = '';
}
// ─── Scan ────────────────────────────────
async function confirmFullScan() {
if (!confirm('Lancer un scan complet ?\nCette opération peut prendre plusieurs minutes selon la taille de vos NAS.\n\nNote : les jaquettes seront récupérées depuis TMDB pour chaque nouveau fichier.')) return;
startScan('full');
}
async function startScan(type) {
const btnFull = document.getElementById('btnFull');
const btnInc = document.getElementById('btnIncremental');
const fab = document.getElementById('syncFab');
try {
btnFull.disabled = btnInc.disabled = true;
fab.classList.add('scanning');
const data = await apiPost(`/api/scan/${type}`);
showScanProgress({ status: 'running', type });
startPolling(data.scan_id);
} catch(e) {
alert(`Erreur : ${e.message}`);
btnFull.disabled = btnInc.disabled = false;
fab.classList.remove('scanning');
}
}
function showScanProgress(data) {
const bar = document.getElementById('scanStatusBar');
bar.classList.add('visible');
const statusLabels = { running: 'En cours…', completed: 'Terminé', failed: 'Échoué' };
const typeLabels = { full: 'Scan complet', incremental: 'Scan incrémental' };
document.getElementById('scanStatusText').textContent = statusLabels[data.status] || data.status;
document.getElementById('scanStatusType').textContent = typeLabels[data.scan_type || data.type] || '';
document.getElementById('scanProgressFill').style.width = data.status === 'running' ? '60%' : '100%';
}
function startPolling(scanId) {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const data = await api(`/api/scan/status/${scanId}`);
showScanProgress(data);
if (data.status === 'completed' || data.status === 'failed') {
clearInterval(pollInterval); pollInterval = null;
document.getElementById('btnFull').disabled = document.getElementById('btnIncremental').disabled = false;
document.getElementById('syncFab').classList.remove('scanning');
loadStats(); loadNas(); loadScanHistory();
if (data.status === 'failed') alert(`Le scan a échoué : ${data.error_message || 'erreur inconnue'}`);
}
} catch(e) { console.error('Poll error:', e); }
}, 2000);
}
async function checkActiveScans() {
try {
const history = await api('/api/scan/history');
const running = history.find(s => s.status === 'running');
if (running) {
document.getElementById('btnFull').disabled = document.getElementById('btnIncremental').disabled = true;
document.getElementById('syncFab').classList.add('scanning');
showScanProgress(running);
startPolling(running.scan_id);
}
} catch(e) { console.error(e); }
}
async function loadScanHistory() {
try {
const history = await api('/api/scan/history');
const el = document.getElementById('scanHistory');
if (!history.length) { el.innerHTML = '<div class="empty-state">Aucun scan effectué</div>'; return; }
el.innerHTML = history.slice(0,5).map(h => `
<div class="scan-history-item">
<div class="sh-left">
<span class="sh-status ${h.status}">${h.status==='completed'?'OK':h.status==='running'?'…':'KO'}</span>
${h.scan_type==='full'?'Complet':'Incrémental'} — +${h.files_added} ajouté${h.files_added!==1?'s':''}
</div>
<div class="sh-right">${fmtDate(h.started_at)}</div>
</div>`).join('');
} catch(e) { console.error(e); }
}
</script>
</body>
</html>