Make cbz file paths into path OpenAPI arguments

FastAPI, in Query arguments, interprets characters like `+`, `;` and `'` in special ways (see https://github.com/fastapi/fastapi/issues/720), which causes issues with the requests that some clients such as Panels send: Receiving these bare characters results in request errors.

So in order to prevent that special handling, move the `path` argument into the URL path: That eliminates the special-handling of those characters, resulting in far more requests from affected clients succeeding.
This commit is contained in:
2025-12-15 15:07:10 -05:00
parent 7b3c76cbc5
commit 58278c033e
2 changed files with 28 additions and 26 deletions

View File

@@ -452,11 +452,11 @@ def _entry_xml_from_row(row) -> str:
rel = row["rel"]
abs_file = LIBRARY_DIR / rel
download_href = f"/download?path={quote(rel)}"
stream_href = f"/stream?path={quote(rel)}"
download_href = f"/download/{quote(rel)}"
stream_href = f"/stream/{quote(rel)}"
# PSE: template URL & count (Panels-compatible)
pse_template = f"/pse/page?path={quote(rel)}&page={{pageNumber}}"
pse_template = f"/pse/page/{quote(rel)}?page={{pageNumber}}"
page_count = 0
try:
if abs_file.exists():
@@ -470,7 +470,7 @@ 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:
image_abs = f"{base}{_abs_url('/thumb?path=' + quote(rel))}"
image_abs = f"{base}{_abs_url('/thumb/' + quote(rel))}"
thumb_href_abs = image_abs
return tpl.render(
@@ -497,8 +497,9 @@ def _entry_xml_from_row(row) -> str:
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)):
@app.get("/opds/{path:path}", response_class=Response)
def browse(path: str, page: int = 1, _=Depends(require_basic)):
path = path.strip("/")
conn = db.connect()
try:
@@ -524,8 +525,8 @@ def browse(path: str = Query("", description="Relative folder path"), page: int
)
entries_xml = [smart_entry] + entries_xml
self_href = f"/opds?path={quote(path)}&page={page}" if path else f"/opds?page={page}"
next_href = f"/opds?path={quote(path)}&page={page+1}" if (start + PAGE_SIZE) < total else None
self_href = f"/opds/{quote(path)}?page={page}" if path else f"/opds?page={page}"
next_href = f"/opds/{quote(path)}?page={page + 1}" if (start + PAGE_SIZE) < total else None
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")
@@ -591,7 +592,7 @@ def _common_file_headers(p: Path) -> dict:
"Content-Disposition": f'inline; filename="{p.name}"',
}
@app.head("/download")
@app.head("/download/{path:path}")
def download_head(path: str, _=Depends(require_basic)):
p = _abspath(path)
if not p.exists() or not p.is_file():
@@ -601,7 +602,7 @@ def download_head(path: str, _=Depends(require_basic)):
headers["Content-Length"] = str(st.st_size)
return Response(status_code=200, headers=headers)
@app.get("/download")
@app.get("/download/{path:path}")
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():
@@ -668,11 +669,13 @@ def download(path: str, request: Request, range: str | None = Header(default=Non
def stream_head(path: str, _=Depends(require_basic)):
return download_head(path)
@app.get("/stream")
@app.get("/stream/{path:path}")
def stream(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
return download(path=path, request=request, range=range)
@app.get("/thumb")
@app.get("/thumb/{path:path}")
def thumb(path: str, _=Depends(require_basic)):
abs_p = _abspath(path)
if not abs_p.exists() or not abs_p.is_file():
@@ -694,8 +697,8 @@ def thumb(path: str, _=Depends(require_basic)):
return FileResponse(p, media_type="image/jpeg")
# -------------------- PSE endpoints --------------------
@app.get("/pse/stream", response_class=Response)
def pse_stream(path: str = Query(..., description="Relative path to CBZ"), _=Depends(require_basic)):
@app.get("/pse/stream/{path:path}", response_class=Response)
def pse_stream(path: str, _=Depends(require_basic)):
"""Optional: Atom feed per-pages (kept for compatibility)."""
abs_cbz = _abspath(path)
if not abs_cbz.exists() or not abs_cbz.is_file() or abs_cbz.suffix.lower() != ".cbz":
@@ -705,7 +708,7 @@ def pse_stream(path: str = Query(..., description="Relative path to CBZ"), _=Dep
page_entry_tpl = env.get_template("pse_page_entry.xml.j2")
entries_xml = []
for i, _name in enumerate(pages, start=1):
page_href = _abs_url(f"/pse/page?path={quote(path)}&page={i}")
page_href = _abs_url(f"/pse/page/{quote(path)}?page={i}")
entries_xml.append(
page_entry_tpl.render(
entry_id=f"{SERVER_BASE.rstrip('/')}{page_href}",
@@ -716,7 +719,7 @@ def pse_stream(path: str = Query(..., description="Relative path to CBZ"), _=Dep
)
pse_feed_tpl = env.get_template("pse_feed.xml.j2")
self_href = f"/pse/stream?path={quote(path)}"
self_href = f"/pse/stream/{quote(path)}"
xml = pse_feed_tpl.render(
feed_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(self_href)}",
updated=now_rfc3339(),
@@ -727,8 +730,8 @@ def pse_stream(path: str = Query(..., description="Relative path to CBZ"), _=Dep
)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@app.get("/pse/page")
def pse_page(path: str = Query(...), page: int = Query(0, ge=0), _=Depends(require_basic)):
@app.get("/pse/page/{path:path}")
def pse_page(path: str, page: int = Query(0, ge=0), _=Depends(require_basic)):
"""Serve page by ZERO-BASED index to match Panels (0 == first page)."""
abs_cbz = _abspath(path)
if not abs_cbz.exists() or not abs_cbz.is_file():
@@ -968,7 +971,7 @@ def opds_smart_list(slug: str, page: int = 1, _=Depends(require_basic)):
self_href = f"/opds/smart/{quote(slug)}?page={page}"
next_href = None
if (start + len(rows)) < total_for_nav:
next_href = f"/opds/smart/{quote(slug)}?page={page+1}"
next_href = f"/opds/smart/{quote(slug)}?page={page + 1}"
xml = _feed(entries_xml, title=sl["name"], self_href=self_href, next_href=next_href)
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
@@ -1029,7 +1032,6 @@ async def smartlists_post(request: Request, _=Depends(require_basic)):
return JSONResponse({"ok": True, "saved": len(lists)})
# -------------------- Index status & Reindex --------------------
@app.get("/index/status", response_class=JSONResponse)
def index_status(_=Depends(require_basic)):

View File

@@ -8,13 +8,13 @@ ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard
|----------|--------|-------------|
| `/` | `GET` | Root OPDS catalog feed (same as `/opds`) |
| `/opds` | `GET` | Root OPDS catalog feed. Supports browsing by folder and smart lists. |
| `/opds?path=...` | `GET` | Browse into a subfolder (series, publisher, etc.). |
| `/opds/{path}?...` | `GET` | Browse into a subfolder (series, publisher, etc.). |
| `/opds/search.xml` | `GET` | [OpenSearch 1.1](https://opensearch.org/) descriptor. Tells OPDS clients how to search. |
| `/opds/search?q=...&page=...` | `GET` | Perform a search query (returns OPDS feed of matching comics). |
| `/download?path=...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
| `/stream?path=...` | `GET` | Stream a `.cbz` file (content-type `application/vnd.comicbook+zip`). |
| `/pse/pages?path=...` | `GET` | OPDS PSE 1.1 page streaming (individual pages as images). Used by Panels and similar clients. |
| `/thumb?path=...` | `GET` | Get thumbnail image for a comic (JPEG format). |
| `/download/{path}?...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
| `/stream/{path}?...` | `GET` | Stream a `.cbz` file (content-type `application/vnd.comicbook+zip`). |
| `/pse/pages/{path}?...` | `GET` | OPDS PSE 1.1 page streaming (individual pages as images). Used by Panels and similar clients. |
| `/thumb/{path}` | `GET` | Get thumbnail image for a comic (JPEG format). |
### 📊 Dashboard & Stats
@@ -46,4 +46,4 @@ ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard
⚠️ **Note:**
- Admin and debug endpoints require Basic Auth unless `DISABLE_AUTH=true` is set.
- OPDS endpoints follow the OPDS 1.2 specification and should work with Panels and other compliant OPDS clients.
- OPDS endpoints follow the OPDS 1.2 specification and should work with Panels and other compliant OPDS clients.