Fixed dashboard and misc.

This commit is contained in:
2025-09-05 18:57:45 +02:00
parent 67a40003bb
commit dad5277513
3 changed files with 934 additions and 202 deletions

View File

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

View File

@@ -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();

View 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=">">&gt; (numeric)</option><option value=">=">&gt;= (numeric)</option>
<option value="<">&lt; (numeric)</option><option value="<=">&lt;= (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,'&quot;')+'"').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>