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:
711
frontend/index.html
Normal file
711
frontend/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
Reference in New Issue
Block a user