Uploaded V1.0

This commit is contained in:
2025-09-04 21:12:06 +02:00
parent 5295b24513
commit 41e1696f07
7 changed files with 350 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import os
from pathlib import Path
def _env_bool(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return val.strip().lower() in ("true", "yes", "on")
LIBRARY_DIR = Path(os.environ.get("CONTENT_BASE_DIR", "/library")).resolve()
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "50"))
# Public base URL used to build absolute links in the OPDS feed
SERVER_BASE = os.environ.get("SERVER_BASE", "http://localhost:8080").rstrip("/")
# Optional path prefix if you serve the app under a subpath (e.g. /comics)
URL_PREFIX = os.environ.get("URL_PREFIX", "").rstrip("/")
if URL_PREFIX == "/":
URL_PREFIX = ""
ENABLE_WATCH = _env_bool("ENABLE_WATCH", True)

View File

@@ -0,0 +1,100 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Iterable, Dict, Any
import json, os
from .meta import read_comicinfo
COMIC_EXTS = {".cbz"}
@dataclass
class Item:
path: Path
is_dir: bool
name: str
rel: str
size: int = 0
mtime: float = 0.0
meta: Dict[str, Any] = field(default_factory=dict)
def _cache_file() -> Path:
return Path("/data/index.json")
def load_cache() -> Dict[str, dict]:
p = _cache_file()
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def save_cache(items: List[Item]):
p = _cache_file()
data = {
it.rel: {
"is_dir": it.is_dir,
"name": it.name,
"size": it.size,
"mtime": it.mtime,
"meta": it.meta
}
for it in items
}
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(data), encoding="utf-8")
def scan(root: Path) -> List[Item]:
root = root.resolve()
cached = load_cache()
items: List[Item] = []
items.append(Item(path=root, is_dir=True, name=root.name or "/", rel=""))
for dirpath, _, filenames in os.walk(root):
base = Path(dirpath)
rel_dir = str(base.relative_to(root)).replace("\\", "/")
if rel_dir != ".":
items.append(Item(path=base, is_dir=True, name=base.name, rel=rel_dir))
for fn in filenames:
p = base / fn
ext = p.suffix.lower()
if ext not in COMIC_EXTS:
continue
rel = str(p.relative_to(root)).replace("\\", "/")
try:
st = p.stat()
except FileNotFoundError:
continue
size, mtime = st.st_size, st.st_mtime
c = cached.get(rel)
meta, name = {}, p.stem
if c and not c.get("is_dir") and c.get("mtime") == mtime and c.get("size") == size:
name = c.get("name") or name
meta = c.get("meta") or {}
else:
meta = read_comicinfo(p)
if meta.get("title"):
name = meta["title"]
items.append(Item(path=p, is_dir=False, name=name, rel=rel, size=size, mtime=mtime, meta=meta))
save_cache(items)
return items
def children(items: List[Item], rel_folder: str) -> Iterable[Item]:
prefix = (rel_folder.strip("/") + "/") if rel_folder else ""
depth = 0 if rel_folder == "" else rel_folder.count("/") + 1
for it in items:
if not it.rel.startswith(prefix):
continue
if it.rel == rel_folder:
continue
if it.is_dir and it.rel.count("/") == depth:
yield it
elif not it.is_dir and it.rel.count("/") == depth:
yield it

View File

@@ -0,0 +1,36 @@
from zipfile import ZipFile
from xml.etree import ElementTree as ET
from pathlib import Path
FIELDS = [
"Series","Number","Volume","Title","Summary",
"Writer","Penciller","Inker","Colorist","Letterer","CoverArtist",
"Publisher","Imprint",
"Year","Month","Day",
"Genre","Tags","Characters","Teams","Locations",
"Web","LanguageISO",
"ComicVineIssue"
]
def read_comicinfo(cbz_path: Path) -> dict:
try:
with ZipFile(cbz_path) as z:
name = next((n for n in z.namelist() if n.lower() == "comicinfo.xml"), None)
if not name:
return {}
with z.open(name) as f:
xml = f.read()
root = ET.fromstring(xml)
except Exception:
return {}
def g(tag):
el = root.find(tag)
return el.text.strip() if el is not None and el.text else None
meta = {}
for f in FIELDS:
v = g(f)
if v:
meta[f.lower()] = v
return meta

View File

@@ -0,0 +1,10 @@
from datetime import datetime, timezone
from pathlib import Path
MIME_MAP = { ".cbz": "application/vnd.comicbook+zip" }
def now_rfc3339():
return datetime.now(timezone.utc).isoformat()
def mime_for(path: Path) -> str:
return MIME_MAP.get(path.suffix.lower(), "application/octet-stream")

