Fixed dashboard and misc.
This commit is contained in:
631
app/main.py
631
app/main.py
@@ -1,12 +1,21 @@
|
||||
from fastapi import FastAPI, Query, HTTPException, Request, Response, Depends, Header
|
||||
from fastapi.responses import StreamingResponse, FileResponse, PlainTextResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.responses import (
|
||||
StreamingResponse,
|
||||
FileResponse,
|
||||
PlainTextResponse,
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
)
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from urllib.parse import quote
|
||||
from collections import Counter
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
import hashlib, email.utils
|
||||
import re
|
||||
import json
|
||||
import math
|
||||
import datetime as dt
|
||||
|
||||
from .config import LIBRARY_DIR, PAGE_SIZE, SERVER_BASE, URL_PREFIX, ENABLE_WATCH
|
||||
from . import fs_index
|
||||
@@ -16,6 +25,7 @@ from .thumbs import have_thumb, generate_thumb
|
||||
|
||||
app = FastAPI(title="ComicOPDS")
|
||||
|
||||
# IMPORTANT: include ".j2" so everything is auto-escaped (fixes & in titles, etc.)
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(Path(__file__).parent / "templates")),
|
||||
autoescape=select_autoescape(enabled_extensions=("xml", "html", "j2"), default=True),
|
||||
@@ -23,13 +33,6 @@ env = Environment(
|
||||
|
||||
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
|
||||
|
||||
@@ -40,8 +43,9 @@ def build_index():
|
||||
global INDEX
|
||||
INDEX = fs_index.scan(LIBRARY_DIR)
|
||||
|
||||
# ---------- helpers for OPDS ----------
|
||||
def _display_title(item):
|
||||
|
||||
# ---------- OPDS helpers ----------
|
||||
def _display_title(item: fs_index.Item) -> str:
|
||||
m = item.meta or {}
|
||||
series, number, volume = m.get("series"), m.get("number"), m.get("volume")
|
||||
title = m.get("title") or item.name
|
||||
@@ -53,77 +57,87 @@ def _display_title(item):
|
||||
|
||||
def _authors_from_meta(meta: dict) -> list[str]:
|
||||
authors = []
|
||||
for key in ("writer","coverartist","penciller","inker","colorist","letterer"):
|
||||
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=[]
|
||||
seen = set()
|
||||
out = []
|
||||
for a in authors:
|
||||
if a.lower() in seen: continue
|
||||
seen.add(a.lower()); out.append(a)
|
||||
if a.lower() in seen:
|
||||
continue
|
||||
seen.add(a.lower())
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
def _issued_from_meta(meta: dict) -> str | None:
|
||||
def _issued_from_meta(meta: dict) -> Optional[str]:
|
||||
y = meta.get("year")
|
||||
if not y: return None
|
||||
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
|
||||
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)
|
||||
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=[]
|
||||
seen = set()
|
||||
out = []
|
||||
for c in cats:
|
||||
lc=c.lower()
|
||||
if lc in seen: continue
|
||||
seen.add(lc); out.append(c)
|
||||
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):
|
||||
def _feed(entries_xml: List[str], title: str, self_href: str, next_href: Optional[str] = None):
|
||||
tpl = env.get_template("feed.xml.j2")
|
||||
base = SERVER_BASE.rstrip("/")
|
||||
return tpl.render(
|
||||
feed_id=f"{SERVER_BASE}{_abs_path(self_href)}",
|
||||
feed_id=f"{base}{_abs_path(self_href)}",
|
||||
updated=now_rfc3339(),
|
||||
title=title,
|
||||
self_href=_abs_path(self_href),
|
||||
start_href=_abs_path("/opds"),
|
||||
base=SERVER_BASE,
|
||||
base=base,
|
||||
next_href=_abs_path(next_href) if next_href else None,
|
||||
entries=entries_xml
|
||||
entries=entries_xml,
|
||||
)
|
||||
|
||||
def _entry_xml(item: fs_index.Item):
|
||||
def _entry_xml(item: fs_index.Item) -> str:
|
||||
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))}",
|
||||
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_path('/opds/' + quote(item.rel))}",
|
||||
updated=now_rfc3339(),
|
||||
title=item.name or "/",
|
||||
is_dir=True,
|
||||
href=_abs_path(href)
|
||||
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")
|
||||
download_href = f"/download?path={quote(item.rel)}"
|
||||
stream_href = f"/stream?path={quote(item.rel)}" # we still provide it; most clients use /download
|
||||
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)
|
||||
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)
|
||||
p = generate_thumb(item.rel, item.path, comicvine_issue)
|
||||
if p:
|
||||
thumb_href=f"/thumb?path={quote(item.rel)}"
|
||||
thumb_href = f"/thumb?path={quote(item.rel)}"
|
||||
|
||||
return tpl.render(
|
||||
entry_id=f"{SERVER_BASE}{_abs_path(download_href)}",
|
||||
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_path(download_href)}",
|
||||
updated=now_rfc3339(),
|
||||
title=_display_title(item),
|
||||
is_dir=False,
|
||||
@@ -131,108 +145,103 @@ def _entry_xml(item: fs_index.Item):
|
||||
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,
|
||||
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 ----------
|
||||
|
||||
# ---------- core routes (OPDS browsing/search) ----------
|
||||
@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
|
||||
path = path.strip("/")
|
||||
children = list(fs_index.children(INDEX, path))
|
||||
|
||||
# Sort: dirs first by name; files by series + number when present
|
||||
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 ""
|
||||
# force numeric-ish, shove non-numeric to end
|
||||
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")
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
end = start + PAGE_SIZE
|
||||
page_items = children[start:end]
|
||||
entries_xml = [_entry_xml(it) for it in page_items]
|
||||
|
||||
# Inject "Smart Lists" virtual folder at root page 1
|
||||
if path == "" and page == 1:
|
||||
tpl = env.get_template("entry.xml.j2")
|
||||
smart_href = _abs_path("/opds/smart")
|
||||
smart_entry = tpl.render(
|
||||
entry_id=f"{SERVER_BASE.rstrip('/')}{smart_href}",
|
||||
updated=now_rfc3339(),
|
||||
title="📁 Smart Lists",
|
||||
is_dir=True,
|
||||
href=smart_href,
|
||||
)
|
||||
entries_xml = [smart_entry] + entries_xml
|
||||
|
||||
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)
|
||||
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")
|
||||
tpl = env.get_template("search-description.xml.j2")
|
||||
xml = tpl.render(base=SERVER_BASE.rstrip("/"))
|
||||
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()]
|
||||
def opds_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)
|
||||
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)]
|
||||
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")
|
||||
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 ----------
|
||||
|
||||
# ---------- file endpoints (download/stream/thumb) ----------
|
||||
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")
|
||||
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
|
||||
|
||||
def _common_file_headers(p: Path) -> dict:
|
||||
return {
|
||||
"Accept-Ranges": "bytes",
|
||||
@@ -240,8 +249,8 @@ def _common_file_headers(p: Path) -> dict:
|
||||
"Content-Disposition": f'inline; filename="{p.name}"',
|
||||
}
|
||||
|
||||
@app.head("/stream")
|
||||
def stream_head(path: str, _=Depends(require_basic)):
|
||||
@app.head("/download")
|
||||
def download_head(path: str, _=Depends(require_basic)):
|
||||
p = _abspath(path)
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404)
|
||||
@@ -250,14 +259,8 @@ def stream_head(path: str, _=Depends(require_basic)):
|
||||
headers["Content-Length"] = str(st.st_size)
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
@app.get("/stream")
|
||||
def stream(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
|
||||
"""
|
||||
Stream file with HTTP Range support.
|
||||
- Returns 206 for valid single-range requests
|
||||
- Returns 200 with Accept-Ranges for full-file (no Range)
|
||||
- Ignores additional ranges if client sends multiple (serves the first)
|
||||
"""
|
||||
@app.get("/download")
|
||||
def download(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
|
||||
p = _abspath(path)
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404)
|
||||
@@ -267,23 +270,20 @@ def stream(path: str, request: Request, range: str | None = Header(default=None)
|
||||
|
||||
rng_header = range or request.headers.get("range")
|
||||
if not rng_header:
|
||||
# No Range ? whole file, but advertise that we support ranges
|
||||
headers["Content-Length"] = str(file_size)
|
||||
return FileResponse(p, headers=headers)
|
||||
|
||||
# Parse e.g. "bytes=START-END" (ignore multi-ranges after the first)
|
||||
try:
|
||||
unit, rngs = rng_header.split("=", 1)
|
||||
if unit.strip().lower() != "bytes":
|
||||
raise ValueError
|
||||
first_range = rngs.split(",")[0].strip() # ignore additional ranges
|
||||
first_range = rngs.split(",")[0].strip()
|
||||
start_str, end_str = (first_range.split("-") + [""])[:2]
|
||||
|
||||
if start_str == "" and end_str == "":
|
||||
raise ValueError
|
||||
|
||||
if start_str == "":
|
||||
# suffix bytes: "-N" ? last N bytes
|
||||
length = int(end_str)
|
||||
if length <= 0:
|
||||
raise ValueError
|
||||
@@ -298,11 +298,10 @@ def stream(path: str, request: Request, range: str | None = Header(default=None)
|
||||
|
||||
end = min(end, file_size - 1)
|
||||
except Exception:
|
||||
# Malformed ? 416
|
||||
raise HTTPException(
|
||||
status_code=416,
|
||||
detail="Invalid Range",
|
||||
headers={"Content-Range": f"bytes */{file_size}"}
|
||||
headers={"Content-Range": f"bytes */{file_size}"},
|
||||
)
|
||||
|
||||
def iter_file(fp: Path, s: int, e: int, chunk: int = 1024 * 1024):
|
||||
@@ -316,7 +315,6 @@ def stream(path: str, request: Request, range: str | None = Header(default=None)
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
# 206 Partial Content
|
||||
part_len = end - start + 1
|
||||
headers.update({
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
@@ -324,8 +322,23 @@ def stream(path: str, request: Request, range: str | None = Header(default=None)
|
||||
})
|
||||
return StreamingResponse(iter_file(p, start, end), status_code=206, headers=headers)
|
||||
|
||||
@app.head("/stream")
|
||||
def stream_head(path: str, _=Depends(require_basic)):
|
||||
p = _abspath(path)
|
||||
if not p.exists() or not p.is_file():
|
||||
raise HTTPException(404)
|
||||
st = p.stat()
|
||||
headers = _common_file_headers(p)
|
||||
headers["Content-Length"] = str(st.st_size)
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
@app.get("/stream")
|
||||
def stream(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
|
||||
# Alias of download with Range support (Panels uses /download)
|
||||
return download(path=path, request=request, range=range)
|
||||
|
||||
@app.get("/thumb")
|
||||
def thumb(path: str, request: Request, _=Depends(require_basic)):
|
||||
def thumb(path: str, _=Depends(require_basic)):
|
||||
abs_p = _abspath(path)
|
||||
if not abs_p.exists() or not abs_p.is_file():
|
||||
raise HTTPException(404)
|
||||
@@ -336,21 +349,10 @@ def thumb(path: str, request: Request, _=Depends(require_basic)):
|
||||
p = have_thumb(path, cvid) or generate_thumb(path, abs_p, cvid)
|
||||
if not p or not p.exists():
|
||||
raise HTTPException(404, "No thumbnail")
|
||||
return FileResponse(p, media_type="image/jpeg")
|
||||
|
||||
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 ----------
|
||||
# ---------- Dashboard & stats ----------
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
def dashboard(_=Depends(require_basic)):
|
||||
tpl = env.get_template("dashboard.html")
|
||||
@@ -375,14 +377,11 @@ def stats(_=Depends(require_basic)):
|
||||
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"])
|
||||
@@ -390,31 +389,27 @@ def stats(_=Depends(require_basic)):
|
||||
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]
|
||||
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]
|
||||
w_labels = [k for k, _ in w_top]
|
||||
w_values = [v for _, v in w_top]
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"last_updated": last_updated,
|
||||
@@ -422,15 +417,349 @@ def stats(_=Depends(require_basic)):
|
||||
"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 },
|
||||
"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)
|
||||
|
||||
# ---------- debug ----------
|
||||
|
||||
# ---------- Debug helper ----------
|
||||
@app.get("/debug/children", response_class=JSONResponse)
|
||||
def debug_children(path: str = ""):
|
||||
ch = list(fs_index.children(INDEX, path.strip("/")))
|
||||
return [{"rel": it.rel, "is_dir": it.is_dir, "name": it.name} for it in ch]
|
||||
return JSONResponse(
|
||||
[{"rel": it.rel, "is_dir": it.is_dir, "name": it.name} for it in ch]
|
||||
)
|
||||
|
||||
|
||||
# ---------- Smart Lists (advanced) ----------
|
||||
SMARTLISTS_PATH = Path("/data/smartlists.json")
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
|
||||
return slug or "list"
|
||||
|
||||
def _load_smartlists() -> list[dict]:
|
||||
if SMARTLISTS_PATH.exists():
|
||||
try:
|
||||
return json.loads(SMARTLISTS_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
def _save_smartlists(lists: list[dict]) -> None:
|
||||
SMARTLISTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
SMARTLISTS_PATH.write_text(json.dumps(lists, ensure_ascii=False, indent=0), encoding="utf-8")
|
||||
|
||||
def _issued_tuple(meta: dict) -> Optional[tuple[int, int, int]]:
|
||||
y = meta.get("year")
|
||||
if not y:
|
||||
return None
|
||||
try:
|
||||
return (int(y), int(meta.get("month") or 1), int(meta.get("day") or 1))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_field_value(it: fs_index.Item, field: str):
|
||||
f = (field or "").lower()
|
||||
m = it.meta or {}
|
||||
if f == "rel": return it.rel
|
||||
if f == "title": return m.get("title") or it.name
|
||||
if f == "series": return m.get("series")
|
||||
if f == "number": return m.get("number")
|
||||
if f == "volume": return m.get("volume")
|
||||
if f == "publisher": return m.get("publisher")
|
||||
if f == "imprint": return m.get("imprint")
|
||||
if f == "writer": return m.get("writer")
|
||||
if f == "characters": return m.get("characters")
|
||||
if f == "teams": return m.get("teams")
|
||||
if f == "tags": return m.get("tags") or m.get("genre")
|
||||
if f == "year": return m.get("year")
|
||||
if f == "month": return m.get("month")
|
||||
if f == "day": return m.get("day")
|
||||
if f == "issued":
|
||||
t = _issued_tuple(m)
|
||||
return f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d}" if t else None
|
||||
if f == "languageiso": return m.get("languageiso")
|
||||
if f == "comicvineissue": return m.get("comicvineissue")
|
||||
if f == "ext": return it.path.suffix.lower().lstrip(".")
|
||||
if f == "size": return it.size
|
||||
if f == "mtime": return int(it.mtime)
|
||||
if f == "has_thumb":
|
||||
return bool(have_thumb(it.rel, m.get("comicvineissue")))
|
||||
if f == "has_meta": return bool(m)
|
||||
return None
|
||||
|
||||
def _to_float(x) -> Optional[float]:
|
||||
try:
|
||||
return float(str(x))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _to_date(s: str) -> Optional[dt.date]:
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
parts = s.split("-")
|
||||
try:
|
||||
if len(parts) == 1:
|
||||
return dt.date(int(parts[0]), 1, 1)
|
||||
if len(parts) == 2:
|
||||
return dt.date(int(parts[0]), int(parts[1]), 1)
|
||||
if len(parts) == 3:
|
||||
return dt.date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _val_to_date(val) -> Optional[dt.date]:
|
||||
if isinstance(val, (int, float)):
|
||||
try:
|
||||
return dt.date.fromtimestamp(int(val))
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return _to_date(val)
|
||||
return None
|
||||
|
||||
def _rule_true(it: fs_index.Item, r: dict) -> bool:
|
||||
field = (r.get("field") or "").lower()
|
||||
op = (r.get("op") or "contains").lower()
|
||||
val = (r.get("value") or "").strip()
|
||||
negate = bool(r.get("not", False))
|
||||
|
||||
left = _get_field_value(it, field)
|
||||
|
||||
if op in ("exists", "missing"):
|
||||
ok = (left is not None and left != "" and left != 0)
|
||||
ok = ok if op == "exists" else (not ok)
|
||||
return (not ok) if negate else ok
|
||||
|
||||
if left is None:
|
||||
return False if not negate else True
|
||||
|
||||
if op in ("=", "==", "!=", ">", ">=", "<", "<="):
|
||||
a = _to_float(left)
|
||||
b = _to_float(val)
|
||||
if a is None or b is None:
|
||||
return False if not negate else True
|
||||
result = {
|
||||
"=": a == b,
|
||||
"==": a == b,
|
||||
"!=": a != b,
|
||||
">": a > b,
|
||||
">=": a >= b,
|
||||
"<": a < b,
|
||||
"<=": a <= b,
|
||||
}[op]
|
||||
return (not result) if negate else result
|
||||
|
||||
if op in ("on", "before", "after", "between") and field in ("issued", "mtime", "year", "month", "day"):
|
||||
if field == "mtime":
|
||||
try:
|
||||
L = dt.date.fromtimestamp(int(left))
|
||||
except Exception:
|
||||
L = None
|
||||
elif field == "issued":
|
||||
L = _val_to_date(left)
|
||||
else:
|
||||
try:
|
||||
yy = int(_get_field_value(it, "year") or 1)
|
||||
mm = int(_get_field_value(it, "month") or 1)
|
||||
dd = int(_get_field_value(it, "day") or 1)
|
||||
L = dt.date(yy, mm, dd)
|
||||
except Exception:
|
||||
L = None
|
||||
if L is None:
|
||||
return False if not negate else True
|
||||
|
||||
if op == "between":
|
||||
parts = [p.strip() for p in val.split(",")]
|
||||
if len(parts) != 2:
|
||||
return False if not negate else True
|
||||
D1 = _to_date(parts[0])
|
||||
D2 = _to_date(parts[1])
|
||||
if not D1 or not D2:
|
||||
return False if not negate else True
|
||||
result = (D1 <= L <= D2)
|
||||
else:
|
||||
D = _to_date(val)
|
||||
if not D:
|
||||
return False if not negate else True
|
||||
result = (L == D) if op == "on" else ((L < D) if op == "before" else (L > D))
|
||||
return (not result) if negate else result
|
||||
|
||||
A = str(left)
|
||||
if op == "regex":
|
||||
try:
|
||||
result = bool(re.search(val, A, flags=re.IGNORECASE))
|
||||
except re.error:
|
||||
result = False
|
||||
else:
|
||||
a = A.lower()
|
||||
b = val.lower()
|
||||
result = (
|
||||
(op == "contains" and b in a)
|
||||
or (op == "equals" and a == b)
|
||||
or (op == "startswith" and a.startswith(b))
|
||||
or (op == "endswith" and a.endswith(b))
|
||||
)
|
||||
return (not result) if negate else result
|
||||
|
||||
def _matches_groups(it: fs_index.Item, groups: list[dict]) -> bool:
|
||||
valid_groups = [g for g in (groups or []) if g.get("rules")]
|
||||
if not valid_groups:
|
||||
return False
|
||||
for g in valid_groups:
|
||||
rules = g.get("rules") or []
|
||||
if all(_rule_true(it, r) for r in rules):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _sort_key(item: fs_index.Item, name: str):
|
||||
n = (name or "").lower()
|
||||
m = item.meta or {}
|
||||
if n in ("issued_desc", "issued"):
|
||||
t = _issued_tuple(m) or (0, 0, 0)
|
||||
return (t[0], t[1], t[2])
|
||||
if n == "series_number":
|
||||
series = (m.get("series") or item.name or "").lower()
|
||||
try:
|
||||
num = int(float(m.get("number", "0")))
|
||||
except ValueError:
|
||||
num = 10**9
|
||||
return (series, num)
|
||||
if n == "title":
|
||||
return ((m.get("title") or item.name).lower(),)
|
||||
if n == "publisher":
|
||||
return ((m.get("publisher") or "").lower(), (m.get("series") or "").lower())
|
||||
return ((item.name or "").lower(),)
|
||||
|
||||
def _distinct_latest_by_series(items: list[fs_index.Item]) -> list[fs_index.Item]:
|
||||
best: Dict[str, fs_index.Item] = {}
|
||||
def rank(x: fs_index.Item):
|
||||
m = x.meta or {}
|
||||
t = _issued_tuple(m) or (0, 0, 0)
|
||||
try:
|
||||
num = int(float(m.get("number", "0")))
|
||||
except ValueError:
|
||||
num = -1
|
||||
return (t[0], t[1], t[2], num)
|
||||
for it in items:
|
||||
series = (it.meta or {}).get("series")
|
||||
if not series:
|
||||
continue
|
||||
key = series.lower()
|
||||
cur = best.get(key)
|
||||
if cur is None or rank(it) > rank(cur):
|
||||
best[key] = it
|
||||
no_series = [it for it in items if not (it.meta or {}).get("series")]
|
||||
return list(best.values()) + no_series
|
||||
|
||||
|
||||
# OPDS Smart Lists navigation & feeds
|
||||
@app.get("/opds/smart", response_class=Response)
|
||||
def opds_smart_lists(_=Depends(require_basic)):
|
||||
lists = _load_smartlists()
|
||||
tpl = env.get_template("entry.xml.j2")
|
||||
entries = []
|
||||
for sl in lists:
|
||||
href = f"/opds/smart/{quote(sl['slug'])}"
|
||||
entries.append(
|
||||
tpl.render(
|
||||
entry_id=f"{SERVER_BASE.rstrip('/')}{_abs_path(href)}",
|
||||
updated=now_rfc3339(),
|
||||
title=sl["name"],
|
||||
is_dir=True,
|
||||
href=_abs_path(href),
|
||||
)
|
||||
)
|
||||
xml = _feed(entries, title="Smart Lists", self_href="/opds/smart")
|
||||
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
|
||||
|
||||
@app.get("/opds/smart/{slug}", response_class=Response)
|
||||
def opds_smart_list(slug: str, page: int = 1, _=Depends(require_basic)):
|
||||
lists = _load_smartlists()
|
||||
sl = next((x for x in lists if x.get("slug") == slug), None)
|
||||
if not sl:
|
||||
raise HTTPException(404, "Smart list not found")
|
||||
|
||||
groups = sl.get("groups") or []
|
||||
matches = [it for it in INDEX if (not it.is_dir) and _matches_groups(it, groups)]
|
||||
|
||||
sort_name = (sl.get("sort") or "issued_desc").lower()
|
||||
reverse = sort_name in ("issued_desc",)
|
||||
matches.sort(key=lambda it: _sort_key(it, sort_name), reverse=reverse)
|
||||
|
||||
if (sl.get("distinct_by") or "") == "series":
|
||||
matches = _distinct_latest_by_series(matches)
|
||||
|
||||
limit = int(sl.get("limit") or 0)
|
||||
if limit > 0:
|
||||
matches = matches[:limit]
|
||||
|
||||
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/smart/{quote(slug)}?page={page}"
|
||||
next_href = f"/opds/smart/{quote(slug)}?page={page+1}" if end < len(matches) else None
|
||||
xml = _feed(entries_xml, title=f"{sl['name']}", self_href=self_href, next_href=next_href)
|
||||
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
|
||||
|
||||
|
||||
# Smart Lists HTML & JSON API
|
||||
@app.get("/search", response_class=HTMLResponse)
|
||||
def smartlists_page(_=Depends(require_basic)):
|
||||
tpl = env.get_template("smartlists.html")
|
||||
return HTMLResponse(tpl.render())
|
||||
|
||||
@app.get("/smartlists.json", response_class=JSONResponse)
|
||||
def smartlists_get(_=Depends(require_basic)):
|
||||
return JSONResponse(_load_smartlists())
|
||||
|
||||
@app.post("/smartlists.json", response_class=JSONResponse)
|
||||
def smartlists_post(payload: list[dict], _=Depends(require_basic)):
|
||||
lists: list[dict] = []
|
||||
for sl in (payload or []):
|
||||
name = (sl.get("name") or "Smart List").strip()
|
||||
slug = _slugify(sl.get("slug") or name)
|
||||
groups = sl.get("groups") or []
|
||||
|
||||
norm_groups = []
|
||||
for g in groups:
|
||||
rules = []
|
||||
for r in (g.get("rules") or []):
|
||||
op = (r.get("op") or "contains").lower()
|
||||
val = (r.get("value") or "")
|
||||
if not val.strip() and op not in ("exists", "missing"):
|
||||
continue
|
||||
rules.append(
|
||||
{
|
||||
"field": (r.get("field") or "").lower(),
|
||||
"op": op,
|
||||
"value": val,
|
||||
"not": bool(r.get("not", False)),
|
||||
}
|
||||
)
|
||||
if rules:
|
||||
norm_groups.append({"rules": rules})
|
||||
|
||||
sort = (sl.get("sort") or "issued_desc").lower()
|
||||
limit = int(sl.get("limit") or 0)
|
||||
distinct_by = (sl.get("distinct_by") or "")
|
||||
lists.append(
|
||||
{
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"groups": norm_groups,
|
||||
"sort": sort,
|
||||
"limit": limit,
|
||||
"distinct_by": distinct_by,
|
||||
}
|
||||
)
|
||||
_save_smartlists(lists)
|
||||
return JSONResponse({"ok": True, "count": len(lists)})
|
||||
|
||||
@@ -1,83 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-bs-theme="auto">
|
||||
<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>
|
||||
<title>ComicOPDS Library Dashboard</title>
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (optional) -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
|
||||
<style>
|
||||
body { background: var(--bs-body-bg); }
|
||||
.metric { display:flex; align-items:center; gap:.75rem; }
|
||||
.metric .bi { font-size:1.6rem; opacity:.8; }
|
||||
.metric .value { font-size:1.9rem; font-weight:700; line-height:1; }
|
||||
.metric .label { color: var(--bs-secondary-color); font-size:.85rem; }
|
||||
.chart-card canvas { width: 100% !important; height: 360px !important; }
|
||||
.footer-note { color: var(--bs-secondary-color); }
|
||||
.kpis .card { transition: transform .15s ease; }
|
||||
.kpis .card:hover { transform: translateY(-2px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ComicOPDS — Library Dashboard</h1>
|
||||
<div class="sub">Last updated: <span id="lastUpdated">…</span> • Covers: <span id="covers">…</span></div>
|
||||
</header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-semibold" href="#"><i class="bi bi-book-half me-2"></i>ComicOPDS</a>
|
||||
<span class="navbar-text small text-secondary">
|
||||
<span id="lastUpdated">—</span> • Covers: <span id="covers">—</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
<main class="container my-4">
|
||||
<!-- KPIs -->
|
||||
<div class="row g-3 kpis">
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="metric">
|
||||
<i class="bi bi-collection"></i>
|
||||
<div>
|
||||
<div class="value" id="totalComics">0</div>
|
||||
<div class="label">Total comics</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="metric">
|
||||
<i class="bi bi-layers"></i>
|
||||
<div>
|
||||
<div class="value" id="uniqueSeries">0</div>
|
||||
<div class="label">Unique series</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="metric">
|
||||
<i class="bi bi-building"></i>
|
||||
<div>
|
||||
<div class="value" id="uniquePublishers">0</div>
|
||||
<div class="label">Publishers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="metric">
|
||||
<i class="bi bi-filetype-zip"></i>
|
||||
<div>
|
||||
<div class="value" id="formats"></div>
|
||||
<div class="card-header fw-semibold">Formats breakdown</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- Charts -->
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Publishers distribution</div>
|
||||
<div class="card-body"><canvas id="publishersChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Top publishers (others grouped).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Publication timeline</div>
|
||||
<div class="card-body"><canvas id="timelineChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Issues per year.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Formats breakdown</div>
|
||||
<div class="card-body"><canvas id="formatsChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Mostly CBZ by design, but shown here.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 chart-card">
|
||||
<div class="card-header fw-semibold">Top writers</div>
|
||||
<div class="card-body"><canvas id="writersChart"></canvas></div>
|
||||
<div class="card-footer small footer-note">Top 15 writers across your library.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="container my-4 small footer-note">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>ComicOPDS Dashboard</span>
|
||||
<span><a href="/opds" class="link-secondary text-decoration-none"><i class="bi bi-rss me-1"></i>OPDS feed</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap JS (optional) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Nice default chart options (Bootstrap-friendly)
|
||||
const baseOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8 } },
|
||||
tooltip: { mode: 'index', intersect: false }
|
||||
},
|
||||
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||||
scales: {
|
||||
x: { ticks: { maxRotation: 0, autoSkip: true } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } }
|
||||
}
|
||||
};
|
||||
|
||||
async function load() {
|
||||
const r = await fetch("/stats.json", { credentials: "include" });
|
||||
const data = await r.json();
|
||||
document.getElementById("lastUpdated").textContent = new Date(data.last_updated * 1000).toLocaleString();
|
||||
const res = await fetch("/stats.json", { credentials: "include" });
|
||||
const data = await res.json();
|
||||
|
||||
// KPIs
|
||||
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(" ");
|
||||
document.getElementById("formats").textContent =
|
||||
Object.entries(data.formats).map(([k,v]) => `${k.toUpperCase()}: ${v}`).join(" ");
|
||||
|
||||
// Charts
|
||||
// 1) Publishers doughnut (sorted by share)
|
||||
const pubs = data.publishers;
|
||||
const pubsSorted = pubs.labels.map((l,i)=>({l, v: pubs.values[i]}))
|
||||
.sort((a,b)=>b.v-a.v);
|
||||
new Chart(document.getElementById("publishersChart"), {
|
||||
type: "doughnut",
|
||||
data: { labels: data.publishers.labels, datasets: [{ data: data.publishers.values }] },
|
||||
options: { responsive: true, plugins: { legend: { position: "bottom" } } }
|
||||
data: {
|
||||
labels: pubsSorted.map(x=>x.l),
|
||||
datasets: [{ data: pubsSorted.map(x=>x.v) }]
|
||||
},
|
||||
options: {
|
||||
...baseOptions,
|
||||
cutout: "60%",
|
||||
scales: {} // none for doughnut
|
||||
}
|
||||
});
|
||||
|
||||
// 2) Timeline line chart with area fill
|
||||
new Chart(document.getElementById("timelineChart"), {
|
||||
type: "line",
|
||||
data: { labels: data.timeline.labels, datasets: [{ label: "Issues per year", data: data.timeline.values, tension: 0.2 }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } } }
|
||||
data: {
|
||||
labels: data.timeline.labels,
|
||||
datasets: [{
|
||||
label: "Issues per year",
|
||||
data: data.timeline.values,
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
}]
|
||||
},
|
||||
options: { ...baseOptions }
|
||||
});
|
||||
const fmtLabels = Object.keys(data.formats), fmtValues = Object.values(data.formats);
|
||||
|
||||
// 3) Formats bar
|
||||
const fmtLabels = Object.keys(data.formats);
|
||||
const fmtValues = Object.values(data.formats);
|
||||
new Chart(document.getElementById("formatsChart"), {
|
||||
type: "bar",
|
||||
data: { labels: fmtLabels, datasets: [{ label: "Files", data: fmtValues }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } } }
|
||||
options: { ...baseOptions }
|
||||
});
|
||||
|
||||
// 4) Top writers (horizontal bar)
|
||||
new Chart(document.getElementById("writersChart"), {
|
||||
type: "bar",
|
||||
data: { labels: data.top_writers.labels, datasets: [{ label: "Issues", data: data.top_writers.values }] },
|
||||
options: { indexAxis: "y", responsive: true, plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } }
|
||||
data: {
|
||||
labels: data.top_writers.labels,
|
||||
datasets: [{ label: "Issues", data: data.top_writers.values }]
|
||||
},
|
||||
options: {
|
||||
...baseOptions,
|
||||
indexAxis: "y"
|
||||
}
|
||||
});
|
||||
}
|
||||
load();
|
||||
|
||||
260
app/templates/smartlists.html
Normal file
260
app/templates/smartlists.html
Normal file
@@ -0,0 +1,260 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>ComicOPDS — Smart Lists</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
.group-card { border-left: 4px solid var(--bs-primary); }
|
||||
.rule-row .form-select, .rule-row .form-control { min-width: 10rem; }
|
||||
.monosmall { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-semibold" href="/dashboard"><i class="bi bi-book-half me-2"></i>ComicOPDS</a>
|
||||
<div class="ms-auto small text-secondary">
|
||||
<a class="text-decoration-none me-3" href="/opds"><i class="bi bi-rss me-1"></i>OPDS</a>
|
||||
<a class="text-decoration-none" href="/dashboard"><i class="bi bi-speedometer2 me-1"></i>Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container my-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold">Create / Edit Smart List</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">List name</label>
|
||||
<input id="listName" class="form-control" placeholder="e.g., Batman 2024"/>
|
||||
</div>
|
||||
|
||||
<div id="groups" class="vstack gap-3"></div>
|
||||
<button class="btn btn-outline-primary mt-2" id="addGroup"><i class="bi bi-plus-lg me-1"></i>Add group (OR)</button>
|
||||
|
||||
<hr class="my-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label">Sort</label>
|
||||
<select id="sort" class="form-select">
|
||||
<option value="issued_desc">Issued (newest first)</option>
|
||||
<option value="series_number">Series + Number</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label">Limit</label>
|
||||
<input id="limit" class="form-control" type="number" min="0" value="0" />
|
||||
<div class="form-text">0 = unlimited</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 d-flex align-items-end">
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="distinctSeries">
|
||||
<label class="form-check-label" for="distinctSeries">Distinct by series (latest)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" id="saveList"><i class="bi bi-save me-1"></i>Save list</button>
|
||||
<button class="btn btn-outline-secondary" id="resetForm">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="small text-secondary mt-3">
|
||||
Rules within a group are <b>AND</b>’d. Groups are <b>OR</b>’d.<br>
|
||||
Date formats: <span class="monosmall">YYYY</span>, <span class="monosmall">YYYY-MM</span>, <span class="monosmall">YYYY-MM-DD</span>.<br>
|
||||
Fields: <span class="monosmall">series, title, number, volume, publisher, imprint, writer, characters, teams, tags, year, month, day, issued, languageiso, comicvineissue, rel, ext, size, mtime, has_thumb, has_meta</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold">Your Smart Lists</div>
|
||||
<div class="card-body">
|
||||
<div id="lists" class="row g-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<template id="groupTpl">
|
||||
<div class="card group-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>Group (AND)</div>
|
||||
<button class="btn btn-sm btn-outline-danger remove-group"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<div class="card-body vstack gap-2 rules"></div>
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-sm btn-outline-primary add-rule"><i class="bi bi-plus-lg me-1"></i>Add rule</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="ruleTpl">
|
||||
<div class="rule-row d-flex flex-wrap align-items-center gap-2">
|
||||
<input class="form-check-input not-flag" type="checkbox" title="NOT">
|
||||
<span class="text-secondary small">NOT</span>
|
||||
<select class="form-select form-select-sm field">
|
||||
<option>series</option><option>title</option><option>number</option><option>volume</option>
|
||||
<option>publisher</option><option>imprint</option><option>writer</option>
|
||||
<option>characters</option><option>teams</option><option>tags</option>
|
||||
<option>year</option><option>month</option><option>day</option><option>issued</option>
|
||||
<option>languageiso</option><option>comicvineissue</option>
|
||||
<option>rel</option><option>ext</option><option>size</option><option>mtime</option>
|
||||
<option>has_thumb</option><option>has_meta</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm op">
|
||||
<option value="contains">contains</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="startswith">starts with</option>
|
||||
<option value="endswith">ends with</option>
|
||||
<option value="regex">regex</option>
|
||||
<option value="=">= (numeric)</option><option value="!=">!= (numeric)</option>
|
||||
<option value=">">> (numeric)</option><option value=">=">>= (numeric)</option>
|
||||
<option value="<">< (numeric)</option><option value="<="><= (numeric)</option>
|
||||
<option value="on">on (date)</option><option value="before">before (date)</option><option value="after">after (date)</option>
|
||||
<option value="between">between (date,date)</option>
|
||||
<option value="exists">exists</option><option value="missing">missing</option>
|
||||
</select>
|
||||
<input class="form-control form-control-sm value" placeholder="value (leave blank for exists/missing)" />
|
||||
<button class="btn btn-sm btn-outline-danger remove-rule"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const listsEl = document.getElementById('lists');
|
||||
const groupsEl = document.getElementById('groups');
|
||||
const groupTpl = document.getElementById('groupTpl');
|
||||
const ruleTpl = document.getElementById('ruleTpl');
|
||||
|
||||
function addGroup(data) {
|
||||
const node = groupTpl.content.cloneNode(true);
|
||||
const card = node.querySelector('.group-card');
|
||||
const rules = node.querySelector('.rules');
|
||||
const addBtn = node.querySelector('.add-rule');
|
||||
const rmGroup = node.querySelector('.remove-group');
|
||||
addBtn.onclick = () => addRule(rules);
|
||||
rmGroup.onclick = () => card.remove();
|
||||
groupsEl.appendChild(node);
|
||||
(data?.rules?.length ? data.rules : [{}]).forEach(r => addRule(groupsEl.lastElementChild.querySelector('.rules'), r));
|
||||
}
|
||||
|
||||
function addRule(container, data) {
|
||||
const node = ruleTpl.content.cloneNode(true);
|
||||
const row = node.querySelector('.rule-row');
|
||||
if (data) {
|
||||
row.querySelector('.not-flag').checked = !!data.not;
|
||||
row.querySelector('.field').value = data.field || 'series';
|
||||
row.querySelector('.op').value = data.op || 'contains';
|
||||
row.querySelector('.value').value = data.value || '';
|
||||
}
|
||||
row.querySelector('.remove-rule').onclick = () => row.remove();
|
||||
container.appendChild(node);
|
||||
}
|
||||
|
||||
function readGroups() {
|
||||
return Array.from(document.querySelectorAll('.group-card')).map(g => {
|
||||
const rules = Array.from(g.querySelectorAll('.rule-row')).map(r => ({
|
||||
not: r.querySelector('.not-flag').checked,
|
||||
field: r.querySelector('.field').value,
|
||||
op: r.querySelector('.op').value,
|
||||
value: r.querySelector('.value').value
|
||||
}));
|
||||
return { rules: rules.filter(x => x.op === 'exists' || x.op === 'missing' || (x.value && x.value.trim())) };
|
||||
}).filter(g => g.rules.length);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('listName').value = '';
|
||||
document.getElementById('sort').value = 'issued_desc';
|
||||
document.getElementById('limit').value = '0';
|
||||
document.getElementById('distinctSeries').checked = false;
|
||||
groupsEl.innerHTML = '';
|
||||
addGroup({rules:[{field:'series',op:'contains',value:''}]});
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
const r = await fetch('/smartlists.json', { credentials:'include' });
|
||||
const data = await r.json();
|
||||
listsEl.innerHTML = '';
|
||||
data.forEach(l => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-12';
|
||||
col.innerHTML = `
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">${l.name}</div>
|
||||
<div class="small text-secondary">
|
||||
${(l.groups||[]).map((g,i)=>'Group '+(i+1)+': '+g.rules.map(r => (r.not?'NOT ':'')+r.field+' '+r.op+' "'+r.value.replace(/"/g,'"')+'"').join(' AND ')).join(' <b>OR</b> ') || '—'}
|
||||
</div>
|
||||
<div class="small text-secondary mt-1">Sort: ${l.sort || 'issued_desc'} • Limit: ${l.limit || 0} • Distinct: ${l.distinct_by || '—'}</div>
|
||||
<div class="mt-2"><a class="btn btn-sm btn-outline-primary" href="/opds/smart/${l.slug}">Open in OPDS</a></div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary edit">Edit</button>
|
||||
<button class="btn btn-outline-danger delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
col.querySelector('.edit').onclick = () => {
|
||||
document.getElementById('listName').value = l.name;
|
||||
document.getElementById('sort').value = l.sort || 'issued_desc';
|
||||
document.getElementById('limit').value = l.limit || 0;
|
||||
document.getElementById('distinctSeries').checked = (l.distinct_by === 'series');
|
||||
groupsEl.innerHTML = '';
|
||||
(l.groups || []).forEach(g => addGroup(g));
|
||||
};
|
||||
col.querySelector('.delete').onclick = async () => {
|
||||
if (!confirm('Delete this smart list?')) return;
|
||||
await saveLists(data.filter(x => x.slug !== l.slug));
|
||||
await loadLists();
|
||||
};
|
||||
listsEl.appendChild(col);
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function saveLists(lists) {
|
||||
await fetch('/smartlists.json', {
|
||||
method: 'POST', headers:{'Content-Type':'application/json'}, credentials:'include',
|
||||
body: JSON.stringify(lists)
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('addGroup').onclick = () => addGroup();
|
||||
document.getElementById('resetForm').onclick = resetForm;
|
||||
|
||||
document.getElementById('saveList').onclick = async () => {
|
||||
const name = document.getElementById('listName').value.trim();
|
||||
const groups = readGroups();
|
||||
if (!name || groups.length === 0) { alert('Please provide a name and at least one rule.'); return; }
|
||||
const sort = document.getElementById('sort').value;
|
||||
const limit = parseInt(document.getElementById('limit').value || '0', 10);
|
||||
const distinct_by = document.getElementById('distinctSeries').checked ? 'series' : '';
|
||||
|
||||
const lists = await loadLists();
|
||||
const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'list');
|
||||
const existing = lists.find(l => l.slug === slug) || lists.find(l => l.name === name);
|
||||
const record = { name, slug, groups, sort, limit, distinct_by };
|
||||
if (existing) Object.assign(existing, record); else lists.push(record);
|
||||
await saveLists(lists);
|
||||
resetForm();
|
||||
await loadLists();
|
||||
};
|
||||
|
||||
// init
|
||||
resetForm();
|
||||
loadLists();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user