feat: videoplayer toegevoegd
This commit is contained in:
@@ -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
|
||||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
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.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse
|
||||||
from backend.app.dependencies import get_file_ops_service
|
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)
|
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)
|
@router.post("/save", response_model=SaveResponse)
|
||||||
async def save(
|
async def save(
|
||||||
request: SaveRequest,
|
request: SaveRequest,
|
||||||
|
|||||||
Binary file not shown.
@@ -86,6 +86,17 @@ class FilesystemAdapter:
|
|||||||
"modified": self.modified_iso(path),
|
"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
|
@staticmethod
|
||||||
def modified_iso(path: Path) -> str:
|
def modified_iso(path: Path) -> str:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
|
|||||||
Binary file not shown.
@@ -25,6 +25,10 @@ SPECIAL_TEXT_FILENAMES = {
|
|||||||
"dockerfile": "text/plain",
|
"dockerfile": "text/plain",
|
||||||
"containerfile": "text/plain",
|
"containerfile": "text/plain",
|
||||||
}
|
}
|
||||||
|
VIDEO_CONTENT_TYPES = {
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".mkv": "video/x-matroska",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FileOpsService:
|
class FileOpsService:
|
||||||
@@ -302,6 +306,53 @@ class FileOpsService:
|
|||||||
modified=saved["modified"],
|
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
|
@staticmethod
|
||||||
def _join_relative(base: str, name: str) -> str:
|
def _join_relative(base: str, name: str) -> str:
|
||||||
return f"{base}/{name}" if base else name
|
return f"{base}/{name}" if base else name
|
||||||
@@ -313,6 +364,10 @@ class FileOpsService:
|
|||||||
return special_name
|
return special_name
|
||||||
return TEXT_CONTENT_TYPES.get(path.suffix.lower())
|
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(
|
def _record_history(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -363,3 +418,43 @@ class FileOpsService:
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
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("F7", body)
|
||||||
self.assertIn("F8", body)
|
self.assertIn("F8", body)
|
||||||
self.assertIn('id="viewer-modal"', 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="settings-modal"', body)
|
||||||
self.assertIn('id="rename-popup"', body)
|
self.assertIn('id="rename-popup"', body)
|
||||||
self.assertIn('id="rename-input"', body)
|
self.assertIn('id="rename-input"', body)
|
||||||
@@ -110,6 +113,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('function openRenamePopup()', app_js)
|
self.assertIn('function openRenamePopup()', app_js)
|
||||||
self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js)
|
self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js)
|
||||||
self.assertIn('return triggerActionButton("rename-btn");', 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('function openMovePopup()', app_js)
|
||||||
self.assertIn('document.getElementById("move-btn").onclick = openF6Flow;', app_js)
|
self.assertIn('document.getElementById("move-btn").onclick = openF6Flow;', app_js)
|
||||||
self.assertIn('await apiRequest("GET", "/api/history")', app_js)
|
self.assertIn('await apiRequest("GET", "/api/history")', app_js)
|
||||||
|
|||||||
+95
-1
@@ -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() {
|
function moveElements() {
|
||||||
return {
|
return {
|
||||||
overlay: document.getElementById("move-popup"),
|
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));
|
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) {
|
function currentParentPath(path) {
|
||||||
const normalized = (path || "").trim();
|
const normalized = (path || "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -657,6 +677,16 @@ function renderPaneItems(pane) {
|
|||||||
}
|
}
|
||||||
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);
|
items.append(row);
|
||||||
});
|
});
|
||||||
updateActionButtons();
|
updateActionButtons();
|
||||||
@@ -1019,6 +1049,10 @@ function isViewerOpen() {
|
|||||||
return !viewerElements().overlay.classList.contains("hidden");
|
return !viewerElements().overlay.classList.contains("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVideoOpen() {
|
||||||
|
return !videoElements().overlay.classList.contains("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
function isEditorOpen() {
|
function isEditorOpen() {
|
||||||
return !editorElements().overlay.classList.contains("hidden");
|
return !editorElements().overlay.classList.contains("hidden");
|
||||||
}
|
}
|
||||||
@@ -1339,6 +1373,15 @@ function closeViewer() {
|
|||||||
viewer.content.textContent = "";
|
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) {
|
function setSettingsTab(tab) {
|
||||||
const elements = settingsElements();
|
const elements = settingsElements();
|
||||||
settingsState.activeTab = tab === "logs" ? "logs" : "general";
|
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() {
|
async function openEditor() {
|
||||||
const selectedItems = activePaneState().selectedItems;
|
const selectedItems = activePaneState().selectedItems;
|
||||||
if (selectedItems.length !== 1 || !isEditableSelection(selectedItems[0])) {
|
if (selectedItems.length !== 1 || !isEditableSelection(selectedItems[0])) {
|
||||||
@@ -1585,10 +1655,16 @@ function jumpCurrentRow(edge) {
|
|||||||
function openCurrentDirectory() {
|
function openCurrentDirectory() {
|
||||||
const pane = state.activePane;
|
const pane = state.activePane;
|
||||||
const item = currentRowItem(pane);
|
const item = currentRowItem(pane);
|
||||||
if (!item || item.kind !== "directory") {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.kind === "directory") {
|
||||||
navigateTo(pane, item.path);
|
navigateTo(pane, item.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVideoSelection(item)) {
|
||||||
|
openVideoViewer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCurrentSelection() {
|
function toggleCurrentSelection() {
|
||||||
@@ -1663,6 +1739,13 @@ function handleKeyboardShortcuts(event) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isVideoOpen()) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeVideoViewer();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isViewerOpen()) {
|
if (isViewerOpen()) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
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();
|
const editor = editorElements();
|
||||||
editor.closeButton.onclick = attemptCloseEditor;
|
editor.closeButton.onclick = attemptCloseEditor;
|
||||||
editor.cancelButton.onclick = attemptCloseEditor;
|
editor.cancelButton.onclick = attemptCloseEditor;
|
||||||
|
|||||||
@@ -166,6 +166,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title">
|
||||||
<div class="popup-card viewer-card">
|
<div class="popup-card viewer-card">
|
||||||
<button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button>
|
<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;
|
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 {
|
.settings-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(760px, calc(100vw - 32px));
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
|||||||
Reference in New Issue
Block a user