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
+228
View File
@@ -0,0 +1,228 @@
# Video Streaming v1
## Doel
Video Streaming v1 voegt een kleine, veilige manier toe om videobestanden direct vanuit de webui af te spelen in de browser, zonder eerst een volledige lokale kopie te maken. Dat past bij de bestaande dual-pane workflow: bestanden blijven centraal browsebaar, en video openen wordt een gerichte viewer-actie binnen dezelfde app.
De kern is browser-native streaming via HTTP, niet het bouwen van een mediaserver. De app blijft een file manager met een beperkte preview-/playbackfunctie.
## Scope
Video Streaming v1 ondersteunt:
- `mp4`
- `mkv`
- afspelen in een modal/popup video viewer
- browser-native:
- play/pause
- seek/scrub bar
- volume
- fullscreen
Niet in scope voor v1:
- transcoding
- codecconversie
- adaptive bitrate streaming
- playlists
- thumbnails / chapter support
- picture-in-picture specifieke UI-logica
- ingebedde subtitle-extractie uit containers
Ondertiteling in v1 is alleen kansrijk als losse subtitle-bestanden later eenvoudig gekoppeld kunnen worden; dat is niet de basis van deze eerste slice.
## Open-/Afspeelgedrag in de UI
Aanbevolen v1-gedrag:
- dubbelklik op videobestand = afspelen
- `Enter` op geselecteerd videobestand = afspelen
- gewone single click blijft selectie
- klik op directorynaam blijft directory openen
Dit sluit aan op standaard file-manager gedrag:
- selecteren en openen blijven gescheiden
- directory-open gedrag blijft intact
- video-open is alleen voor videobestanden
Rechtermuisknop/contextmenu blijft buiten scope. Dat zou extra event-complexiteit toevoegen zonder noodzaak voor een eerste bruikbare versie.
## Streamingmodel
De aanbevolen techniek is een read-only HTTP endpoint met `Range` request ondersteuning.
Waarom:
- browsers kunnen dan direct streamen en seeken
- grote bestanden hoeven niet volledig in memory of eerst volledig gedownload te worden
- dit past goed bij `<video src="...">`
Gewenst gedrag:
- browser vraagt een eerste byte-range op
- server serveert alleen de gevraagde byte-range
- bij seeken vraagt de browser een nieuw bereik op
- response gebruikt `206 Partial Content` waar nodig
Dit is beter dan volledige download vooraf omdat:
- startup sneller is
- geheugenverbruik laag blijft
- netwerkverkeer beperkt blijft tot wat de speler echt nodig heeft
## Backend-impact
Aanbevolen nieuw endpoint:
- `GET /api/files/video?path=...`
Aanvullend gedrag:
- alleen files
- alleen binnen bestaand `path_guard` / whitelist model
- directory => `type_conflict`
- niet gevonden => `path_not_found`
- traversal / buiten whitelist => bestaande securityfouten
Belangrijke backendvereisten:
- valideer pad via bestaand `path_guard`
- valideer dat het om een file gaat
- content-type bepalen op basis van extensie / bekende mapping
- `Range` request parsing ondersteunen
- response streamen vanaf filehandle, niet eerst volledig inlezen
- correcte headers:
- `Accept-Ranges: bytes`
- `Content-Range` bij partial content
- `Content-Length`
- passend `Content-Type`
Geheugenverbruik blijft laag door:
- file in chunks te lezen
- geen volledige buffering
- directe streamingresponse
## Frontend-impact
Aanbevolen richting:
- aparte video modal naast bestaande text `View` modal
- geen overbelasting van de huidige tekstviewer
Reden:
- tekst-view en video-view hebben ander gedrag
- regressierisico blijft lager als beide modaltypen gescheiden zijn
UI-richting:
- bestaand selectiemechanisme blijft
- open-actie detecteert videotype
- video opent in aparte modal met `<video controls>`
Dat laat de bestaande tekst-viewflow intact en voorkomt dat `View` v1 voor tekst regressies krijgt.
## MKV / Codec-realiteit
`mkv` als container betekent niet dat elke browser het bestand echt kan afspelen.
Belangrijke realiteit:
- browser support hangt af van codecs in de container
- bijvoorbeeld H.264/AAC in MP4 is meestal kansrijker
- MKV met niet-ondersteunde codecs kan ondanks correcte streaming nog steeds niet afspelen
Veilige v1-verwachting:
- server ondersteunt streaming van `mkv`
- browser probeert native playback
- als browser/codec-combinatie niet ondersteund wordt, toont de UI een nette melding dat afspelen in deze browser niet beschikbaar is
Dus:
- v1 belooft streaming
- v1 belooft geen universele codeccompatibiliteit
## Ondertiteling
Veilige v1-richting:
- geen ingebedde MKV subtitles
- eventueel later alleen losse subtitle-bestanden zoals `.vtt` of `.srt`
Waarom ingebedde subtitles buiten scope zijn:
- vereist parsing/extractie uit container
- verhoogt backendcomplexiteit duidelijk
- browsers ondersteunen losse tracks eenvoudiger dan container-interne subtitles
Conclusie:
- subtitles niet als kern van v1
- als later toegevoegd, begin met losse subtitle-bestanden
## Risico's
Belangrijkste risico's:
- correcte `Range` handling
- browser codec support
- grote files / seek-gedrag
- security op file access
- regressie op bestaande view/open-flow
Specifiek:
- foutieve range-implementatie breekt seeken
- te brede content-type toelating maakt gedrag onduidelijk
- combineren van tekst-view en video-view in één modal verhoogt regressierisico
## Teststrategie
Backend golden tests:
- video endpoint success voor ondersteund pad
- range request geeft partial content
- directory => `type_conflict`
- path not found
- traversal attempt
- invalid/non-video type indien endpoint daarop beperkt wordt
UI smoke/regressietests:
- video modal container aanwezig
- JS wiring voor dubbelklik / `Enter` op videobestand
- bestaande directory-open flow blijft bestaan
- bestaande tekst-viewflow blijft bestaan
Handmatige validatie:
- mp4 opent en speelt af
- seek werkt
- fullscreen werkt
- sluiten modal werkt
- mkv met browser-ondersteunde codec speelt
- mkv met niet-ondersteunde codec faalt netjes
## Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- nieuw backend endpoint `GET /api/files/video?path=...`
- read-only streaming met `Range` support
- aparte video modal in de frontend
- openen via:
- dubbelklik op videobestand
- `Enter` op geselecteerd videobestand
- `mp4` als primaire happy path
- `mkv` technisch toestaan, maar met expliciete verwachting dat browser/codec support kan verschillen
- geen subtitle-feature als kern van v1
Dit is de kleinste bruikbare stap die:
- goed past bij de huidige app
- bestaande security hergebruikt
- geen mediaserver-architectuur introduceert
- regressierisico op browse/view/edit laag houdt
+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));