From 67a40003bb3f1e2289c499a865a200279298da45 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Thu, 4 Sep 2025 21:59:11 +0200 Subject: [PATCH] Fixed streaming --- app/fs_index.py | 27 ++++++----- app/main.py | 116 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 100 insertions(+), 43 deletions(-) diff --git a/app/fs_index.py b/app/fs_index.py index 8c807a7..3c0cf14 100644 --- a/app/fs_index.py +++ b/app/fs_index.py @@ -86,23 +86,26 @@ def scan(root: Path) -> List[Item]: save_cache(items) return items -def children(items: List[Item], rel_folder: str) -> Iterable[Item]: +def children(items: list[Item], rel_folder: str): """ - Yield direct children (files or dirs) inside rel_folder. - Works reliably even with spaces, parentheses, and deep trees. + Yield direct children (files or dirs) of rel_folder. + Works with spaces, parentheses, and deep trees. """ base = rel_folder.strip("/") - prefix = (base + "/") if base else "" + if base == "": + # top-level children: anything with no "/" in its rel (and not empty) + for it in items: + if it.rel and "/" not in it.rel: + yield it + return + + prefix = base + "/" + plen = len(prefix) for it in items: rel = it.rel - if base == "": - # top-level direct children have no slash in rel - if "/" not in rel and rel != "": - yield it - continue if not rel.startswith(prefix) or rel == base: continue - remainder = rel[len(prefix):] - # only direct children have no "/" in the remainder - if "/" not in remainder and remainder != "": + remainder = rel[plen:] + # direct child has no further "/" + if remainder and "/" not in remainder: yield it diff --git a/app/main.py b/app/main.py index b5f9ea1..d4cee66 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Query, HTTPException, Request, Response, Depends +from fastapi import FastAPI, Query, HTTPException, Request, Response, Depends, Header from fastapi.responses import StreamingResponse, FileResponse, PlainTextResponse, HTMLResponse, JSONResponse from pathlib import Path from typing import List, Dict, Any @@ -18,7 +18,7 @@ app = FastAPI(title="ComicOPDS") env = Environment( loader=FileSystemLoader(str(Path(__file__).parent / "templates")), - autoescape=select_autoescape(enabled_extensions=("xml","html")) + autoescape=select_autoescape(enabled_extensions=("xml", "html", "j2"), default=True), ) INDEX: List[fs_index.Item] = [] @@ -233,42 +233,96 @@ def download(path: str, request: Request, _=Depends(require_basic)): resp.headers["Accept-Ranges"] = "bytes" return resp -@app.get("/stream") -def stream(path: str, request: Request, _=Depends(require_basic)): - p=_abspath(path) +def _common_file_headers(p: Path) -> dict: + return { + "Accept-Ranges": "bytes", + "Content-Type": mime_for(p), + "Content-Disposition": f'inline; filename="{p.name}"', + } + +@app.head("/stream") +def stream_head(path: str, _=Depends(require_basic)): + p = _abspath(path) if not p.exists() or not p.is_file(): raise HTTPException(404) - file_size=p.stat().st_size - range_header=request.headers.get("range") - if range_header is None: - return FileResponse(p,media_type=mime_for(p),filename=p.name) + st = p.stat() + headers = _common_file_headers(p) + headers["Content-Length"] = str(st.st_size) + return Response(status_code=200, headers=headers) + +@app.get("/stream") +def stream(path: str, request: Request, range: str | None = Header(default=None), _=Depends(require_basic)): + """ + Stream file with HTTP Range support. + - Returns 206 for valid single-range requests + - Returns 200 with Accept-Ranges for full-file (no Range) + - Ignores additional ranges if client sends multiple (serves the first) + """ + p = _abspath(path) + if not p.exists() or not p.is_file(): + raise HTTPException(404) + + file_size = p.stat().st_size + headers = _common_file_headers(p) + + rng_header = range or request.headers.get("range") + if not rng_header: + # No Range ? whole file, but advertise that we support ranges + headers["Content-Length"] = str(file_size) + return FileResponse(p, headers=headers) + + # Parse e.g. "bytes=START-END" (ignore multi-ranges after the first) try: - _,rng=range_header.split("=") - start_str,end_str=(rng.split("-")+[""])[:2] - start=int(start_str) if start_str else 0 - end=int(end_str) if end_str else file_size-1 - end=min(end,file_size-1) - if start>end or start<0: + unit, rngs = rng_header.split("=", 1) + if unit.strip().lower() != "bytes": raise ValueError + first_range = rngs.split(",")[0].strip() # ignore additional ranges + start_str, end_str = (first_range.split("-") + [""])[:2] + + if start_str == "" and end_str == "": + raise ValueError + + if start_str == "": + # suffix bytes: "-N" ? last N bytes + length = int(end_str) + if length <= 0: + raise ValueError + start = max(file_size - length, 0) + end = file_size - 1 + else: + start = int(start_str) + end = int(end_str) if end_str else (file_size - 1) + + if start < 0 or end < start or start >= file_size: + raise ValueError + + end = min(end, file_size - 1) except Exception: - raise HTTPException(416,"Invalid Range") - def iter_file(fp: Path,s:int,e:int,chunk:int=1024*1024): + # Malformed ? 416 + raise HTTPException( + status_code=416, + detail="Invalid Range", + headers={"Content-Range": f"bytes */{file_size}"} + ) + + def iter_file(fp: Path, s: int, e: int, chunk: int = 1024 * 1024): with fp.open("rb") as f: f.seek(s) - remaining=e-s+1 - while remaining>0: - data=f.read(min(chunk,remaining)) - if not data: break - remaining-=len(data) + remaining = e - s + 1 + while remaining > 0: + data = f.read(min(chunk, remaining)) + if not data: + break + remaining -= len(data) yield data - headers={ - "Content-Range":f"bytes {start}-{end}/{file_size}", - "Accept-Ranges":"bytes", - "Content-Length":str(end-start+1), - "Content-Type":mime_for(p), - "Content-Disposition":f'inline; filename="{p.name}"', - } - return StreamingResponse(iter_file(p,start,end),status_code=206,headers=headers) + + # 206 Partial Content + part_len = end - start + 1 + headers.update({ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(part_len), + }) + return StreamingResponse(iter_file(p, start, end), status_code=206, headers=headers) @app.get("/thumb") def thumb(path: str, request: Request, _=Depends(require_basic)): @@ -379,4 +433,4 @@ def stats(_=Depends(require_basic)): @app.get("/debug/children", response_class=JSONResponse) def debug_children(path: str = ""): ch = list(fs_index.children(INDEX, path.strip("/"))) - return JSONResponse([{"rel": it.rel, "is_dir": it.is_dir, "name": it.name} for it in ch]) \ No newline at end of file + return [{"rel": it.rel, "is_dir": it.is_dir, "name": it.name} for it in ch] \ No newline at end of file