View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ComicOPDS — Library Dashboard</title>
<style>
:root { --gap: 16px; --card: #fff; --bg: #f6f7fb; --text:#222; --muted:#666; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif; background: var(--bg); color: var(--text); }
header { padding: 20px; background: #0f172a; color: #fff; }
header h1 { margin: 0 0 6px 0; font-size: 20px; }
header .sub { color: #cbd5e1; font-size: 12px; }
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap); }
.card { background: var(--card); border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.stat { display:flex; flex-direction:column; gap:6px; }
.stat .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.stat .value { font-size: 28px; font-weight: 700; }
.charts { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--gap); margin-top: var(--gap); }
canvas { width: 100% !important; height: 360px !important; }
@media (max-width: 1000px) {
.grid { grid-template-columns: repeat(2, 1fr); }
.charts { grid-template-columns: 1fr; }
}
</style>
<!-- Chart.js from CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<header>
<h1>ComicOPDS — Library Dashboard</h1>
<div class="sub">Last updated: <span id="lastUpdated"></span> • Covers: <span id="covers"></span></div>
</header>
<main>
<section class="grid" id="overview">
<div class="card stat"><div class="label">Total comics</div><div class="value" id="totalComics">0</div></div>
<div class="card stat"><div class="label">Unique series</div><div class="value" id="uniqueSeries">0</div></div>
<div class="card stat"><div class="label">Publishers</div><div class="value" id="uniquePublishers">0</div></div>
<div class="card stat"><div class="label">Formats</div><div class="value" id="formats"></div></div>
</section>
<section class="charts">
<div class="card"><h3>Publishers distribution</h3><canvas id="publishersChart"></canvas></div>
<div class="card"><h3>Publication timeline</h3><canvas id="timelineChart"></canvas></div>
<div class="card"><h3>Formats breakdown</h3><canvas id="formatsChart"></canvas></div>
<div class="card"><h3>Top writers</h3><canvas id="writersChart"></canvas></div>
</section>
</main>
<script>
async function load() {
const r = await fetch("/stats.json", { credentials: "include" });
const data = await r.json();
// Stats
document.getElementById("lastUpdated").textContent = new Date(data.last_updated * 1000).toLocaleString();
document.getElementById("covers").textContent = data.total_covers;
document.getElementById("totalComics").textContent = data.total_comics;
document.getElementById("uniqueSeries").textContent = data.unique_series;
document.getElementById("uniquePublishers").textContent = data.unique_publishers;
document.getElementById("formats").textContent = Object.entries(data.formats)
.map(([k,v]) => `${k.toUpperCase()}: ${v}`).join(" ");
// Charts
const ctx1 = document.getElementById("publishersChart");
const pubLabels = data.publishers.labels;
const pubValues = data.publishers.values;
new Chart(ctx1, {
type: "doughnut",
data: { labels: pubLabels, datasets: [{ data: pubValues }] },
options: { responsive: true, plugins: { legend: { position: "bottom" } } }
});
const ctx2 = document.getElementById("timelineChart");
const years = data.timeline.labels;
const counts = data.timeline.values;
new Chart(ctx2, {
type: "line",
data: { labels: years, datasets: [{ label: "Issues per year", data: counts, tension: 0.2 }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { autoSkip: true, maxTicksLimit: 12 } } } }
});
const ctx3 = document.getElementById("formatsChart");
const fmtLabels = Object.keys(data.formats);
const fmtValues = Object.values(data.formats);
new Chart(ctx3, {
type: "bar",
data: { labels: fmtLabels, datasets: [{ label: "Files", data: fmtValues }] },
options: { responsive: true, plugins: { legend: { display: false } } }
});
const ctx4 = document.getElementById("writersChart");
const wLabels = data.top_writers.labels;
const wValues = data.top_writers.values;
new Chart(ctx4, {
type: "bar",
data: { labels: wLabels, datasets: [{ label: "Issues", data: wValues }] },
options: {
indexAxis: "y",
responsive: true,
plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true } }
}
});
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
import hashlib, re, io, zipfile
from pathlib import Path
from typing import Optional
from PIL import Image
THUMB_DIR: Path = Path("/data/thumbs")
THUMB_DIR.mkdir(parents=True, exist_ok=True)
SAFE = re.compile(r"[^A-Za-z0-9._-]")
def _sanitize(name: str) -> str:
name = SAFE.sub("_", name).strip("._")
return name[:128] or "unnamed"
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"
def thumb_path(rel: str, comicvine_issue: Optional[str]) -> Path:
return THUMB_DIR / _thumb_name(rel, comicvine_issue)
def have_thumb(rel: str, comicvine_issue: Optional[str]) -> Optional[Path]:
p = thumb_path(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 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)
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)
return out
except Exception:
return None

View File

@@ -0,0 +1,17 @@
services:
comicopds:
build: .
image: comicopds:latest
ports:
- "8382:8080"
environment:
CONTENT_BASE_DIR: /library
PAGE_SIZE: "50"
#SERVER_BASE: "https://comics.example.com" # <- set to your public domain
URL_PREFIX: "" # or "/comics" if served under a subpath
OPDS_BASIC_USER: "admin"
OPDS_BASIC_PASS: "change-me"
ENABLE_WATCH: "false" # true/false (not 1/0)
volumes:
- /path/to/your/comics:/library:ro
- ./data:/data