Added button to generate thumbs, compose variables and fixed auth req.

This commit is contained in:
2025-09-09 10:51:56 +02:00
parent 3ceb5d41ea
commit 4655f9bc67
5 changed files with 547 additions and 221 deletions

View File

@@ -1,20 +1,19 @@
import os
from fastapi import Depends, HTTPException, status
from fastapi import HTTPException, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from secrets import compare_digest
USER = os.environ.get("OPDS_BASIC_USER")
PASS = os.environ.get("OPDS_BASIC_PASS")
security = HTTPBasic()
def require_basic(creds: HTTPBasicCredentials = Depends(security)):
if not USER or not PASS:
return # auth disabled
if compare_digest(creds.username, USER) and compare_digest(creds.password, PASS):
DISABLE_AUTH = os.getenv("DISABLE_AUTH", "false").strip().lower() in ("1","true","yes")
USER = os.getenv("OPDS_BASIC_USER", "").strip()
PASS = os.getenv("OPDS_BASIC_PASS", "").strip()
def require_basic(request: Request, credentials: HTTPBasicCredentials = None):
# If disabled, or no credentials configured at all, allow through
if DISABLE_AUTH or not USER or not PASS:
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized",
headers={"WWW-Authenticate": "Basic"},
)
if credentials is None:
credentials = security(request)
if not (credentials.username == USER and credentials.password == PASS):
raise HTTPException(status_code=401, detail="Not authenticated",
headers={"WWW-Authenticate": "Basic"})

View File

@@ -19,3 +19,6 @@ if URL_PREFIX == "/":
URL_PREFIX = ""
ENABLE_WATCH = _env_bool("ENABLE_WATCH", True)
PRECACHE_THUMBS = os.getenv("PRECACHE_THUMBS", "false").strip().lower() not in ("0","false","no","off")
THUMB_WORKERS = max(1, int(os.getenv("THUMB_WORKERS", "2")))
PRECACHE_ON_START = os.getenv("PRECACHE_ON_START", "false").strip().lower() in ("1","true","yes")

View File

