feat: videoplayer toegevoegd
This commit is contained in:
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse
|
||||
from backend.app.dependencies import get_file_ops_service
|
||||
@@ -42,6 +43,21 @@ async def view(
|
||||
return service.view(path=path, for_edit=for_edit)
|
||||
|
||||
|
||||
@router.get("/video")
|
||||
async def video(
|
||||
path: str,
|
||||
request: Request,
|
||||
service: FileOpsService = Depends(get_file_ops_service),
|
||||
) -> StreamingResponse:
|
||||
prepared = service.prepare_video_stream(path=path, range_header=request.headers.get("range"))
|
||||
return StreamingResponse(
|
||||
prepared["content"],
|
||||
status_code=prepared["status_code"],
|
||||
headers=prepared["headers"],
|
||||
media_type=prepared["content_type"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/save", response_model=SaveResponse)
|
||||
async def save(
|
||||
request: SaveRequest,
|
||||
|
||||
Binary file not shown.
@@ -86,6 +86,17 @@ class FilesystemAdapter:
|
||||
"modified": self.modified_iso(path),
|
||||
}
|
||||
|
||||
async def stream_file_range(self, path: Path, start: int, end: int, chunk_size: int = 1024 * 1024):
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(start)
|
||||
remaining = (end - start) + 1
|
||||
while remaining > 0:
|
||||
chunk = handle.read(min(chunk_size, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
@staticmethod
|
||||
def modified_iso(path: Path) -> str:
|
||||
stat = path.stat()
|
||||
|
||||
Binary file not shown.
@@ -25,6 +25,10 @@ SPECIAL_TEXT_FILENAMES = {
|
||||
"dockerfile": "text/plain",
|
||||
"containerfile": "text/plain",
|
||||
}
|
||||
VIDEO_CONTENT_TYPES = {
|
||||
".mp4": "video/mp4",
|
||||
".mkv": "video/x-matroska",
|
||||
}
|
||||
|
||||
|
||||
class FileOpsService:
|
||||
@@ -302,6 +306,53 @@ class FileOpsService:
|
||||
modified=saved["modified"],
|
||||
)
|
||||
|
||||
def prepare_video_stream(self, path: str, range_header: str | None = None) -> dict:
|
||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||
|
||||
if resolved_target.absolute.is_dir():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a file",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
if not resolved_target.absolute.is_file():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Unsupported path type for video",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
content_type = self._video_content_type_for(resolved_target.absolute)
|
||||
if content_type is None:
|
||||
raise AppError(
|
||||
code="unsupported_type",
|
||||
message="File type is not supported for video playback",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
file_size = int(resolved_target.absolute.stat().st_size)
|
||||
start = 0
|
||||
end = max(file_size - 1, 0)
|
||||
status_code = 200
|
||||
headers = {"Accept-Ranges": "bytes"}
|
||||
|
||||
if range_header:
|
||||
start, end = self._parse_range_header(range_header, file_size)
|
||||
status_code = 206
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
|
||||
headers["Content-Length"] = str(max((end - start) + 1, 0))
|
||||
|
||||
return {
|
||||
"status_code": status_code,
|
||||
"headers": headers,
|
||||
"content_type": content_type,
|
||||
"content": self._filesystem.stream_file_range(resolved_target.absolute, start, end),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _join_relative(base: str, name: str) -> str:
|
||||
return f"{base}/{name}" if base else name
|
||||
@@ -313,6 +364,10 @@ class FileOpsService:
|
||||
return special_name
|
||||
return TEXT_CONTENT_TYPES.get(path.suffix.lower())
|
||||
|
||||
@staticmethod
|
||||
def _video_content_type_for(path: Path) -> str | None:
|
||||
return VIDEO_CONTENT_TYPES.get(path.suffix.lower())
|
||||
|
||||
def _record_history(
|
||||
self,
|
||||
*,
|
||||
@@ -363,3 +418,43 @@ class FileOpsService:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
@staticmethod
|
||||
def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
|
||||
def invalid_range() -> AppError:
|
||||
return AppError(
|
||||
code="invalid_request",
|
||||
message="Invalid Range header",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not range_header.startswith("bytes="):
|
||||
raise invalid_range()
|
||||
value = range_header[len("bytes="):].strip()
|
||||
if "," in value or "-" not in value:
|
||||
raise invalid_range()
|
||||
start_text, end_text = value.split("-", 1)
|
||||
if start_text == "" and end_text == "":
|
||||
raise invalid_range()
|
||||
|
||||
try:
|
||||
if start_text == "":
|
||||
suffix_length = int(end_text)
|
||||
if suffix_length <= 0:
|
||||
raise invalid_range()
|
||||
if suffix_length >= file_size:
|
||||
return 0, max(file_size - 1, 0)
|
||||
return file_size - suffix_length, file_size - 1
|
||||
|
||||
start = int(start_text)
|
||||
if start < 0 or start >= file_size:
|
||||
raise invalid_range()
|
||||
|
||||
if end_text == "":
|
||||
return start, file_size - 1
|
||||
|
||||
end = int(end_text)
|
||||
if end < start:
|
||||
raise invalid_range()
|
||||
return start, min(end, file_size - 1)
|
||||
except ValueError as exc:
|
||||
raise invalid_range() from exc
|
||||
|
||||
Reference in New Issue
Block a user