forked from FrederikBaerentsen/ComicOPDS
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:
42
app/main.py
42
app/main.py
@@ -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)):
|
||||
|
||||
12
docs/api.md
12
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.
|
||||
- OPDS endpoints follow the OPDS 1.2 specification and should work with Panels and other compliant OPDS clients.
|
||||
|
||||
Reference in New Issue
Block a user