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)
|
||||
|
||||
+96
-2
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user