@@ -7,6 +7,7 @@ from fastapi.responses import (
from pathlib import Path
from typing import List, Dict, Any, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import quote
import threading
import time
@@ -20,7 +21,7 @@ import sys
import logging
from math import ceil
from .config import LIBRARY_DIR, PAGE_SIZE, SERVER_BASE, URL_PREFIX
from .config import LIBRARY_DIR, PAGE_SIZE, SERVER_BASE, URL_PREFIX, PRECACHE_THUMBS, THUMB_WORKERS, PRECACHE_ON_START
from .opds import now_rfc3339, mime_for
from .auth import require_basic
from .thumbs import have_thumb, generate_thumb
@@ -28,6 +29,7 @@ from . import db # SQLite adapter
# -------------------- Logging --------------------
LOG_LEVEL = os.getenv("LOG_LEVEL", "ERROR").upper()
ERROR_LOG_PATH = Path("/data/thumbs_errors.log")
app_logger = logging.getLogger("comicopds")
app_logger.setLevel(LOG_LEVEL)
_handler = logging.StreamHandler(sys.stdout)
@@ -70,6 +72,17 @@ async def log_requests(request: Request, call_next):
pass
return resp
# -------------------- Thumbnail state (background) ----------------
_THUMB_STATUS = {
"running": False,
"total": 0,
"done": 0,
"started_at": 0.0,
"ended_at": 0.0,
}
_THUMB_LOCK = threading.Lock()
# -------------------- Index state (background) --------------------
_INDEX_STATUS = {
"running": False,
@@ -186,6 +199,12 @@ def _run_scan():
_index_progress(rel)
db.prune_stale(conn)
# after scanning and pruning
if PRECACHE_THUMBS:
_set_status(phase="thumbnails")
_run_precache_thumbs(THUMB_WORKERS)
_set_status(phase="idle", running=False, ended_at=time.time(), current="")
except Exception as e:
app_logger.error(f"scan error: {e}")
@@ -196,6 +215,55 @@ def _run_scan():
except Exception:
pass
def _collect_cbz_rows() -> list[dict]:
"""Fetch all file rows (is_dir=0, ext='cbz') with comicvineissue."""
conn = db.connect()
try:
rows = conn.execute("""
SELECT i.rel, i.ext, m.comicvineissue
FROM items i
LEFT JOIN meta m ON m.rel = i.rel
WHERE i.is_dir=0 AND LOWER(i.ext)='cbz'
""").fetchall()
return [{"rel": r["rel"], "cvid": r["comicvineissue"]} for r in rows]
finally:
conn.close()
def _thumb_task(rel: str, cvid: str | None):
try:
ensure = generate_thumb # well call with abs path to avoid second stat
abs_cbz = (LIBRARY_DIR / rel)
if abs_cbz.exists():
ensure(rel, abs_cbz, cvid)
except Exception:
pass
finally:
with _THUMB_LOCK:
_THUMB_STATUS["done"] += 1
def _run_precache_thumbs(workers: int):
with _THUMB_LOCK:
_THUMB_STATUS.update({"running": True, "total": 0, "done": 0, "started_at": time.time(), "ended_at": 0.0})
items = _collect_cbz_rows()
total = len(items)
with _THUMB_LOCK:
_THUMB_STATUS["total"] = total
if total == 0:
with _THUMB_LOCK:
_THUMB_STATUS.update({"running": False, "ended_at": time.time()})
return
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
futures = [pool.submit(_thumb_task, it["rel"], it["cvid"]) for it in items]
for _ in as_completed(futures):
pass
with _THUMB_LOCK:
_THUMB_STATUS.update({"running": False, "ended_at": time.time()})
def _start_scan(force=False):
if not force and _INDEX_STATUS["running"]:
return
@@ -223,6 +291,10 @@ def startup():
if AUTO_INDEX_ON_START:
_start_scan(force=True)
return
# Run thumbnails pre-cache at startup even if no scan runs
if PRECACHE_ON_START and not _INDEX_STATUS["running"] and not _THUMB_STATUS["running"]:
t = threading.Thread(target=_run_precache_thumbs, args=(THUMB_WORKERS,), daemon=True)
t.start()
conn = db.connect()
try:
@@ -814,3 +886,48 @@ def index_status(_=Depends(require_basic)):
def admin_reindex(_=Depends(require_basic)):
_start_scan(force=True)
return JSONResponse({"ok": True, "started": True})
@app.post("/admin/thumbs/precache", response_class=JSONResponse)
def admin_thumbs_precache(_=Depends(require_basic)):
if _THUMB_STATUS["running"]:
return JSONResponse({"ok": True, "started": False, "reason": "already running"})
t = threading.Thread(target=_run_precache_thumbs, args=(THUMB_WORKERS,), daemon=True)
t.start()
return JSONResponse({"ok": True, "started": True})
@app.get("/thumbs/status", response_class=JSONResponse)
def thumbs_status(_=Depends(require_basic)):
return JSONResponse(_THUMB_STATUS)
# -------------------- Thumbs Errors --------------------
@app.get("/thumbs/errors/count", response_class=JSONResponse)
def thumbs_errors_count(_=Depends(require_basic)):
n = 0
size = 0
mtime = 0.0
if ERROR_LOG_PATH.exists():
try:
# Fast line count
with ERROR_LOG_PATH.open("rb") as f:
n = sum(1 for _ in f)
st = ERROR_LOG_PATH.stat()
size = st.st_size
mtime = st.st_mtime
except Exception:
pass
return {"lines": n, "size_bytes": size, "modified": mtime}
@app.get("/thumbs/errors/log")
def thumbs_errors_log(_=Depends(require_basic)):
if not ERROR_LOG_PATH.exists():
# return an empty text file to keep the link working
return PlainTextResponse("", media_type="text/plain", headers={
"Content-Disposition": "attachment; filename=thumbs_errors.log"
})
return FileResponse(
path=str(ERROR_LOG_PATH),
media_type="text/plain",
filename="thumbs_errors.log",
headers={"Cache-Control": "no-store"}
)

View File

@@ -1,212 +1,318 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>ComicOPDS — Dashboard</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ComicOPDS — Library Dashboard</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons (optional) -->
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
body { background: #f8f9fa; }
.stat-card { min-height: 120px; }
.chart-wrap { position: relative; min-height: 300px; }
.empty-overlay {
position:absolute; inset:0;
display:flex; align-items:center; justify-content:center;
font-size:0.95rem; color:#777; pointer-events:none;
}
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
body { background: var(--bs-body-bg); }
.metric { display:flex; align-items:center; gap:.75rem; }
.metric .bi { font-size:1.6rem; opacity:.8; }
.metric .value { font-size:1.9rem; font-weight:700; line-height:1; }
.metric .label { color: var(--bs-secondary-color); font-size:.85rem; }
.chart-card canvas { width:100% !important; height:360px !important; }
.footer-note { color: var(--bs-secondary-color); }
.kpis .card { transition: transform .15s ease; }
.kpis .card:hover { transform: translateY(-2px); }
</style>
</head>
<body>
<div class="container py-4">
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
<div class="container">
<a class="navbar-brand fw-semibold" href="#"><i class="bi bi-book-half me-2"></i>ComicOPDS</a>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0"><i class="bi bi-bar-chart"></i> ComicOPDS Dashboard</h1>
<div>
<button id="btn-refresh" class="btn btn-info btn-sm me-2"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
<button id="btn-reindex" class="btn btn-warning btn-sm"><i class="bi bi-arrow-repeat"></i> Reindex</button>
<div class="ms-auto d-flex align-items-center gap-2">
<span class="navbar-text small text-secondary me-2">
<span id="lastUpdated"></span> • Covers: <span id="covers"></span>
<!-- NEW: errors badge + download link -->
• Errors: <a id="errLink" href="/thumbs/errors/log" class="link-danger text-decoration-none"><span id="errCount">0</span></a>
</span>
<button id="thumbsBtn" class="btn btn-sm btn-outline-primary">
<i class="bi bi-images me-1"></i> Pre-cache Thumbnails
</button>
<button id="reindexBtn" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-repeat me-1"></i> Reindex
</button>
</div>
</div>
</div>
</nav>
<!-- Indexing status -->
<div id="indexBox" class="alert alert-info py-2" style="display:none;">
<strong>Indexing:</strong>
<span id="idx-phase"></span>
<span id="idx-counts">0 / 0</span>
<div class="progress mt-2" style="height: 8px;">
<div id="idx-progress" class="progress-bar bg-info" role="progressbar" style="width:0%">0%</div>
<main class="container my-4">
<!-- Indexing progress -->
<div id="indexProgress" class="alert alert-secondary d-none" role="alert">
<div class="d-flex justify-content-between">
<div>
<strong>Indexing your library…</strong>
<div class="small text-secondary">
<span id="idxPhase">indexing</span>
<span id="idxCounts" class="ms-2">(0 / 0)</span>
</div>
<div class="small text-secondary" id="idxCurrent"></div>
</div>
<div class="text-end">
<span class="badge text-bg-light" id="idxEta"></span>
</div>
</div>
<div class="progress mt-2">
<div id="idxBar" class="progress-bar progress-bar-striped progress-bar-animated" style="width:0%">0%</div>
</div>
</div>
</div>
<!-- Stats cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card stat-card text-center">
<div class="card-body">
<div class="fw-bold text-muted">Total comics</div>
<div id="stat-total" class="display-6"></div>
<!-- Thumbnails pre-cache progress -->
<div id="thumbsProgress" class="alert alert-primary d-none" role="alert">
<div class="d-flex justify-content-between">
<div>
<strong>Generating thumbnails…</strong>
<div class="small text-secondary">
<span id="thCounts">(0 / 0)</span>
</div>
</div>
<div class="text-end">
<span class="badge text-bg-light" id="thEta"></span>
</div>
</div>
<div class="progress mt-2">
<div id="thBar" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" style="width:0%">0%</div>
</div>
</div>
<!-- KPIs -->
<div class="row g-3 kpis">
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-collection"></i>
<div><div class="value" id="totalComics">0</div><div class="label">Total comics</div></div>
</div>
</div></div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-layers"></i>
<div><div class="value" id="uniqueSeries">0</div><div class="label">Unique series</div></div>
</div>
</div></div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-building"></i>
<div><div class="value" id="uniquePublishers">0</div><div class="label">Publishers</div></div>
</div>
</div></div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100"><div class="card-body">
<div class="metric"><i class="bi bi-filetype-zip"></i>
<div><div class="value" id="formats"></div><div class="label">Formats</div></div>
</div>
</div></div>
</div>
</div>
<!-- Charts -->
<div class="row g-3 mt-1">
<div class="col-12 col-lg-6">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Publishers distribution</div>
<div class="card-body"><canvas id="publishersChart"></canvas></div>
<div class="card-footer small footer-note">Top publishers (others grouped).</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Publication timeline</div>
<div class="card-body"><canvas id="timelineChart"></canvas></div>
<div class="card-footer small footer-note">Issues per year.</div>
</div>
</div>
<div class="col-12 col-lg-6" id="formatsCardCol">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Formats breakdown</div>
<div class="card-body"><canvas id="formatsChart"></canvas></div>
<div class="card-footer small footer-note">Counts by file extension (e.g., CBZ).</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100 chart-card">
<div class="card-header fw-semibold">Top writers</div>
<div class="card-body"><canvas id="writersChart"></canvas></div>
<div class="card-footer small footer-note">Top 15 writers across your library.</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card text-center">
<div class="card-body">
<div class="fw-bold text-muted">Unique series</div>
<div id="stat-series" class="display-6"></div>
</div>
</div>
</main>
<footer class="container my-4 small footer-note">
<div class="d-flex justify-content-between">
<span>ComicOPDS Dashboard</span>
<span><a href="/opds" class="link-secondary text-decoration-none"><i class="bi bi-rss me-1"></i>OPDS feed</a></span>
</div>
<div class="col-md-3">
<div class="card stat-card text-center">
<div class="card-body">
<div class="fw-bold text-muted">Publishers</div>
<div id="stat-publishers" class="display-6"></div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card text-center">
<div class="card-body">
<div class="fw-bold text-muted">Last updated</div>
<div id="stat-updated" class="h6 font-mono"></div>
</div>
</div>
</div>
</div>
</footer>
<!-- Charts -->
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Publishers distribution</div>
<div class="card-body chart-wrap">
<canvas id="publishersChart"></canvas>
<div id="pub-empty" class="empty-overlay">No data</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Publication timeline</div>
<div class="card-body chart-wrap">
<canvas id="timelineChart"></canvas>
<div id="time-empty" class="empty-overlay">No data</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const baseOptions = {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels:{usePointStyle:true, boxWidth:8} }, tooltip:{mode:'index', intersect:false} },
interaction:{ mode:'nearest', axis:'x', intersect:false },
scales:{ x:{ ticks:{ maxRotation:0, autoSkip:true } }, y:{ beginAtZero:true, ticks:{ precision:0 } } }
};
const charts = {};
function upsertChart(id, cfg){ const e=Chart.getChart(id)||charts[id]; if(e) e.destroy(); const el=document.getElementById(id); if(!el) return null; return charts[id]=new Chart(el, cfg); }
async function jget(u){ const r=await fetch(u,{credentials:'include'}); if(!r.ok) throw Error(r.status); return r.json(); }
<div class="row g-3 mb-3">
<div class="col-md-12">
<div class="card">
<div class="card-header fw-semibold">Top writers</div>
<div class="card-body chart-wrap" style="min-height:380px;">
<canvas id="writersChart"></canvas>
<div id="writers-empty" class="empty-overlay">No data</div>
</div>
</div>
</div>
</div>
function mapPublishers(d){ const a=Array.isArray(d.top_publishers)?d.top_publishers:[]; return {labels:a.map(x=>x.publisher??'(Unknown)'),values:a.map(x=>x.count??x.c??0)}; }
function mapTimeline(d){ const l=(d.timeline_by_year||[]).filter(x=>x.year!=null).sort((a,b)=>(+a.year)-(+b.year)); return {labels:l.map(x=>String(x.year)),values:l.map(x=>x.count??0)}; }
function mapWriters(d){ const a=Array.isArray(d.top_writers)?d.top_writers:[]; return {labels:a.map(x=>x.writer??'(Unknown)'),values:a.map(x=>x.count??0)}; }
function mapFormats(d){ const fmt=d.formats&&typeof d.formats==='object'?d.formats:null; if(fmt&&Object.keys(fmt).length){return{labelText:Object.entries(fmt).map(([k,v])=>`${k.toUpperCase()}: ${v}`).join(' '),labels:Object.keys(fmt),values:Object.values(fmt)}} const tot=d.total_comics??0; return{labelText:`CBZ: ${tot}`,labels:tot?['CBZ']:[],values:tot?[tot]:[]}; }
<p class="text-muted small">Tip: Use <em>Reindex</em> after adding comics. Thumbnails are generated lazily unless pre-cache is enabled.</p>
</div>
async function load(){
const d = await jget("/stats.json");
document.getElementById("lastUpdated").textContent = d.last_updated? new Date(d.last_updated*1000).toLocaleString() : "—";
document.getElementById("covers").textContent = d.total_covers ?? "0";
document.getElementById("totalComics").textContent = d.total_comics ?? "0";
document.getElementById("uniqueSeries").textContent = d.unique_series ?? "0";
document.getElementById("uniquePublishers").textContent = d.publishers ?? "0";
<script>
(function(){
let charts = { publishers:null, timeline:null, writers:null };
const fmt = mapFormats(d);
document.getElementById("formats").textContent = fmt.labelText || "—";
const hasFormats = (fmt.values.reduce((a,b)=>a+b,0) > 0);
document.getElementById("formatsCardCol").style.display = hasFormats ? "" : "none";
function fmtInt(n){ return (n||n===0)? n.toLocaleString() : "—"; }
function fmtDateEpoch(sec){
if (!sec) return "—";
try { return new Date(sec*1000).toISOString().slice(0,19).replace('T',' '); }
catch { return "—"; }
}
const pubs = mapPublishers(d);
const zipped = pubs.labels.map((l,i)=>({l, v: pubs.values[i]||0})).sort((a,b)=>b.v-a.v);
upsertChart("publishersChart",{ type:"doughnut", data:{labels:zipped.map(x=>x.l), datasets:[{data:zipped.map(x=>x.v)}]}, options:{...baseOptions, cutout:"60%", scales:{}} });
async function jget(u){ const r=await fetch(u); if(!r.ok) throw Error(r.status); return r.json(); }
const tl = mapTimeline(d);
upsertChart("timelineChart",{ type:"line", data:{labels:tl.labels, datasets:[{ label:"Issues per year", data:tl.values, fill:true, tension:0.25, pointRadius:2 }]}, options:{...baseOptions} });
async function loadStats(){
const d = await jget("/stats.json");
document.getElementById("stat-total").textContent = fmtInt(d.total_comics);
document.getElementById("stat-series").textContent = fmtInt(d.unique_series);
document.getElementById("stat-publishers").textContent = fmtInt(d.publishers);
document.getElementById("stat-updated").textContent = fmtDateEpoch(d.last_updated);
if (hasFormats){
upsertChart("formatsChart",{ type:"bar", data:{labels:fmt.labels, datasets:[{ label:"Files", data:fmt.values }]}, options:{...baseOptions} });
} else {
const ex = Chart.getChart("formatsChart"); if (ex) ex.destroy();
}
// Publishers
const pubs = d.top_publishers||[];
toggleEmpty("pub-empty", pubs.length===0);
if(charts.publishers) charts.publishers.destroy();
if(pubs.length){
charts.publishers = new Chart(document.getElementById("publishersChart"), {
type:"doughnut",
data:{ labels: pubs.map(x=>x.publisher), datasets:[{ data: pubs.map(x=>x.count) }]},
options:{ responsive:true, plugins:{ legend:{position:"bottom"}}, maintainAspectRatio:false }
});
const tw = mapWriters(d);
upsertChart("writersChart",{ type:"bar", data:{labels:tw.labels, datasets:[{ label:"Issues", data:tw.values }]}, options:{...baseOptions, indexAxis:'y'} });
}
// Timeline
const tl = (d.timeline_by_year||[]).filter(x=>x.year).sort((a,b)=>a.year-b.year);
toggleEmpty("time-empty", tl.length===0);
if(charts.timeline) charts.timeline.destroy();
if(tl.length){
charts.timeline = new Chart(document.getElementById("timelineChart"), {
type:"line",
data:{ labels: tl.map(x=>x.year), datasets:[{ data: tl.map(x=>x.count), tension:0.2 }]},
options:{ responsive:true, plugins:{legend:{display:false}}, maintainAspectRatio:false }
});
// ----- Index progress -----
let lastIdx = null;
function showIndexUI(s){
const box = document.getElementById("indexProgress");
if (s.running || (!s.usable && s.phase!=='idle')){
box.classList.remove("d-none");
const done=s.done||0, total=Math.max(s.total||0,1), pct=Math.min(100, Math.floor(done*100/total));
document.getElementById("idxPhase").textContent = s.phase||"indexing";
document.getElementById("idxCounts").textContent = `(${done} / ${s.total||0})`;
document.getElementById("idxCurrent").textContent = s.current || "";
const bar = document.getElementById("idxBar");
bar.style.width = pct+"%"; bar.textContent = pct+"%";
let eta="—";
if (s.started_at && done>5 && (s.total>done)){
const elapsed=(Date.now()/1000 - s.started_at), per=elapsed/done, secs=Math.round(per*(s.total-done));
eta=`~${Math.floor(secs/60)}m ${secs%60}s left`;
}
document.getElementById("idxEta").textContent = eta;
} else {
box.classList.add("d-none");
}
}
function shouldReloadCharts(newS, oldS){
if (!newS) return false;
if (!oldS) return (!newS.running && newS.usable);
const finished = oldS.running && !newS.running;
const endedChanged = newS.ended_at && newS.ended_at !== oldS.ended_at;
const becameUsable = !oldS.usable && newS.usable;
return finished || endedChanged || becameUsable;
}
async function pollIndex(){
let delay=5000;
try{
const s = await jget("/index/status");
showIndexUI(s);
if (shouldReloadCharts(s, lastIdx)) await load();
delay = s.running ? 800 : 5000;
lastIdx = s;
}catch{ delay=5000; }
setTimeout(pollIndex, delay);
}
// Writers
const wr = d.top_writers||[];
toggleEmpty("writers-empty", wr.length===0);
if(charts.writers) charts.writers.destroy();
if(wr.length){
charts.writers = new Chart(document.getElementById("writersChart"), {
type:"bar",
data:{ labels: wr.map(x=>x.writer), datasets:[{ data: wr.map(x=>x.count)}]},
options:{ indexAxis:"y", responsive:true, plugins:{legend:{display:false}}, maintainAspectRatio:false }
});
// ----- Thumbnails pre-cache progress -----
async function pollThumbs(){
let delay=5000;
try{
const t = await jget("/thumbs/status");
// Progress UI
const box = document.getElementById("thumbsProgress");
if (t && t.running){
box.classList.remove("d-none");
const done=t.done||0, total=Math.max(t.total||0,1), pct=Math.min(100, Math.floor(done*100/total));
document.getElementById("thCounts").textContent = `(${done} / ${t.total||0})`;
const bar=document.getElementById("thBar");
bar.style.width = pct+"%"; bar.textContent = pct+"%";
let eta="—";
if (t.started_at && done>5 && (t.total>done)){
const elapsed=(Date.now()/1000 - t.started_at), per=elapsed/done, secs=Math.round(per*(t.total-done));
eta=`~${Math.floor(secs/60)}m ${secs%60}s left`;
}
document.getElementById("thEta").textContent = eta;
delay = 800;
} else {
box.classList.add("d-none");
delay = 5000;
}
}catch{ delay=5000; }
setTimeout(pollThumbs, delay);
}
}
function toggleEmpty(id, show){ document.getElementById(id).style.display = show? "flex":"none"; }
// ----- Errors counter -----
async function pollErrors(){
let delay=8000;
try{
const e = await jget("/thumbs/errors/count");
const n = e?.lines || 0;
const el = document.getElementById("errCount");
el.textContent = n;
el.parentElement.classList.toggle("link-danger", n>0);
delay = n>0 ? 5000 : 15000;
}catch{ delay=15000; }
setTimeout(pollErrors, delay);
}
async function fetchIndexStatus(){
try{
const s=await jget("/index/status");
const box=document.getElementById("indexBox");
if(!s||!s.running){ box.style.display="none"; return; }
box.style.display="";
document.getElementById("idx-phase").textContent=s.phase||"running";
document.getElementById("idx-counts").textContent=`${s.done||0} / ${s.total||0}`;
const pct=(s.total>0)? Math.floor((s.done/s.total)*100):0;
const bar=document.getElementById("idx-progress");
bar.style.width=pct+"%"; bar.textContent=pct+"%";
}catch{}
}
async function triggerThumbs(){
const btn = document.getElementById("thumbsBtn");
const html = btn.innerHTML;
try{
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> Starting…';
await fetch("/admin/thumbs/precache", { method:"POST", credentials:"include" });
}catch(e){
alert("Failed to start thumbnails pre-cache: " + (e?.message || e));
}finally{
setTimeout(()=>{ btn.disabled=false; btn.innerHTML=html; }, 600);
}
}
async function triggerReindex(){
try{
await fetch("/admin/reindex",{method:"POST"});
document.getElementById("indexBox").style.display="";
let tries=0; const t=setInterval(async()=>{
tries++; await fetchIndexStatus();
if(tries>300) clearInterval(t);
},1000);
}catch(e){ alert("Failed to start reindex: "+e); }
}
document.getElementById("reindexBtn").addEventListener("click", async ()=>{
await fetch("/admin/reindex",{method:"POST", credentials:"include"}).catch(()=>{});
});
document.getElementById("thumbsBtn").addEventListener("click", triggerThumbs);
document.getElementById("btn-refresh").onclick=()=>{ loadStats(); fetchIndexStatus(); };
document.getElementById("btn-reindex").onclick=()=>{ triggerReindex(); };
// Initial load & polls
load();
pollIndex();
pollThumbs();
pollErrors();
loadStats(); fetchIndexStatus();
})();
</script>
window.addEventListener("beforeunload", ()=>{ Object.values(Chart.instances||{}).forEach(c=>{ try{c.destroy()}catch{}}); });
</script>
</body>
</html>

View File

@@ -1,54 +1,155 @@
import hashlib, re, io, zipfile
# app/thumbs.py
from __future__ import annotations
import logging, warnings
from pathlib import Path
from typing import Optional
from PIL import Image
import hashlib
import zipfile
from PIL import Image, UnidentifiedImageError
THUMB_DIR: Path = Path("/data/thumbs")
THUMB_DIR.mkdir(parents=True, exist_ok=True)
from .config import LIBRARY_DIR
SAFE = re.compile(r"[^A-Za-z0-9._-]")
logger = logging.getLogger("comicopds")
warnings.simplefilter("ignore", UserWarning) # silence noisy EXIF warnings
ERROR_LOG = Path("/data/thumbs_errors.log")
def _sanitize(name: str) -> str:
name = SAFE.sub("_", name).strip("._")
return name[:128] or "unnamed"
THUMBS_DIR = Path("/data/thumbs")
THUMBS_DIR.mkdir(parents=True, exist_ok=True)
# Keep consistent naming if we have a ComicVine issue id
def _thumb_name(rel: str, comicvine_issue: Optional[str]) -> str:
if comicvine_issue:
return _sanitize(comicvine_issue) + ".jpg"
return hashlib.sha1(rel.encode("utf-8")).hexdigest() + ".jpg"
safe = "".join(c for c in comicvine_issue if c.isalnum() or c in ("-", "_"))
if not safe:
safe = comicvine_issue
return f"{safe}.jpg"
# stable fallback by path hash
h = hashlib.sha1(rel.encode("utf-8")).hexdigest()
return f"{h}.jpg"
def thumb_path(rel: str, comicvine_issue: Optional[str]) -> Path:
return THUMB_DIR / _thumb_name(rel, comicvine_issue)
def _cover_candidate_names():
# common cover file names (lowercased)
return (
"cover.jpg", "cover.jpeg", "cover.png", "000.jpg", "001.jpg", "0001.jpg", "1.jpg",
"front.jpg", "folder.jpg"
)
def _choose_cover_name(names: list[str]) -> str:
# pick best candidate; otherwise first image by natural order
lower = {n.lower(): n for n in names}
for key in _cover_candidate_names():
if key in lower:
return lower[key]
# natural sort by numeric chunks
import re
def natkey(s: str):
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)]
images = [n for n in names if not n.endswith("/")]
images.sort(key=natkey)
return images[0] if images else names[0]
def _list_image_entries(zf: zipfile.ZipFile) -> list[str]:
valid = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
return [n for n in zf.namelist() if Path(n).suffix.lower() in valid and not n.endswith("/")]
def have_thumb(rel: str, comicvine_issue: Optional[str]) -> Optional[Path]:
p = thumb_path(rel, comicvine_issue)
p = THUMBS_DIR / _thumb_name(rel, comicvine_issue)
return p if p.exists() else None
def _first_image_from_cbz(fp: Path) -> Optional[bytes]:
try:
with zipfile.ZipFile(fp, "r") as zf:
names = sorted(zf.namelist())
for n in names:
ln = n.lower()
if ln.endswith((".jpg", ".jpeg", ".png", ".webp")) and not ln.endswith("/"):
with zf.open(n) as f:
return f.read()
except Exception:
return None
return None
def _save_as_jpeg(src_img: Image.Image, dest: Path) -> Path:
im = src_img
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
elif im.mode == "L":
im = im.convert("RGB")
dest.parent.mkdir(parents=True, exist_ok=True)
# reasonable default size/quality; tweak if you wish
# resize if huge (e.g., keep max dimension ≈ 1200px to save space)
max_dim = 1200
w, h = im.size
if max(w, h) > max_dim:
if w >= h:
nh = int(h * (max_dim / float(w)))
im = im.resize((max_dim, nh))
else:
nw = int(w * (max_dim / float(h)))
im = im.resize((nw, max_dim))
im.save(dest, format="JPEG", quality=88, optimize=True)
return dest
def generate_thumb(rel: str, abs_path: Path, comicvine_issue: Optional[str], size=(512, 512)) -> Optional[Path]:
out = thumb_path(rel, comicvine_issue)
out.parent.mkdir(parents=True, exist_ok=True)
def generate_thumb(rel: str, abs_cbz_path: Path, comicvine_issue: Optional[str]) -> Optional[Path]:
"""
Create the thumbnail if missing. Returns the path if it exists afterwards.
Logs errors to /data/thumbs_errors.log via _log_thumb_error().
"""
out = THUMBS_DIR / _thumb_name(rel, comicvine_issue)
img_bytes = _first_image_from_cbz(abs_path)
if img_bytes is None:
return None
try:
im = Image.open(io.BytesIO(img_bytes)).convert("RGB")
im.thumbnail(size)
im.save(out, "JPEG", quality=85)
# Already there?
if out.exists():
return out
except Exception:
# Missing source
if not abs_cbz_path.exists() or not abs_cbz_path.is_file():
_log_thumb_error(rel, FileNotFoundError(f"CBZ not found: {abs_cbz_path}"))
return None
try:
with zipfile.ZipFile(abs_cbz_path, "r") as zf:
images = _list_image_entries(zf)
if not images:
_log_thumb_error(rel, RuntimeError("No image entries in archive"))
return None
cover_name = _choose_cover_name(images)
try:
with zf.open(cover_name) as fp:
try:
img = Image.open(fp)
# Force decode to catch truncated/corrupt images early
img.load()
except UnidentifiedImageError as e:
_log_thumb_error(rel, UnidentifiedImageError(f"Unidentified image: {cover_name}"))
return None
except Exception as e:
_log_thumb_error(rel, e)
return None
try:
return _save_as_jpeg(img, out)
except Exception as e:
_log_thumb_error(rel, e)
return None
except KeyError:
_log_thumb_error(rel, KeyError(f"Cover not found in zip: {cover_name}"))
return None
except zipfile.BadZipFile as e:
_log_thumb_error(rel, e)
return None
except Exception as e:
_log_thumb_error(rel, e)
return None
def ensure_thumb(rel: str, comicvine_issue: Optional[str]) -> Optional[Path]:
"""
Ensure a thumb exists (lazy). Uses LIBRARY_DIR and rel to find the CBZ.
"""
existing = have_thumb(rel, comicvine_issue)
if existing:
return existing
abs_cbz = (LIBRARY_DIR / rel)
if abs_cbz.suffix.lower() != ".cbz":
return None
return generate_thumb(rel, abs_cbz, comicvine_issue)
def _log_thumb_error(rel: str, err: Exception):
try:
msg = f"{rel}: {err}"
logger.warning(f"thumbnail error: {msg}")
ERROR_LOG.parent.mkdir(parents=True, exist_ok=True)
with ERROR_LOG.open("a", encoding="utf-8") as fp:
fp.write(msg + "\n")
except Exception:
pass