Search now working in Panels
This commit is contained in:
264
app/main.py
264
app/main.py
@@ -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:
|
||||
# we’ll 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 we’ll 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")
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user