From 5129bdbb3b8ac4b44a1e1682d1d1fa1f5a312eff Mon Sep 17 00:00:00 2001 From: Andreas Fuchs Date: Mon, 15 Dec 2025 15:07:10 -0500 Subject: [PATCH] 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. --- app/main.py | 44 +++++++++++++++++++++++--------------------- docs/api.md | 12 ++++++------ 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/main.py b/app/main.py index fab0fb6..b24deac 100644 --- a/app/main.py +++ b/app/main.py @@ -440,7 +440,7 @@ def _entry_xml_from_row(row) -> str: base = SERVER_BASE.rstrip("/") if row["is_dir"]: - href = f"/opds?path={quote(row['rel'])}" if row["rel"] else "/opds" + href = f"/opds/{quote(row['rel'])}" if row["rel"] else "/opds" return tpl.render( entry_id=f"{base}{_abs_url('/opds/' + quote(row['rel']))}", updated=now_rfc3339(), @@ -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)): diff --git a/docs/api.md b/docs/api.md index a3c2f39..11bb8b4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. \ No newline at end of file +- OPDS endpoints follow the OPDS 1.2 specification and should work with Panels and other compliant OPDS clients.