Search now working in Panels

This commit is contained in:
2025-09-08 17:17:46 +02:00
parent 3d40f51169
commit 7c0fd81207
3 changed files with 60 additions and 216 deletions

View File

@@ -1,5 +1,5 @@
from __future__ import annotations
import logging
from fastapi import FastAPI, Query, HTTPException, Request, Response, Depends, Header
from fastapi.responses import (
StreamingResponse, FileResponse, PlainTextResponse, HTMLResponse, JSONResponse
@@ -14,8 +14,10 @@ import os
import re
import json
import zipfile
import hashlib, sys
import hashlib
from PIL import Image
import sys
import logging
from math import ceil
from .config import LIBRARY_DIR, PAGE_SIZE, SERVER_BASE, URL_PREFIX
@@ -24,18 +26,24 @@ from .auth import require_basic
from .thumbs import have_thumb, generate_thumb
from . import db # SQLite adapter
# -------------------- Logging --------------------
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
# App logger to STDOUT
app_logger = logging.getLogger("comicopds")
app_logger.setLevel(LOG_LEVEL)
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
app_logger.handlers.clear()
app_logger.addHandler(_handler)
app_logger.propagate = False # don't duplicate into uvicorn.error
app_logger.propagate = False
def _mask_headers(h: dict) -> dict:
masked = {}
for k, v in h.items():
if k.lower() in ("authorization", "cookie", "set-cookie", "x-api-key"):
masked[k] = "***"
else:
masked[k] = v
return masked
# -------------------- FastAPI & Jinja --------------------
app = FastAPI(title="ComicOPDS")
@@ -45,33 +53,27 @@ env = Environment(
autoescape=select_autoescape(enabled_extensions=("xml", "html", "j2"), default=True),
)
# def _mask_headers(h: dict) -> dict:
# masked = {}
# for k, v in h.items():
# if k.lower() in ("authorization", "cookie", "set-cookie", "x-api-key"):
# masked[k] = "***"
# else:
# masked[k] = v
# return masked
# @app.middleware("http")
# async def log_requests(request: Request, call_next):
# app_logger.info(f"--> {request.method} {request.url.path}?{request.url.query}")
# qp = dict(request.query_params)
# if qp:
# app_logger.info(f" query: {qp}")
# app_logger.info(f" headers: {_mask_headers(dict(request.headers))}")
# resp = await call_next(request)
# app_logger.info(f"<-- {request.method} {request.url.path} {resp.status_code}")
# return resp
@app.middleware("http")
async def log_requests(request: Request, call_next):
try:
app_logger.info(f"--> {request.method} {request.url.path}?{request.url.query}")
qp = dict(request.query_params)
if qp:
app_logger.info(f" query: {qp}")
app_logger.info(f" headers: {_mask_headers(dict(request.headers))}")
except Exception:
pass
resp = await call_next(request)
try:
app_logger.info(f"<-- {request.method} {request.url.path} {resp.status_code}")
except Exception:
pass
return resp
# -------------------- Index state (background) --------------------
_INDEX_STATUS = {
"running": False,
"phase": "idle", # "counting" | "indexing" | "idle"
"phase": "idle",
"total": 0,
"done": 0,
"current": "",
@@ -79,7 +81,6 @@ _INDEX_STATUS = {
"ended_at": 0.0,
}
_INDEX_LOCK = threading.Lock()
AUTO_INDEX_ON_START = os.getenv("AUTO_INDEX_ON_START", "false").strip().lower() not in ("0","false","no","off")
# -------------------- Small helpers --------------------
@@ -142,7 +143,7 @@ def _index_progress(rel: str):
_INDEX_STATUS["current"] = rel
def _run_scan():
"""Background scanner thread: writes into SQLite using its own connection."""
"""Background scanner: writes into SQLite using its own connection."""
conn = db.connect()
try:
db.begin_scan(conn)
@@ -186,7 +187,8 @@ def _run_scan():
db.prune_stale(conn)
_set_status(phase="idle", running=False, ended_at=time.time(), current="")
except Exception:
except Exception as e:
app_logger.error(f"scan error: {e}")
_set_status(phase="idle", running=False, ended_at=time.time())
finally:
try:
@@ -203,13 +205,12 @@ def _start_scan(force=False):
@app.on_event("startup")
def startup():
if not LIBRARY_DIR.exists():
raise RuntimeError(f"CONTENT_BASE_DIR does not exist: {LIBRARY_DIR}")
raise RuntimeError(f"CONTENT_BASE_DIR does not exist: {LIBRARY_DIR}")
if AUTO_INDEX_ON_START:
_start_scan(force=True)
return
# Skip auto-index if DB already has rows
conn = db.connect()
try:
has_any = conn.execute("SELECT EXISTS(SELECT 1 FROM items LIMIT 1)").fetchone()[0] == 1
@@ -228,7 +229,6 @@ VALID_PAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".t
def _cbz_list_pages(cbz_path: Path) -> list[str]:
with zipfile.ZipFile(cbz_path, "r") as zf:
names = [n for n in zf.namelist() if Path(n).suffix.lower() in VALID_PAGE_EXTS and not n.endswith("/")]
# natural sort
import re as _re
def natkey(s: str):
return [int(t) if t.isdigit() else t.lower() for t in _re.split(r"(\d+)", s)]
@@ -314,7 +314,7 @@ def _feed(entries_xml: List[str], title: str, self_href: str,
os_total: Optional[int] = None,
os_start: Optional[int] = None,
os_items: Optional[int] = None,
search_href: str = "/opds/v1.2/search.xml",
search_href: str = "/opds/search.xml",
start_href_override: Optional[str] = None):
tpl = env.get_template("feed.xml.j2")
base = SERVER_BASE.rstrip("/")
@@ -333,7 +333,6 @@ def _feed(entries_xml: List[str], title: str, self_href: str,
os_items=os_items,
)
def _entry_xml_from_row(row) -> str:
tpl = env.get_template("entry.xml.j2")
base = SERVER_BASE.rstrip("/")
@@ -354,7 +353,7 @@ def _entry_xml_from_row(row) -> str:
download_href = f"/download?path={quote(rel)}"
stream_href = f"/stream?path={quote(rel)}"
# PSE: template URL & count (Komga-style)
# PSE: template URL & count (Panels-compatible)
pse_template = f"/pse/page?path={quote(rel)}&page={{pageNumber}}"
page_count = 0
try:
@@ -369,7 +368,6 @@ def _entry_xml_from_row(row) -> str:
if (rget(row, "ext") or "").lower() == "cbz":
p = have_thumb(rel, comicvine_issue) or generate_thumb(rel, abs_file, comicvine_issue)
if p:
# well use the same image for both full image and thumbnail rels
image_abs = f"{base}{_abs_url('/thumb?path=' + quote(rel))}"
thumb_href_abs = image_abs
@@ -433,52 +431,36 @@ def browse(path: str = Query("", description="Relative folder path"), page: int
def root(_=Depends(require_basic)):
return browse(path="", page=1)
# ---- OpenSearch (descriptor) + Search results (OPDS 1.x) ----
@app.get("/opds/search.xml", response_class=Response)
def opensearch_compat(_=Depends(require_basic)):
# same doc, for clients that still hit /opds/search.xml
def opensearch_description(_=Depends(require_basic)):
tpl = env.get_template("search-description.xml.j2")
xml = tpl.render(base=SERVER_BASE.rstrip("/"))
return Response(content=xml, media_type="application/opensearchdescription+xml")
def _search_feed(request: Request, term: str, page_param: int | None,
start_index_param: int | None, count_param: int | None):
qp = request.query_params
items = int(qp.get("count") or (count_param if count_param is not None else PAGE_SIZE))
items = max(1, min(items, 100))
if page_param is not None:
pg = max(1, int(page_param))
elif "startPage" in qp:
try:
pg = max(1, int(qp.get("startPage")))
except Exception:
pg = 1
elif (start_index_param is not None) or ("startIndex" in qp):
try:
si = int(start_index_param if start_index_param is not None else qp.get("startIndex"))
except Exception:
si = 0
si = max(0, si - 1) if si > 0 else 0
pg = max(1, int(ceil((si + 1) / items))) if items else 1
else:
pg = 1
@app.get("/opds/search", response_class=Response)
def opds_search(query: str | None = Query(None, alias="query"),
page: int | None = Query(None),
request: Request = None,
_=Depends(require_basic)):
term = (query or "").strip()
if not term:
return browse(path="", page=1)
items = PAGE_SIZE
pg = max(1, int(page or 1))
offset = (pg - 1) * items
conn = db.connect()
try:
rows = db.search_q(conn, term, items, offset)
try:
total = db.search_count(conn, term)
except Exception:
total = offset + len(rows)
total = db.search_count(conn, term)
finally:
conn.close()
entries_xml = [_entry_xml_from_row(r) for r in rows]
# Build self/next using v1.2 path & 'query' param
self_href = f"/opds/v1.2/search?query={quote(term)}&page={pg}"
next_href = f"/opds/v1.2/search?query={quote(term)}&page={pg+1}" if (offset + len(rows)) < total else None
self_href = f"/opds/search?query={quote(term)}&page={pg}"
next_href = f"/opds/search?query={quote(term)}&page={pg+1}" if (offset + len(rows)) < total else None
xml = _feed(
entries_xml,
@@ -488,32 +470,11 @@ def _search_feed(request: Request, term: str, page_param: int | None,
os_total=total,
os_start=offset + 1 if total > 0 else 0,
os_items=items,
search_href="/opds/v1.2/search.xml",
start_href_override="/opds", # keep your normal start at /opds
search_href="/opds/search.xml",
start_href_override="/opds",
)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/opds/v1.2/search.xml", response_class=Response)
def opensearch_v12(_=Depends(require_basic)):
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/v1.2/search", response_class=Response)
def opds_search_v12(query: str | None = Query(None, alias="query"),
page: int | None = Query(None),
startIndex: int | None = Query(None),
count: int | None = Query(None),
request: Request = None,
_=Depends(require_basic)):
term = (query or "").strip()
if not term:
# Some clients may call with empty query → return root catalog
return browse(path="", page=1)
return _search_feed(request, term, page, startIndex, count)
# -------------------- File endpoints --------------------
def _abspath(rel: str) -> Path:
p = (LIBRARY_DIR / rel).resolve()
@@ -675,9 +636,9 @@ def pse_page(path: str = Query(...), page: int = Query(0, ge=0), _=Depends(requi
if not pages or page >= len(pages):
raise HTTPException(404, "Page not found")
inner = pages[page] # zero-based
inner = pages[page] # zero-based
cache_dir = _book_cache_dir(path)
dest = cache_dir / f"{page+1:04d}.jpg" # keep filenames 1-based
dest = cache_dir / f"{page+1:04d}.jpg"
out = _ensure_page_jpeg(abs_cbz, inner, dest)
return FileResponse(out, media_type="image/jpeg")
@@ -840,116 +801,3 @@ def index_status(_=Depends(require_basic)):
def admin_reindex(_=Depends(require_basic)):
_start_scan(force=True)
return JSONResponse({"ok": True, "started": True})
# Komga-style root catalog (alias)
@app.get("/opds/v1.2/catalog", response_class=Response)
def opds_v12_catalog(page: int = 1, _=Depends(require_basic)):
# identical to your root browse, just pinned under /opds/v1.2
path = ""
conn = db.connect()
try:
total = db.children_count(conn, path)
start = (page - 1) * PAGE_SIZE
rows = db.children_page(conn, path, PAGE_SIZE, start)
finally:
conn.close()
entries_xml = [_entry_xml_from_row(r) for r in rows]
# (optional) Smart Lists in page 1
if page == 1:
tpl = env.get_template("entry.xml.j2")
base = SERVER_BASE.rstrip("/")
smart_href = "/opds/smart"
entries_xml = [tpl.render(
entry_id=f"{base}{smart_href}",
updated=now_rfc3339(),
title="📁 Smart Lists",
is_dir=True,
href_abs=f"{base}{smart_href}",
)] + entries_xml
self_href = f"/opds/v1.2/catalog?page={page}"
next_href = f"/opds/v1.2/catalog?page={page+1}" if (start + PAGE_SIZE) < total else None
xml = _feed(
entries_xml,
title="Library",
self_href=self_href,
next_href=next_href,
search_href="/opds/v1.2/search.xml", # important
start_href_override="/opds/v1.2/catalog", # keep “start” in v1.2 space
)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
# Komga-style OpenSearchDescription (alias)
@app.get("/opds/v1.2/search.xml", response_class=Response)
def opensearch_v12(_=Depends(require_basic)):
tpl = env.get_template("search-description.xml.j2")
# IMPORTANT: in the template well prefer v1.2 URLs first (already done below)
xml = tpl.render(base=SERVER_BASE.rstrip("/"))
return Response(content=xml, media_type="application/opensearchdescription+xml")
def _search_feed(request: Request, term: str, page_param: int | None,
start_index_param: int | None, count_param: int | None):
# Resolve items per page
qp = request.query_params
items = int(qp.get("count") or (count_param if count_param is not None else PAGE_SIZE))
items = max(1, min(items, 100))
# Resolve page number from page/startPage or from startIndex
if page_param is not None:
pg = max(1, int(page_param))
elif "startPage" in qp:
try:
pg = max(1, int(qp.get("startPage")))
except Exception:
pg = 1
elif (start_index_param is not None) or ("startIndex" in qp):
try:
si = int(start_index_param if start_index_param is not None else qp.get("startIndex"))
except Exception:
si = 0
si = max(0, si - 1) if si > 0 else 0 # tolerate 1-based startIndex
pg = max(1, int(ceil((si + 1) / items))) if items else 1
else:
pg = 1
offset = (pg - 1) * items
# Query DB
conn = db.connect()
try:
rows = db.search_q(conn, term, items, offset)
try:
total = db.search_count(conn, term)
except Exception:
total = offset + len(rows)
finally:
conn.close()
entries_xml = [_entry_xml_from_row(r) for r in rows]
# Build self/next using the ACTUAL path Panels called (Komga-style or not)
base_path = request.url.path # e.g., /opds/v1.2/search or /opds/search
# prefer 'query' on v1.2 path, otherwise 'q'
term_key = "query" if base_path.endswith("/v1.2/search") else "q"
self_href = f"{base_path}?{term_key}={quote(term)}&page={pg}"
next_href = None
if (offset + len(rows)) < total:
next_href = f"{base_path}?{term_key}={quote(term)}&page={pg+1}"
xml = _feed(
entries_xml,
title=f"Search: {term}",
self_href=self_href,
next_href=next_href,
os_total=total,
os_start=offset + 1 if total > 0 else 0,
os_items=items,
)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")

View File

@@ -15,18 +15,16 @@
<!-- Direct file stream (Range-enabled, optional) -->
<link rel="enclosure" href="{{ stream_href_abs }}" type="{{ mime }}" />
<!-- OPDS Page Streaming Extension 1.1: template URL + page count -->
<!-- OPDS Page Streaming Extension 1.1 (Panels looks for this pattern) -->
<link rel="http://vaemendis.net/opds-pse/stream"
type="image/jpeg"
href="{{ pse_template_abs }}"
pse:count="{{ page_count }}" />
{% if image_abs %}
<!-- Full cover (some clients prefer a full image rel) -->
<link rel="http://opds-spec.org/image" href="{{ image_abs }}" type="image/jpeg" />
{% endif %}
{% if thumb_href_abs %}
<!-- Thumbnail cover -->
<link rel="http://opds-spec.org/image/thumbnail" href="{{ thumb_href_abs }}" type="image/jpeg" />
{% endif %}

View File

@@ -3,11 +3,9 @@
<ShortName>ComicOPDS</ShortName>
<Description>Search your ComicOPDS library</Description>
<!-- OPDS v1.2 search (both MIME variants, with rel="results") -->
<Url rel="results" type="application/atom+xml;profile=opds-catalog"
template="{{ base }}/opds/v1.2/search?query={searchTerms}&page={startPage?}" />
<Url rel="results" type="application/atom+xml"
template="{{ base }}/opds/v1.2/search?query={searchTerms}&page={startPage?}" />
<!-- Single, simple template Panels follows: -->
<Url type="application/atom+xml;profile=opds-catalog"
template="{{ base }}/opds/search?query={searchTerms}" />
<InputEncoding>UTF-8</InputEncoding>
<OutputEncoding>UTF-8</OutputEncoding>