From 5295b24513eb5b5bb088e8a6d48f0a0140aeb89c Mon Sep 17 00:00:00 2001 From: Frederik Baerentsen Date: Thu, 4 Sep 2025 21:10:52 +0200 Subject: [PATCH] Uploaded V1.0 --- Dockerfile | 26 +++ README.md | 183 +++++++++++++++++ app/auth.py | 20 ++ app/config.py | 0 app/fs_index.py | 0 app/main.py | 375 +++++++++++++++++++++++++++++++++++ app/meta.py | 0 app/opds.py | 0 app/templates/dashboard.html | 0 app/thumbs.py | 0 compose.yaml | 0 requirements.txt | 4 + 12 files changed, 608 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/fs_index.py create mode 100644 app/main.py create mode 100644 app/meta.py create mode 100644 app/opds.py create mode 100644 app/templates/dashboard.html create mode 100644 app/thumbs.py create mode 100644 compose.yaml create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..700c051 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . + +# install system libs for Pillow (JPEG, PNG, WebP) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libjpeg62-turbo zlib1g libpng16-16 libwebp7 \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir -r requirements.txt + +COPY app /app/app + +ENV CONTENT_BASE_DIR=/library \ + PAGE_SIZE=50 \ + SERVER_BASE=http://localhost:8080 \ + URL_PREFIX= \ + OPDS_BASIC_USER= \ + OPDS_BASIC_PASS= \ + ENABLE_WATCH=true + +EXPOSE 8080 +VOLUME ["/data", "/library"] + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..91457e8 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# ComicOPDS + +A tiny, fast **OPDS 1.x** server for **CBZ** comics with ComicRack metadata. +Built with FastAPI, runs great in Docker, works nicely with Panels for iOS. + +## Features + +* 📁 **Browse** your library by folder (no database) +* 🔎 **Search** (OPDS 1.x + OpenSearch) across filenames and ComicInfo metadata +* ⬇️ **Download** & ▶️ **Stream** (HTTP Range support) CBZ files +* 🖼️ **Thumbnails** extracted from CBZ (first image), cached on disk + – If `ComicVineIssue` exists in `ComicInfo.xml`, the cover is named `.jpg` +* 🏷️ **ComicInfo.xml** parsing and caching (Series, Number, Title, Publisher, Writer, Year, …) +* ⚡ **Warm index** persisted to `/data/index.json` (no full re-index on startup) +* 🔐 Optional **HTTP Basic Auth** +* 📊 **Dashboard** at `/dashboard` with overview stats & charts (powered by `/stats.json`) +* 🧰 Reverse-proxy friendly (domain/subpath support) + +> **Scope**: CBZ **only** (by design). + +--- + +## Quick start + +### 1) Folder layout (example) + +``` +/your/comics/ + Batman (2016)/ + Batman (2016) - 001.cbz # contains ComicInfo.xml + Batman (2016) - 002.cbz + Saga/ + Saga - 001.cbz +``` + +Make sure each CBZ has a `ComicInfo.xml` inside (ComicRack-compatible). + +### 2) Docker Compose + +```yaml +services: + comicopds: + build: . # build the image from the included Dockerfile + image: comicopds:latest + container_name: comicopds + ports: + - "8080:8080" + environment: + CONTENT_BASE_DIR: /library + PAGE_SIZE: "50" + SERVER_BASE: "https://comics.example.com" # your public origin (or http://localhost:8080) + URL_PREFIX: "" # e.g. "/comics" if served under a subpath + OPDS_BASIC_USER: "admin" # leave empty to disable auth + OPDS_BASIC_PASS: "change-me" + volumes: + - /path/to/your/comics:/library:ro + - ./data:/data + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] + interval: 30s + timeout: 3s + retries: 3 +``` + +Build & run: + +```bash +docker compose up --build +``` +On the first run, Docker will build the image from the included Dockerfile. + +On later runs, Docker will reuse the existing image unless you change the code/Dockerfile (then it will rebuild automatically). + +Visit: + +* OPDS root feed: `https://comics.example.com/` (or `http://localhost:8080/`) +* Dashboard: `https://comics.example.com/dashboard` + +> If you run behind a reverse proxy **and** a subpath (e.g. `/comics`), set +> `SERVER_BASE=https://example.com` and `URL_PREFIX=/comics`. +> Uvicorn is started with `--proxy-headers`, so `X-Forwarded-*` are respected. + +--- + +## OPDS endpoints + +* `GET /` → OPDS start feed (Atom) +* `GET /opds` → folder browsing feed (`?path=` and `?page=`) +* `GET /opds/search.xml` → OpenSearch description +* `GET /opds/search?q=…&page=` → search results feed (Atom) +* `GET /download?path=…` → download CBZ (correct MIME) +* `GET /stream?path=…` → range-enabled streaming (206 Partial Content) +* `GET /thumb?path=…` → JPEG cover (from CBZ’s first image; cached) +* `GET /dashboard` → HTML dashboard +* `GET /stats.json` → library stats for the dashboard +* `GET /healthz` → “ok” healthcheck + +All routes can be protected by HTTP Basic (set `OPDS_BASIC_USER/PASS`). + +--- + +## How it works + +### Warm index + +* On first run, scans `/library` for `.cbz`, reads **`ComicInfo.xml` once**, and writes `/data/index.json`. +* On later startups, **reuses cached metadata** if file `size` & `mtime` match; only new/changed files are opened. +* Thumbnails live in `/data/thumbs/`: + + * If `` exists → `THUMBS/.jpg` + * Else → `THUMBS/.jpg` + +### Metadata fields used (from ComicInfo.xml) + +* Titles/ordering: `Series`, `Number`, `Volume`, `Title` +* People: `Writer`, `Penciller`, `Inker`, `Colorist`, `Letterer`, `CoverArtist` +* Catalog: `Publisher`, `Imprint`, `Genre`, `Tags`, `Characters`, `Teams`, `Locations` +* Dates: `Year`, `Month`, `Day` → `dcterms:issued` +* Extras: `ComicVineIssue` (for stable cover filenames) + +### Dashboard stats + +* **Overview**: total comics, unique series, publishers, formats +* **Charts**: + + * Publishers (doughnut) + * Publication timeline by year (line) + * Formats breakdown (bar — mostly CBZ) + * Top writers (horizontal bar) + +--- + +## Using with Panels (iOS) + +In Panels → **Add Catalog** → **OPDS**: + +* **URL**: your OPDS root (e.g. `https://comics.example.com/`) +* Add your username/password if you enabled Basic Auth +* Panels will show covers via OPDS `image` links and use your folder structure for browsing + +Search in Panels maps to `/opds/search`. + +--- + +## Environment variables + +| Name | Default | Purpose | +| ------------------ | ----------------------- | -------------------------------------------------- | +| `CONTENT_BASE_DIR` | `/library` | Path inside the container where comics are mounted | +| `PAGE_SIZE` | `50` | Items per OPDS page | +| `SERVER_BASE` | `http://localhost:8080` | Public origin used to build absolute OPDS links | +| `URL_PREFIX` | `""` | If serving under a subpath (e.g. `/comics`) | +| `OPDS_BASIC_USER` | *(empty)* | Username for HTTP Basic (if empty → auth off) | +| `OPDS_BASIC_PASS` | *(empty)* | Password for HTTP Basic | + +--- + +## Reverse proxy tips + +* **Domain at root**: set `SERVER_BASE=https://comics.example.com` +* **Domain + subpath**: set `SERVER_BASE=https://example.com` and `URL_PREFIX=/comics` + +--- + +## Troubleshooting + +* **No comics show up**: confirm your host path is mounted read-only at `/library` and files end with `.cbz`. +* **Missing covers**: first request to an entry generates the cover. Check `/data/thumbs/` is writable. +* **Auth prompts repeatedly**: verify browser/app supports Basic Auth and credentials match `OPDS_BASIC_USER/PASS`. +* **Wrong links behind proxy**: ensure `SERVER_BASE` and (if needed) `URL_PREFIX` are set correctly. + +--- + +## Development + +Install deps locally: + +```bash +pip install fastapi uvicorn jinja2 pillow +uvicorn app.main:app --reload --proxy-headers --forwarded-allow-ips="*" +``` + +--- diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..f83dfa6 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,20 @@ +import os +from fastapi import Depends, HTTPException, status +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): + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized", + headers={"WWW-Authenticate": "Basic"}, + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e69de29 diff --git a/app/fs_index.py b/app/fs_index.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..70162a0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,375 @@ +from fastapi import FastAPI, Query, HTTPException, Request, Response, Depends +from fastapi.responses import StreamingResponse, FileResponse, PlainTextResponse, HTMLResponse, JSONResponse +from pathlib import Path +from typing import List, Dict, Any +from jinja2 import Environment, FileSystemLoader, select_autoescape +from urllib.parse import quote +import os +from collections import Counter, defaultdict +import hashlib, email.utils + +from .config import LIBRARY_DIR, PAGE_SIZE, SERVER_BASE, URL_PREFIX, ENABLE_WATCH +from . import fs_index +from .opds import now_rfc3339, mime_for +from .auth import require_basic +from .thumbs import have_thumb, generate_thumb + +app = FastAPI(title="ComicOPDS") + +env = Environment( + loader=FileSystemLoader(str(Path(__file__).parent / "templates")), + autoescape=select_autoescape(enabled_extensions=("xml","html")) +) + +INDEX: List[fs_index.Item] = [] + +def _etag_for(p: Path) -> str: + st = p.stat() + return '"' + hashlib.md5(f"{st.st_size}-{st.st_mtime}".encode()).hexdigest() + '"' + +def _last_modified_for(p: Path) -> str: + return email.utils.formatdate(p.stat().st_mtime, usegmt=True) + +def _abs_path(p: str) -> str: + return (URL_PREFIX + p) if URL_PREFIX else p + +@app.on_event("startup") +def build_index(): + if not LIBRARY_DIR.exists(): + raise RuntimeError(f"CONTENT_BASE_DIR does not exist: {LIBRARY_DIR}") + global INDEX + INDEX = fs_index.scan(LIBRARY_DIR) + +# ---------- helpers for OPDS ---------- +def _display_title(item): + m = item.meta or {} + series, number, volume = m.get("series"), m.get("number"), m.get("volume") + title = m.get("title") or item.name + if series and number: + vol = f" ({volume})" if volume else "" + suffix = f" — {title}" if title and title != series else "" + return f"{series}{vol} #{number}{suffix}" + return title + +def _authors_from_meta(meta: dict) -> list[str]: + authors = [] + for key in ("writer","coverartist","penciller","inker","colorist","letterer"): + v = meta.get(key) + if v: + authors.extend([x.strip() for x in v.split(",") if x.strip()]) + seen=set(); out=[] + for a in authors: + if a.lower() in seen: continue + seen.add(a.lower()); out.append(a) + return out + +def _issued_from_meta(meta: dict) -> str | None: + y = meta.get("year") + if not y: return None + m = int(meta.get("month") or 1) + d = int(meta.get("day") or 1) + try: return f"{int(y):04d}-{m:02d}-{d:02d}" + except Exception: return None + +def _categories_from_meta(meta: dict) -> list[str]: + cats=[] + for k in ("genre","tags","characters","teams","locations"): + v=meta.get(k) + if v: + cats += [x.strip() for x in v.split(",") if x.strip()] + seen=set(); out=[] + for c in cats: + lc=c.lower() + if lc in seen: continue + seen.add(lc); out.append(c) + return out + +def _feed(entries_xml: List[str], title: str, self_href: str, next_href: str | None = None): + tpl = env.get_template("feed.xml.j2") + return tpl.render( + feed_id=f"{SERVER_BASE}{_abs_path(self_href)}", + updated=now_rfc3339(), + title=title, + self_href=_abs_path(self_href), + start_href=_abs_path("/opds"), + base=SERVER_BASE, + next_href=_abs_path(next_href) if next_href else None, + entries=entries_xml + ) + +def _entry_xml(item: fs_index.Item): + tpl = env.get_template("entry.xml.j2") + if item.is_dir: + href = f"/opds?path={quote(item.rel)}" if item.rel else "/opds" + return tpl.render( + entry_id=f"{SERVER_BASE}{_abs_path('/opds/' + quote(item.rel))}", + updated=now_rfc3339(), + title=item.name or "/", + is_dir=True, + href=_abs_path(href) + ) + else: + download_href=f"/download?path={quote(item.rel)}" + stream_href=f"/stream?path={quote(item.rel)}" + meta=item.meta or {} + comicvine_issue=meta.get("comicvineissue") + + thumb_href=None + if item.path.suffix.lower()==".cbz": + p=have_thumb(item.rel, comicvine_issue) + if not p: + p=generate_thumb(item.rel,item.path,comicvine_issue) + if p: + thumb_href=f"/thumb?path={quote(item.rel)}" + + return tpl.render( + entry_id=f"{SERVER_BASE}{_abs_path(download_href)}", + updated=now_rfc3339(), + title=_display_title(item), + is_dir=False, + download_href=_abs_path(download_href), + stream_href=_abs_path(stream_href), + mime=mime_for(item.path), + size_str=f"{item.size} bytes", + thumb_href=_abs_path("/thumb?path="+quote(item.rel)) if thumb_href else None, + authors=_authors_from_meta(meta), + issued=_issued_from_meta(meta), + summary=(meta.get("summary") or None), + categories=_categories_from_meta(meta), + ) + +# ---------- OPDS routes ---------- +@app.get("/healthz") +def health(): + return PlainTextResponse("ok") + +@app.get("/opds", response_class=Response) +def browse(path: str = Query("", description="Relative folder path"), page: int = 1, _=Depends(require_basic)): + path=path.strip("/") + children=list(fs_index.children(INDEX,path)) + # Sort files in a nicer way: by series + numeric Number when present, else by name + def sort_key(it: fs_index.Item): + if it.is_dir: + return (0, it.name.lower(), 0) + meta = it.meta or {} + series = meta.get("series") or "" + try: + num = int(float(meta.get("number","0"))) + except ValueError: + num = 10**9 + return (1, series.lower() or it.name.lower(), num) + children.sort(key=sort_key) + + start=(page-1)*PAGE_SIZE + end=start+PAGE_SIZE + page_items=children[start:end] + entries_xml=[_entry_xml(it) for it in page_items] + self_href=f"/opds?path={quote(path)}&page={page}" if path else f"/opds?page={page}" + next_href=None + if end str: + meta = it.meta or {} + meta_vals = " ".join(str(v) for v in meta.values() if v) + return (it.name + " " + meta_vals).lower() + + matches=[it for it in INDEX if (not it.is_dir) and all(t in haystack(it) for t in terms)] + + start=(page-1)*PAGE_SIZE + end=start+PAGE_SIZE + page_items=matches[start:end] + entries_xml=[_entry_xml(it) for it in page_items] + self_href=f"/opds/search?q={quote(q)}&page={page}" + next_href=f"/opds/search?q={quote(q)}&page={page+1}" if end Path: + p=(LIBRARY_DIR/rel).resolve() + if LIBRARY_DIR not in p.parents and p!=LIBRARY_DIR: + raise HTTPException(400,"Invalid path") + return p + +@app.get("/download") +def download(path: str, request: Request, _=Depends(require_basic)): + p = _abspath(path) + if not p.exists() or not p.is_file(): + raise HTTPException(404) + + etag = _etag_for(p) + lastmod = _last_modified_for(p) + + # Handle If-None-Match / If-Modified-Since + if request.headers.get("if-none-match") == etag: + return Response(status_code=304) + if request.headers.get("if-modified-since") == lastmod: + return Response(status_code=304) + + resp = FileResponse(p, media_type=mime_for(p), filename=p.name) + resp.headers["ETag"] = etag + resp.headers["Last-Modified"] = lastmod + resp.headers["Accept-Ranges"] = "bytes" + return resp + +@app.get("/stream") +def stream(path: str, request: Request, _=Depends(require_basic)): + p=_abspath(path) + if not p.exists() or not p.is_file(): + raise HTTPException(404) + file_size=p.stat().st_size + range_header=request.headers.get("range") + if range_header is None: + return FileResponse(p,media_type=mime_for(p),filename=p.name) + try: + _,rng=range_header.split("=") + start_str,end_str=(rng.split("-")+[""])[:2] + start=int(start_str) if start_str else 0 + end=int(end_str) if end_str else file_size-1 + end=min(end,file_size-1) + if start>end or start<0: + raise ValueError + except Exception: + raise HTTPException(416,"Invalid Range") + def iter_file(fp: Path,s:int,e:int,chunk:int=1024*1024): + with fp.open("rb") as f: + f.seek(s) + remaining=e-s+1 + while remaining>0: + data=f.read(min(chunk,remaining)) + if not data: break + remaining-=len(data) + yield data + headers={ + "Content-Range":f"bytes {start}-{end}/{file_size}", + "Accept-Ranges":"bytes", + "Content-Length":str(end-start+1), + "Content-Type":mime_for(p), + "Content-Disposition":f'inline; filename="{p.name}"', + } + return StreamingResponse(iter_file(p,start,end),status_code=206,headers=headers) + +@app.get("/thumb") +def thumb(path: str, request: Request, _=Depends(require_basic)): + abs_p = _abspath(path) + if not abs_p.exists() or not abs_p.is_file(): + raise HTTPException(404) + it = next((x for x in INDEX if not x.is_dir and x.rel == path), None) + if not it: + raise HTTPException(404) + cvid = (it.meta or {}).get("comicvineissue") + p = have_thumb(path, cvid) or generate_thumb(path, abs_p, cvid) + if not p or not p.exists(): + raise HTTPException(404, "No thumbnail") + + etag = _etag_for(p) + lastmod = _last_modified_for(p) + if request.headers.get("if-none-match") == etag: + return Response(status_code=304) + if request.headers.get("if-modified-since") == lastmod: + return Response(status_code=304) + + resp = FileResponse(p, media_type="image/jpeg") + resp.headers["ETag"] = etag + resp.headers["Last-Modified"] = lastmod + resp.headers["Accept-Ranges"] = "bytes" + return resp + +# ---------- dashboard & stats ---------- +@app.get("/dashboard", response_class=HTMLResponse) +def dashboard(_=Depends(require_basic)): + tpl = env.get_template("dashboard.html") + return HTMLResponse(tpl.render()) + +@app.get("/stats.json", response_class=JSONResponse) +def stats(_=Depends(require_basic)): + files = [it for it in INDEX if not it.is_dir] + total_comics = len(files) + series_set = set() + publishers = Counter() + formats = Counter() + writers = Counter() + timeline = Counter() # year -> count + last_updated = 0.0 + + for it in files: + m = it.meta or {} + if it.mtime > last_updated: + last_updated = it.mtime + if m.get("series"): + series_set.add(m["series"]) + if m.get("publisher"): + publishers[m["publisher"]] += 1 + # formats by extension + ext = it.path.suffix.lower().lstrip(".") or "unknown" + formats[ext] += 1 + # writers + if m.get("writer"): + for w in [x.strip() for x in m["writer"].split(",") if x.strip()]: + writers[w] += 1 + # timeline by year + if m.get("year"): + try: + y = int(m["year"]) + timeline[y] += 1 + except ValueError: + pass + + # total covers = files in /data/thumbs + thumbs_dir = Path("/data/thumbs") + total_covers = 0 + if thumbs_dir.exists(): + total_covers = sum(1 for _ in thumbs_dir.glob("*.jpg")) + + # Compact publisher chart (top 15 + "Other") + pub_labels, pub_values = [], [] + if publishers: + top = publishers.most_common(15) + other = sum(v for _, v in list(publishers.items())[15:]) + pub_labels = [k for k,_ in top] + pub_values = [v for _,v in top] + if other: + pub_labels.append("Other") + pub_values.append(other) + + # Timeline sorted by year + years = sorted(timeline.keys()) + year_values = [timeline[y] for y in years] + + # Top writers (top 15) + w_top = writers.most_common(15) + w_labels = [k for k,_ in w_top] + w_values = [v for _,v in w_top] + + payload: Dict[str, Any] = { + "last_updated": last_updated, + "total_covers": total_covers, + "total_comics": total_comics, + "unique_series": len(series_set), + "unique_publishers": len(publishers), + "formats": dict(formats) or {"cbz": 0}, + "publishers": { "labels": pub_labels, "values": pub_values }, + "timeline": { "labels": years, "values": year_values }, + "top_writers": { "labels": w_labels, "values": w_values }, + } + return JSONResponse(payload) diff --git a/app/meta.py b/app/meta.py new file mode 100644 index 0000000..e69de29 diff --git a/app/opds.py b/app/opds.py new file mode 100644 index 0000000..e69de29 diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..e69de29 diff --git a/app/thumbs.py b/app/thumbs.py new file mode 100644 index 0000000..e69de29 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..06e1832 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +jinja2>=3.1.4 +pillow>=10.4.0