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