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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from backend.app.api.routes_files import video as video_route
|
||||
from backend.app.dependencies import get_file_ops_service
|
||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||
from backend.app.main import app
|
||||
from backend.app.security.path_guard import PathGuard
|
||||
from backend.app.services.file_ops_service import FileOpsService
|
||||
|
||||
|
||||
class VideoApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name) / "root"
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
path_guard = PathGuard({"storage1": str(self.root)})
|
||||
self.service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter())
|
||||
|
||||
async def _override_file_ops_service() -> FileOpsService:
|
||||
return self.service
|
||||
|
||||
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _request(self, path: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
||||
async def _run() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
return await client.get("/api/files/video", params={"path": path}, headers=headers)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def _stream_route(self, path: str, headers: dict[str, str] | None = None) -> tuple[object, bytes]:
|
||||
async def _run() -> tuple[object, bytes]:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/files/video",
|
||||
"headers": [
|
||||
(key.lower().encode("latin-1"), value.encode("latin-1"))
|
||||
for key, value in (headers or {}).items()
|
||||
],
|
||||
}
|
||||
response = await video_route(path=path, request=Request(scope), service=self.service)
|
||||
body = b""
|
||||
async for chunk in response.body_iterator:
|
||||
body += chunk
|
||||
return response, body
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def test_video_stream_success_mp4(self) -> None:
|
||||
payload = b"\x00\x00\x00\x20ftypisom\x00\x00\x00\x00mp42"
|
||||
(self.root / "sample.mp4").write_bytes(payload)
|
||||
|
||||
response, body = self._stream_route("storage1/sample.mp4")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["content-type"], "video/mp4")
|
||||
self.assertEqual(response.headers["accept-ranges"], "bytes")
|
||||
self.assertEqual(response.headers["content-length"], str(len(payload)))
|
||||
self.assertEqual(body, payload)
|
||||
|
||||
def test_video_stream_range_partial_content(self) -> None:
|
||||
payload = b"abcdefghijklmnopqrstuvwxyz"
|
||||
(self.root / "clip.mp4").write_bytes(payload)
|
||||
|
||||
response, body = self._stream_route("storage1/clip.mp4", headers={"Range": "bytes=2-5"})
|
||||
|
||||
self.assertEqual(response.status_code, 206)
|
||||
self.assertEqual(response.headers["accept-ranges"], "bytes")
|
||||
self.assertEqual(response.headers["content-range"], f"bytes 2-5/{len(payload)}")
|
||||
self.assertEqual(response.headers["content-length"], "4")
|
||||
self.assertEqual(body, b"cdef")
|
||||
|
||||
def test_video_directory_type_conflict(self) -> None:
|
||||
(self.root / "media").mkdir()
|
||||
|
||||
response = self._request("storage1/media")
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "type_conflict")
|
||||
|
||||
def test_video_path_not_found(self) -> None:
|
||||
response = self._request("storage1/missing.mp4")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_not_found")
|
||||
|
||||
def test_video_traversal_blocked(self) -> None:
|
||||
response = self._request("storage1/../etc/passwd")
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
|
||||
|
||||
def test_video_non_video_type_blocked(self) -> None:
|
||||
(self.root / "notes.txt").write_text("hello", encoding="utf-8")
|
||||
|
||||
response = self._request("storage1/notes.txt")
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "unsupported_type")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -50,6 +50,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn("F7", body)
|
||||
self.assertIn("F8", body)
|
||||
self.assertIn('id="viewer-modal"', body)
|
||||
self.assertIn('id="video-modal"', body)
|
||||
self.assertIn('id="video-player"', body)
|
||||
self.assertIn('id="video-close-btn"', body)
|
||||
self.assertIn('id="settings-modal"', body)
|
||||
self.assertIn('id="rename-popup"', body)
|
||||
self.assertIn('id="rename-input"', body)
|
||||
@@ -110,6 +113,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function openRenamePopup()', app_js)
|
||||
self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js)
|
||||
self.assertIn('return triggerActionButton("rename-btn");', app_js)
|
||||
self.assertIn('function openVideoViewer()', app_js)
|
||||
self.assertIn('video.player.src = streamUrl;', app_js)
|
||||
self.assertIn('document.getElementById("video-close-btn")', app_js)
|
||||
self.assertIn('row.ondblclick = (ev) => {', app_js)
|
||||
self.assertIn('function openMovePopup()', app_js)
|
||||
self.assertIn('document.getElementById("move-btn").onclick = openF6Flow;', app_js)
|
||||
self.assertIn('await apiRequest("GET", "/api/history")', app_js)
|
||||
|
||||
Reference in New Issue
Block a user