Uploaded V1.0

This commit is contained in:
2025-09-04 21:10:52 +02:00
parent 121523a798
commit 5295b24513
12 changed files with 608 additions and 0 deletions

26
Dockerfile Normal file
View File

@@ -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", "*"]

183
README.md Normal file
View File

@@ -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 `<ComicVineIssue>.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 CBZs 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 `<ComicVineIssue>` exists → `THUMBS/<comicvine_issue>.jpg`
* Else → `THUMBS/<sha1-of-relpath>.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="*"
```
---

20
app/auth.py Normal file
View File

@@ -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"},
)

0
app/config.py Normal file
View File

0
app/fs_index.py Normal file
View File

375
app/main.py Normal file
View File

@@ -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<len(children):
next_href=f"/opds?path={quote(path)}&page={page+1}" if path else f"/opds?page={page+1}"
xml=_feed(entries_xml,title=f"/{path}" if path else "Library",self_href=self_href,next_href=next_href)
return Response(content=xml,media_type="application/atom+xml;profile=opds-catalog")
@app.get("/", response_class=Response)
def root(_=Depends(require_basic)):
# Keep root as OPDS start for clients
return browse(path="",page=1)
@app.get("/opds/search.xml", response_class=Response)
def opensearch_description(_=Depends(require_basic)):
tpl=env.get_template("search-description.xml.j2")
xml=tpl.render(base=SERVER_BASE)
return Response(content=xml,media_type="application/opensearchdescription+xml")
@app.get("/opds/search", response_class=Response)
def search(q: str = Query("",alias="q"), page: int = 1, _=Depends(require_basic)):
terms=[t.lower() for t in q.split() if t.strip()]
if not terms:
return browse(path="",page=page)
def haystack(it: fs_index.Item) -> 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<len(matches) else None
xml=_feed(entries_xml,title=f"Search: {q}",self_href=self_href,next_href=next_href)
return Response(content=xml,media_type="application/atom+xml;profile=opds-catalog")
# ---------- file endpoints ----------
def _abspath(rel: str) -> 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)

0
app/meta.py Normal file
View File

0
app/opds.py Normal file
View File

View File

0
app/thumbs.py Normal file
View File

0
compose.yaml Normal file
View File

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
jinja2>=3.1.4
pillow>=10.4.0