feat: videoplayer toegevoegd

This commit is contained in:
kodi
2026-03-12 10:37:06 +01:00
parent 5123067100
commit 8c2fbfef74
14 changed files with 593 additions and 3 deletions
+17 -1
View File
@@ -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,
@@ -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()
@@ -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
@@ -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)
+96 -2
View File
@@ -140,6 +140,18 @@ function editorElements() {
};
}
function videoElements() {
return {
overlay: document.getElementById("video-modal"),
title: document.getElementById("video-title"),
fileName: document.getElementById("video-file-name"),
filePath: document.getElementById("video-file-path"),
error: document.getElementById("video-error"),
player: document.getElementById("video-player"),
closeButton: document.getElementById("video-close-btn"),
};
}
function moveElements() {
return {
overlay: document.getElementById("move-popup"),
@@ -344,6 +356,14 @@ function isEditableSelection(item) {
return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".css", ".html"].some((suffix) => lower.endsWith(suffix));
}
function isVideoSelection(item) {
if (!item || item.kind !== "file") {
return false;
}
const lower = (item.name || "").toLowerCase();
return lower.endsWith(".mp4") || lower.endsWith(".mkv");
}
function currentParentPath(path) {
const normalized = (path || "").trim();
if (!normalized) {
@@ -657,6 +677,16 @@ function renderPaneItems(pane) {
}
renderPaneItems(pane);
};
if (entry.kind === "file" && isVideoSelection({ path: entry.path, name: entry.name, kind: entry.kind })) {
row.ondblclick = (ev) => {
ev.stopPropagation();
setActivePane(pane);
model.currentRowIndex = index;
setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index);
renderPaneItems(pane);
openVideoViewer();
};
}
items.append(row);
});
updateActionButtons();
@@ -1019,6 +1049,10 @@ function isViewerOpen() {
return !viewerElements().overlay.classList.contains("hidden");
}
function isVideoOpen() {
return !videoElements().overlay.classList.contains("hidden");
}
function isEditorOpen() {
return !editorElements().overlay.classList.contains("hidden");
}
@@ -1339,6 +1373,15 @@ function closeViewer() {
viewer.content.textContent = "";
}
function closeVideoViewer() {
const video = videoElements();
video.overlay.classList.add("hidden");
video.error.textContent = "";
video.player.pause();
video.player.removeAttribute("src");
video.player.load();
}
function setSettingsTab(tab) {
const elements = settingsElements();
settingsState.activeTab = tab === "logs" ? "logs" : "general";
@@ -1479,6 +1522,33 @@ async function openViewer() {
}
}
function videoPlaybackMessage(item) {
const lower = (item.name || "").toLowerCase();
if (lower.endsWith(".mkv")) {
return "MKV playback is best-effort and depends on browser codec support.";
}
return "";
}
async function openVideoViewer() {
const selectedItems = activePaneState().selectedItems;
if (selectedItems.length !== 1 || !isVideoSelection(selectedItems[0])) {
return;
}
const selected = selectedItems[0];
const video = videoElements();
const streamUrl = `/api/files/video?${new URLSearchParams({ path: selected.path }).toString()}`;
video.overlay.classList.remove("hidden");
video.title.textContent = "Video";
video.fileName.textContent = selected.name;
video.filePath.textContent = selected.path;
video.error.textContent = videoPlaybackMessage(selected);
video.player.pause();
video.player.src = streamUrl;
video.player.load();
}
async function openEditor() {
const selectedItems = activePaneState().selectedItems;
if (selectedItems.length !== 1 || !isEditableSelection(selectedItems[0])) {
@@ -1585,10 +1655,16 @@ function jumpCurrentRow(edge) {
function openCurrentDirectory() {
const pane = state.activePane;
const item = currentRowItem(pane);
if (!item || item.kind !== "directory") {
if (!item) {
return;
}
navigateTo(pane, item.path);
if (item.kind === "directory") {
navigateTo(pane, item.path);
return;
}
if (isVideoSelection(item)) {
openVideoViewer();
}
}
function toggleCurrentSelection() {
@@ -1663,6 +1739,13 @@ function handleKeyboardShortcuts(event) {
}
return;
}
if (isVideoOpen()) {
if (event.key === "Escape") {
event.preventDefault();
closeVideoViewer();
}
return;
}
if (isViewerOpen()) {
if (event.key === "Escape") {
event.preventDefault();
@@ -1868,6 +1951,17 @@ function setupEvents() {
}
};
const video = videoElements();
video.closeButton.onclick = closeVideoViewer;
video.player.onerror = () => {
video.error.textContent = "Playback failed in this browser for this file.";
};
video.overlay.onclick = (event) => {
if (event.target === video.overlay) {
closeVideoViewer();
}
};
const editor = editorElements();
editor.closeButton.onclick = attemptCloseEditor;
editor.cancelButton.onclick = attemptCloseEditor;
+11
View File
@@ -166,6 +166,17 @@
</div>
</div>
<div id="video-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="video-title">
<div class="popup-card viewer-card">
<button id="video-close-btn" class="viewer-close" type="button" aria-label="Close video">X</button>
<h3 id="video-title">Video</h3>
<div id="video-file-name" class="popup-meta"></div>
<div id="video-file-path" class="popup-meta"></div>
<div id="video-error" class="error"></div>
<video id="video-player" class="video-player" controls playsinline preload="metadata"></video>
</div>
</div>
<div id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title">
<div class="popup-card viewer-card">
<button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button>
+8
View File
@@ -516,6 +516,14 @@ button:disabled {
flex-direction: column;
}
.video-player {
width: 100%;
max-height: calc(100vh - 180px);
background: #000;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.settings-card {
position: relative;
width: min(760px, calc(100vw - 32px));