Uploaded V1.0
This commit is contained in:
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
183
README.md
Normal 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 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 `<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
20
app/auth.py
Normal 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
0
app/config.py
Normal file
0
app/fs_index.py
Normal file
0
app/fs_index.py
Normal file
375
app/main.py
Normal file
375
app/main.py
Normal 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
0
app/meta.py
Normal file
0
app/opds.py
Normal file
0
app/opds.py
Normal file
0
app/templates/dashboard.html
Normal file
0
app/templates/dashboard.html
Normal file
0
app/thumbs.py
Normal file
0
app/thumbs.py
Normal file
0
compose.yaml
Normal file
0
compose.yaml
Normal file
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
jinja2>=3.1.4
|
||||
pillow>=10.4.0
|
||||
Reference in New Issue
Block a user