Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5129bdbb3b |
+23
-21
@@ -440,7 +440,7 @@ def _entry_xml_from_row(row) -> str:
|
|||||||
base = SERVER_BASE.rstrip("/")
|
base = SERVER_BASE.rstrip("/")
|
||||||
|
|
||||||
if row["is_dir"]:
|
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(
|
return tpl.render(
|
||||||
entry_id=f"{base}{_abs_url('/opds/' + quote(row['rel']))}",
|
entry_id=f"{base}{_abs_url('/opds/' + quote(row['rel']))}",
|
||||||
updated=now_rfc3339(),
|
updated=now_rfc3339(),
|
||||||
@@ -452,11 +452,11 @@ def _entry_xml_from_row(row) -> str:
|
|||||||
rel = row["rel"]
|
rel = row["rel"]
|
||||||
abs_file = LIBRARY_DIR / rel
|
abs_file = LIBRARY_DIR / rel
|
||||||
|
|
||||||
download_href = f"/download?path={quote(rel)}"
|
download_href = f"/download/{quote(rel)}"
|
||||||
stream_href = f"/stream?path={quote(rel)}"
|
stream_href = f"/stream/{quote(rel)}"
|
||||||
|
|
||||||
# PSE: template URL & count (Panels-compatible)
|
# 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
|
page_count = 0
|
||||||
try:
|
try:
|
||||||
if abs_file.exists():
|
if abs_file.exists():
|
||||||
@@ -470,7 +470,7 @@ def _entry_xml_from_row(row) -> str:
|
|||||||
if (rget(row, "ext") or "").lower() == "cbz":
|
if (rget(row, "ext") or "").lower() == "cbz":
|
||||||
p = have_thumb(rel, comicvine_issue) or generate_thumb(rel, abs_file, comicvine_issue)
|
p = have_thumb(rel, comicvine_issue) or generate_thumb(rel, abs_file, comicvine_issue)
|
||||||
if p:
|
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
|
thumb_href_abs = image_abs
|
||||||
|
|
||||||
return tpl.render(
|
return tpl.render(
|
||||||
@@ -497,8 +497,9 @@ def _entry_xml_from_row(row) -> str:
|
|||||||
def health():
|
def health():
|
||||||
return PlainTextResponse("ok")
|
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("/")
|
path = path.strip("/")
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
@@ -524,8 +525,8 @@ def browse(path: str = Query("", description="Relative folder path"), page: int
|
|||||||
)
|
)
|
||||||
entries_xml = [smart_entry] + entries_xml
|
entries_xml = [smart_entry] + entries_xml
|
||||||
|
|
||||||
self_href = f"/opds?path={quote(path)}&page={page}" if path else f"/opds?page={page}"
|
self_href = f"/opds/{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
|
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)
|
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")
|
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}"',
|
"Content-Disposition": f'inline; filename="{p.name}"',
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.head("/download")
|
@app.head("/download/{path:path}")
|
||||||
def download_head(path: str, _=Depends(require_basic)):
|
def download_head(path: str, _=Depends(require_basic)):
|
||||||
p = _abspath(path)
|
p = _abspath(path)
|
||||||
if not p.exists() or not p.is_file():
|
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)
|
headers["Content-Length"] = str(st.st_size)
|
||||||
return Response(status_code=200, headers=headers)
|
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)):
|
def download(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
|
||||||
p = _abspath(path)
|
p = _abspath(path)
|
||||||
if not p.exists() or not p.is_file():
|
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)):
|
def stream_head(path: str, _=Depends(require_basic)):
|
||||||
return download_head(path)
|
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)):
|
def stream(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)):
|
||||||
return download(path=path, request=request, range=range)
|
return download(path=path, request=request, range=range)
|
||||||
|
|
||||||
@app.get("/thumb")
|
|
||||||
|
@app.get("/thumb/{path:path}")
|
||||||
def thumb(path: str, _=Depends(require_basic)):
|
def thumb(path: str, _=Depends(require_basic)):
|
||||||
abs_p = _abspath(path)
|
abs_p = _abspath(path)
|
||||||
if not abs_p.exists() or not abs_p.is_file():
|
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")
|
return FileResponse(p, media_type="image/jpeg")
|
||||||
|
|
||||||
# -------------------- PSE endpoints --------------------
|
# -------------------- PSE endpoints --------------------
|
||||||
@app.get("/pse/stream", response_class=Response)
|
@app.get("/pse/stream/{path:path}", response_class=Response)
|
||||||
def pse_stream(path: str = Query(..., description="Relative path to CBZ"), _=Depends(require_basic)):
|
def pse_stream(path: str, _=Depends(require_basic)):
|
||||||
"""Optional: Atom feed per-pages (kept for compatibility)."""
|
"""Optional: Atom feed per-pages (kept for compatibility)."""
|
||||||
abs_cbz = _abspath(path)
|
abs_cbz = _abspath(path)
|
||||||
if not abs_cbz.exists() or not abs_cbz.is_file() or abs_cbz.suffix.lower() != ".cbz":
|
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")
|
page_entry_tpl = env.get_template("pse_page_entry.xml.j2")
|
||||||
entries_xml = []
|
entries_xml = []
|
||||||
for i, _name in enumerate(pages, start=1):
|
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(
|
entries_xml.append(
|
||||||
page_entry_tpl.render(
|
page_entry_tpl.render(
|
||||||
entry_id=f"{SERVER_BASE.rstrip('/')}{page_href}",
|
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")
|
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(
|
xml = pse_feed_tpl.render(
|
||||||
feed_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(self_href)}",
|
feed_id=f"{SERVER_BASE.rstrip('/')}{_abs_url(self_href)}",
|
||||||
updated=now_rfc3339(),
|
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")
|
return Response(content=xml, media_type="application/atom+xml;profile=opds-catalog")
|
||||||
|
|
||||||
@app.get("/pse/page")
|
@app.get("/pse/page/{path:path}")
|
||||||
def pse_page(path: str = Query(...), page: int = Query(0, ge=0), _=Depends(require_basic)):
|
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)."""
|
"""Serve page by ZERO-BASED index to match Panels (0 == first page)."""
|
||||||
abs_cbz = _abspath(path)
|
abs_cbz = _abspath(path)
|
||||||
if not abs_cbz.exists() or not abs_cbz.is_file():
|
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}"
|
self_href = f"/opds/smart/{quote(slug)}?page={page}"
|
||||||
next_href = None
|
next_href = None
|
||||||
if (start + len(rows)) < total_for_nav:
|
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)
|
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")
|
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)})
|
return JSONResponse({"ok": True, "saved": len(lists)})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Index status & Reindex --------------------
|
# -------------------- Index status & Reindex --------------------
|
||||||
@app.get("/index/status", response_class=JSONResponse)
|
@app.get("/index/status", response_class=JSONResponse)
|
||||||
def index_status(_=Depends(require_basic)):
|
def index_status(_=Depends(require_basic)):
|
||||||
|
|||||||
+6
-6
@@ -8,13 +8,13 @@ ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard
|
|||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/` | `GET` | Root OPDS catalog feed (same as `/opds`) |
|
| `/` | `GET` | Root OPDS catalog feed (same as `/opds`) |
|
||||||
| `/opds` | `GET` | Root OPDS catalog feed. Supports browsing by folder and smart lists. |
|
| `/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.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). |
|
| `/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. |
|
| `/download/{path}?...` | `GET` | Download a `.cbz` file. Supports HTTP range requests. |
|
||||||
| `/stream?path=...` | `GET` | Stream a `.cbz` file (content-type `application/vnd.comicbook+zip`). |
|
| `/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. |
|
| `/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). |
|
| `/thumb/{path}` | `GET` | Get thumbnail image for a comic (JPEG format). |
|
||||||
|
|
||||||
### 📊 Dashboard & Stats
|
### 📊 Dashboard & Stats
|
||||||
|
|
||||||
@@ -46,4 +46,4 @@ ComicOPDS exposes both user-facing endpoints (for OPDS clients and the dashboard
|
|||||||
|
|
||||||
⚠️ **Note:**
|
⚠️ **Note:**
|
||||||
- Admin and debug endpoints require Basic Auth unless `DISABLE_AUTH=true` is set.
|
- 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