From dad5277513bba50615057525d34ee199a56163f3 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Fri, 5 Sep 2025 18:57:45 +0200 Subject: [PATCH] Fixed dashboard and misc. --- app/main.py | 631 ++++++++++++++++++++++++++-------- app/templates/dashboard.html | 245 ++++++++++--- app/templates/smartlists.html | 260 ++++++++++++++ 3 files changed, 934 insertions(+), 202 deletions(-) create mode 100644 app/templates/smartlists.html diff --git a/app/main.py b/app/main.py index d4cee66..2cd657b 100644 --- a/app/main.py +++ b/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 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 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] \ No newline at end of file + 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)}) diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index f214246..b908b2d 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,83 +1,226 @@ - + - ComicOPDS — Library Dashboard - + ComicOPDS Library Dashboard + + + + + + + + -
-

ComicOPDS — Library Dashboard

-
Last updated: • Covers:
-
+ -
-
-
Total comics
0
-
Unique series
0
-
Publishers
0
-
Formats
-
+
+ +
+
+
+
+
+ +
+
0
+
Total comics
+
+
+
+
+
+
+
+
+
+ +
+
0
+
Unique series
+
+
+
+
+
+
+
+
+
+ +
+
0
+
Publishers
+
+
+
+
+
+
+
+
+
+ +
+
+
Formats breakdown
+
+
+
+
+
+
-
-

Publishers distribution

-

Publication timeline

-

Formats breakdown

-

Top writers

-
+ +
+
+
+
Publishers distribution
+
+ +
+
+ +
+
+
Publication timeline
+
+ +
+
+ +
+
+
Formats breakdown
+
+ +
+
+ +
+
+
Top writers
+
+ +
+
+
+ + + + + + +