Added button to generate thumbs, compose variables and fixed auth req.
This commit is contained in:
27
app/auth.py
27
app/auth.py
@@ -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"})
|
||||
|
||||
@@ -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")
|
||||
119
app/main.py
119
app/main.py
@@ -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 # we’ll 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"}
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
175
app/thumbs.py
175
app/thumbs.py
@@ -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
|
||||
Reference in New Issue
Block a user