Uploaded V1.0
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
100
app/fs_index.py
100
app/fs_index.py
@@ -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
|
||||||
|
|||||||
36
app/meta.py
36
app/meta.py
@@ -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
|
||||||
|
|||||||
10
app/opds.py
10
app/opds.py
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
17
compose.yaml
17
compose.yaml
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user