Working on Panels search
This commit is contained in:
72
app/db.py
72
app/db.py
@@ -179,38 +179,48 @@ def stats(conn: sqlite3.Connection) -> dict:
|
||||
out["top_writers"] = {"labels":[r[0] for r in writers], "values":[r[1] for r in writers]}
|
||||
return out
|
||||
|
||||
def search_q(conn, q: str, limit: int, offset: int):
|
||||
like = f"%{q}%"
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT i.*, m.title, m.series, m.number, m.volume, m.year, m.month, m.day,
|
||||
m.writer, m.publisher, m.summary, m.genre, m.tags, m.characters,
|
||||
m.teams, m.locations, m.comicvineissue
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE i.is_dir = 0
|
||||
AND (
|
||||
i.name LIKE ? OR
|
||||
m.title LIKE ? OR
|
||||
m.series LIKE ? OR
|
||||
m.writer LIKE ? OR
|
||||
m.publisher LIKE ?
|
||||
)
|
||||
ORDER BY COALESCE(m.series, i.name), CAST(COALESCE(NULLIF(m.number,''), '0') AS INTEGER), i.name
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(like, like, like, like, like, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
def search_q(conn: sqlite3.Connection, q: str, limit: int, offset: int) -> list[sqlite3.Row]:
|
||||
# Use FTS if available; else fallback to LIKE across a few fields
|
||||
q = (q or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
try:
|
||||
return conn.execute(
|
||||
"""SELECT i.rel,i.name,i.is_dir,i.size,i.mtime,i.ext,
|
||||
m.title,m.series,m.number,m.volume,m.publisher,m.writer,m.year,m.month,m.day,
|
||||
m.summary,m.languageiso,m.comicvineissue,m.genre,m.tags,m.characters,m.teams,m.locations
|
||||
FROM search s
|
||||
JOIN items i ON i.rel=s.rel
|
||||
LEFT JOIN meta m ON m.rel=i.rel
|
||||
WHERE s MATCH ? AND i.is_dir=0
|
||||
ORDER BY rank LIMIT ? OFFSET ?""",
|
||||
(q, limit, offset),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
qlike = f"%{q.lower()}%"
|
||||
return conn.execute(
|
||||
"""SELECT i.rel,i.name,i.is_dir,i.size,i.mtime,i.ext,
|
||||
m.title,m.series,m.number,m.volume,m.publisher,m.writer,m.year,m.month,m.day,
|
||||
m.summary,m.languageiso,m.comicvineissue,m.genre,m.tags,m.characters,m.teams,m.locations
|
||||
FROM items i LEFT JOIN meta m ON m.rel=i.rel
|
||||
WHERE i.is_dir=0 AND (
|
||||
LOWER(i.name) LIKE ? OR LOWER(IFNULL(m.title,'')) LIKE ? OR LOWER(IFNULL(m.series,'')) LIKE ?
|
||||
OR LOWER(IFNULL(m.publisher,'')) LIKE ? OR LOWER(IFNULL(m.writer,'')) LIKE ?
|
||||
)
|
||||
LIMIT ? OFFSET ?""",
|
||||
(qlike, qlike, qlike, qlike, qlike, limit, offset),
|
||||
).fetchall()
|
||||
def search_count(conn, q: str) -> int:
|
||||
like = f"%{q}%"
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM items i
|
||||
LEFT JOIN meta m ON m.rel = i.rel
|
||||
WHERE i.is_dir = 0
|
||||
AND (
|
||||
i.name LIKE ? OR
|
||||
m.title LIKE ? OR
|
||||
m.series LIKE ? OR
|
||||
m.writer LIKE ? OR
|
||||
m.publisher LIKE ?
|
||||
)
|
||||
""",
|
||||
(like, like, like, like, like),
|
||||
).fetchone()
|
||||
return int(row[0] if row else 0)
|
||||
|
||||
|
||||
# ------- smart list (advanced) dynamic WHERE builder -------
|
||||
|
||||
88
app/main.py
88
app/main.py
@@ -16,6 +16,7 @@ import json
|
||||
import zipfile
|
||||
import hashlib
|
||||
from PIL import Image
|
||||
from math import ceil
|
||||
|
||||
from .config import LIBRARY_DIR, PAGE_SIZE, SERVER_BASE, URL_PREFIX
|
||||
from .opds import now_rfc3339, mime_for
|
||||
@@ -272,7 +273,11 @@ def _categories_from_row(row) -> list[str]:
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
def _feed(entries_xml: List[str], title: str, self_href: str, next_href: Optional[str] = None):
|
||||
def _feed(entries_xml: List[str], title: str, self_href: str,
|
||||
next_href: Optional[str] = None,
|
||||
os_total: Optional[int] = None,
|
||||
os_start: Optional[int] = None,
|
||||
os_items: Optional[int] = None):
|
||||
tpl = env.get_template("feed.xml.j2")
|
||||
base = SERVER_BASE.rstrip("/")
|
||||
return tpl.render(
|
||||
@@ -284,8 +289,12 @@ def _feed(entries_xml: List[str], title: str, self_href: str, next_href: Optiona
|
||||
base=base,
|
||||
next_href=_abs_url(next_href) if next_href else None,
|
||||
entries=entries_xml,
|
||||
os_total=os_total,
|
||||
os_start=os_start,
|
||||
os_items=os_items,
|
||||
)
|
||||
|
||||
|
||||
def _entry_xml_from_row(row) -> str:
|
||||
tpl = env.get_template("entry.xml.j2")
|
||||
base = SERVER_BASE.rstrip("/")
|
||||
@@ -392,24 +401,81 @@ def opensearch_description(_=Depends(require_basic)):
|
||||
return Response(content=xml, media_type="application/opensearchdescription+xml")
|
||||
|
||||
@app.get("/opds/search", response_class=Response)
|
||||
def opds_search(q: str = Query("", alias="q"), page: int = 1, _=Depends(require_basic)):
|
||||
q_str = (q or "").strip()
|
||||
if not q_str:
|
||||
return browse(path="", page=page)
|
||||
def opds_search(q: str | None = Query(None, alias="q"),
|
||||
page: int | None = Query(None),
|
||||
request: Request = None,
|
||||
_=Depends(require_basic)):
|
||||
"""
|
||||
Panels/clients compatibility:
|
||||
- query can be in q, query, searchTerms, term
|
||||
- paging can be page, startPage, or startIndex (1-based or index-based)
|
||||
"""
|
||||
|
||||
# Collect query term across multiple names
|
||||
qp = request.query_params if request else {}
|
||||
term = (q or
|
||||
qp.get("query") or
|
||||
qp.get("searchTerms") or
|
||||
qp.get("term") or
|
||||
"").strip()
|
||||
|
||||
if not term:
|
||||
# no query -> behave like browse root (some clients call search with empty)
|
||||
return browse(path="", page=1)
|
||||
|
||||
# Resolve page number
|
||||
# 1) explicit page (1-based)
|
||||
if page is not None:
|
||||
pg = max(1, int(page))
|
||||
else:
|
||||
# 2) startPage (1-based)
|
||||
if "startPage" in qp:
|
||||
try:
|
||||
pg = max(1, int(qp.get("startPage")))
|
||||
except Exception:
|
||||
pg = 1
|
||||
# 3) startIndex (0/1-based index of first result)
|
||||
elif "startIndex" in qp:
|
||||
try:
|
||||
si = int(qp.get("startIndex"))
|
||||
# Many clients use 1-based startIndex; tolerate 0-based as well
|
||||
si = max(0, si - 1) if si > 0 else 0
|
||||
pg = max(1, int(ceil((si + 1) / PAGE_SIZE)))
|
||||
except Exception:
|
||||
pg = 1
|
||||
else:
|
||||
pg = 1
|
||||
|
||||
start = (pg - 1) * PAGE_SIZE
|
||||
|
||||
# Query DB
|
||||
conn = db.connect()
|
||||
try:
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
rows = db.search_q(conn, q_str, PAGE_SIZE, start)
|
||||
rows = db.search_q(conn, term, PAGE_SIZE, start)
|
||||
try:
|
||||
total = db.search_count(conn, term)
|
||||
except Exception:
|
||||
total = start + len(rows)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
entries_xml = [_entry_xml_from_row(r) for r in rows]
|
||||
self_href = f"/opds/search?q={quote(q_str)}&page={page}"
|
||||
next_href = f"/opds/search?q={quote(q_str)}&page={page+1}" if len(rows) == PAGE_SIZE else None
|
||||
xml = _feed(entries_xml, title=f"Search: {q_str}", self_href=self_href, next_href=next_href)
|
||||
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
|
||||
|
||||
self_href = f"/opds/search?q={quote(term)}&page={pg}"
|
||||
next_href = None
|
||||
if (start + len(rows)) < total:
|
||||
next_href = f"/opds/search?q={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=start + 1, # show 1-based start index in feed metadata
|
||||
os_items=PAGE_SIZE,
|
||||
)
|
||||
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
|
||||
# -------------------- File endpoints --------------------
|
||||
def _abspath(rel: str) -> Path:
|
||||
p = (LIBRARY_DIR / rel).resolve()
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<link rel="next" type="application/atom+xml;profile=opds-catalog" href="{{ base }}{{ next_href }}" />
|
||||
{% endif %}
|
||||
|
||||
{% if os_total is defined %}
|
||||
<opensearch:totalResults>{{ os_total }}</opensearch:totalResults>
|
||||
<opensearch:startIndex>{{ os_start }}</opensearch:startIndex>
|
||||
<opensearch:itemsPerPage>{{ os_items }}</opensearch:itemsPerPage>
|
||||
{% endif %}
|
||||
|
||||
{% for e in entries -%}
|
||||
{{ e | safe }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>ComicOPDS Search</ShortName>
|
||||
<Description>Search your comics</Description>
|
||||
<ShortName>ComicOPDS</ShortName>
|
||||
<Description>Search your ComicOPDS library</Description>
|
||||
|
||||
<!-- Common variants some clients expect -->
|
||||
<Url type="application/atom+xml"
|
||||
template="{{ base }}/opds/search?q={searchTerms}&page={startPage?}" />
|
||||
template="{{ base }}/opds/search?q={searchTerms}&page={startPage?}" />
|
||||
<Url type="application/atom+xml"
|
||||
template="{{ base }}/opds/search?query={searchTerms}&page={startPage?}" />
|
||||
<Url type="application/atom+xml"
|
||||
template="{{ base }}/opds/search?q={searchTerms}&startIndex={startIndex?}" />
|
||||
|
||||
<!-- With profile (others prefer this) -->
|
||||
<Url type="application/atom+xml;profile=opds-catalog"
|
||||
template="{{ base }}/opds/search?q={searchTerms}&page={startPage?}" />
|
||||
|
||||
<Query role="example" searchTerms="batman" />
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<OutputEncoding>UTF-8</OutputEncoding>
|
||||
</OpenSearchDescription>
|
||||
|
||||
Reference in New Issue
Block a user