From 3de2615cd0589dae8090b2da872621e5d2b01235 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sat, 16 May 2026 10:51:23 +0800 Subject: [PATCH] feat: add video room feature with API, database model, and UI integration --- api/routers.py | 2 + domain/adapters/providers/quark.py | 91 +++++---- domain/video_rooms/__init__.py | 9 + domain/video_rooms/api.py | 72 +++++++ domain/video_rooms/service.py | 82 ++++++++ domain/video_rooms/types.py | 39 ++++ domain/video_rooms/ws.py | 31 +++ models/database.py | 17 ++ pyproject.toml | 1 + uv.lock | 29 +++ web/src/api/client.ts | 1 + web/src/api/videoRooms.ts | 35 ++++ web/src/i18n/locales/en.json | 9 + web/src/i18n/locales/zh.json | 9 + .../FileExplorerPage/FileExplorerPage.tsx | 9 + .../components/ContextMenu.tsx | 13 +- .../components/Modals/VideoRoomModal.tsx | 95 +++++++++ web/src/pages/VideoRoomPage.tsx | 180 ++++++++++++++++++ web/src/router/index.tsx | 4 +- 19 files changed, 690 insertions(+), 38 deletions(-) create mode 100644 domain/video_rooms/__init__.py create mode 100644 domain/video_rooms/api.py create mode 100644 domain/video_rooms/service.py create mode 100644 domain/video_rooms/types.py create mode 100644 domain/video_rooms/ws.py create mode 100644 web/src/api/videoRooms.ts create mode 100644 web/src/pages/FileExplorerPage/components/Modals/VideoRoomModal.tsx create mode 100644 web/src/pages/VideoRoomPage.tsx diff --git a/api/routers.py b/api/routers.py index fb311a5..f59474f 100644 --- a/api/routers.py +++ b/api/routers.py @@ -11,6 +11,7 @@ from domain.plugins import api as plugins from domain.processors import api as processors from domain.share import api as share from domain.tasks import api as tasks +from domain.video_rooms import api as video_rooms from domain.ai import api as ai from domain.agent import api as agent from domain.virtual_fs import api as virtual_fs @@ -32,6 +33,7 @@ def include_routers(app: FastAPI): app.include_router(config.router) app.include_router(processors.router) app.include_router(tasks.router) + app.include_router(video_rooms.router) app.include_router(share.router) app.include_router(share.public_router) app.include_router(backup.router) diff --git a/domain/adapters/providers/quark.py b/domain/adapters/providers/quark.py index 8692316..2eb59e7 100644 --- a/domain/adapters/providers/quark.py +++ b/domain/adapters/providers/quark.py @@ -360,25 +360,14 @@ class QuarkAdapter: if tr: url = tr dl_headers = self._download_headers() - - # 预获取大小/是否支持范围 - total_size: Optional[int] = None - async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client: - try: - head_resp = await client.head(url, headers=dl_headers) - if head_resp.status_code == 200: - cl = head_resp.headers.get("Content-Length") - if cl and cl.isdigit(): - total_size = int(cl) - except Exception: - pass + file_size = int(it.get("size") or 0) mime, _ = mimetypes.guess_type(rel) content_type = mime or "application/octet-stream" # 解析 Range start = 0 - end: Optional[int] = None + end: Optional[int] = file_size - 1 if file_size > 0 else None status_code = 200 if range_header and range_header.startswith("bytes="): status_code = 206 @@ -388,35 +377,65 @@ class QuarkAdapter: start = int(s) if e.strip(): end = int(e) + elif file_size > 0: + end = file_size - 1 + if file_size > 0: + if start >= file_size: + raise HTTPException(416, detail="Requested Range Not Satisfiable") + if end is None or end >= file_size: + end = file_size - 1 + if start > end: + raise HTTPException(416, detail="Requested Range Not Satisfiable") + headers = dict(dl_headers) + if status_code == 206: + headers["Range"] = f"bytes={start}-" if end is None else f"bytes={start}-{end}" - if total_size is not None and end is None and status_code == 206: - end = total_size - 1 - if end is not None and total_size is not None and end >= total_size: - end = total_size - 1 - if total_size is not None and start >= total_size: + client = httpx.AsyncClient(timeout=None, follow_redirects=True) + req = client.build_request("GET", url, headers=headers) + resp = await client.send(req, stream=True) + if resp.status_code == 404: + await resp.aclose() + await client.aclose() + raise FileNotFoundError(rel) + if resp.status_code == 416: + await resp.aclose() + await client.aclose() raise HTTPException(416, detail="Requested Range Not Satisfiable") + try: + resp.raise_for_status() + except Exception: + await resp.aclose() + await client.aclose() + raise - resp_headers: Dict[str, str] = {"Accept-Ranges": "bytes", "Content-Type": content_type} - if status_code == 206 and total_size is not None and end is not None: - resp_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}" - resp_headers["Content-Length"] = str(end - start + 1) - elif total_size is not None: - resp_headers["Content-Length"] = str(total_size) + resp_headers: Dict[str, str] = { + "Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"), + "Content-Type": resp.headers.get("Content-Type", content_type), + } + content_range = resp.headers.get("Content-Range") + content_length = resp.headers.get("Content-Length") + if content_range: + resp_headers["Content-Range"] = content_range + elif status_code == 206 and file_size > 0 and end is not None: + resp_headers["Content-Range"] = f"bytes {start}-{end}/{file_size}" + if content_length: + resp_headers["Content-Length"] = content_length + elif file_size > 0: + if status_code == 206 and end is not None: + resp_headers["Content-Length"] = str(end - start + 1) + elif resp.status_code == 200: + resp_headers["Content-Length"] = str(file_size) async def iterator(): - headers = dict(dl_headers) - if status_code == 206 and end is not None: - headers["Range"] = f"bytes={start}-{end}" - async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: - async with client.stream("GET", url, headers=headers) as resp: - if resp.status_code in (404, 416): - await resp.aclose() - raise HTTPException(resp.status_code, detail="Upstream not available") - async for chunk in resp.aiter_bytes(): - if chunk: - yield chunk + try: + async for chunk in resp.aiter_bytes(): + if chunk: + yield chunk + finally: + await resp.aclose() + await client.aclose() - return StreamingResponse(iterator(), status_code=status_code, headers=resp_headers, media_type=content_type) + return StreamingResponse(iterator(), status_code=resp.status_code, headers=resp_headers, media_type=content_type) # ----------------- # 上传(大文件分片) diff --git a/domain/video_rooms/__init__.py b/domain/video_rooms/__init__.py new file mode 100644 index 0000000..bd85380 --- /dev/null +++ b/domain/video_rooms/__init__.py @@ -0,0 +1,9 @@ +from .service import VideoRoomService +from .types import VideoRoomCreate, VideoRoomInfo, VideoRoomState + +__all__ = [ + "VideoRoomService", + "VideoRoomCreate", + "VideoRoomInfo", + "VideoRoomState", +] diff --git a/domain/video_rooms/api.py b/domain/video_rooms/api.py new file mode 100644 index 0000000..6d9906b --- /dev/null +++ b/domain/video_rooms/api.py @@ -0,0 +1,72 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect + +from api.response import success +from domain.audit import AuditAction, audit +from domain.auth import User, get_current_active_user +from domain.permission import require_path_permission +from domain.permission.types import PathAction +from models.database import UserAccount +from .service import VideoRoomService +from .types import VideoRoomCreate, VideoRoomInfo +from .ws import video_room_ws_manager + +router = APIRouter(prefix="/api/video-rooms", tags=["Video Rooms"]) + + +@router.post("", response_model=VideoRoomInfo) +@audit(action=AuditAction.SHARE, description="创建视频房", body_fields=["name", "path"]) +@require_path_permission(PathAction.SHARE, "payload.path") +async def create_video_room( + request: Request, + payload: VideoRoomCreate, + current_user: Annotated[User, Depends(get_current_active_user)], +): + user_account = await UserAccount.get(id=current_user.id) + room = await VideoRoomService.create_room( + user=user_account, + name=payload.name, + path=payload.path, + ) + return VideoRoomInfo.from_orm(room, VideoRoomService.get_effective_state(room)) + + +@router.get("/{token}", response_model=VideoRoomInfo) +@audit(action=AuditAction.SHARE, description="获取视频房信息") +async def get_video_room(request: Request, token: str): + room = await VideoRoomService.get_room_by_token(token) + return VideoRoomInfo.from_orm(room, VideoRoomService.get_effective_state(room)) + + +@router.get("/{token}/stream") +@audit(action=AuditAction.DOWNLOAD, description="播放视频房文件") +async def stream_video_room(token: str, request: Request): + return await VideoRoomService.stream_room_file(token, request.headers.get("Range")) + + +@router.websocket("/{token}/ws") +async def video_room_ws(websocket: WebSocket, token: str): + room = await VideoRoomService.get_room_by_token(token) + await video_room_ws_manager.connect(token, websocket) + try: + state = VideoRoomService.get_effective_state(room) + await websocket.send_json({"type": "state", "state": state.model_dump()}) + while True: + data = await websocket.receive_json() + if data.get("type") != "state": + continue + state = await VideoRoomService.update_state( + room, + current_time=float(data.get("current_time") or 0), + paused=bool(data.get("paused")), + ) + await video_room_ws_manager.broadcast( + token, + {"type": "state", "state": state.model_dump()}, + exclude=websocket, + ) + except WebSocketDisconnect: + pass + finally: + video_room_ws_manager.disconnect(token, websocket) diff --git a/domain/video_rooms/service.py b/domain/video_rooms/service.py new file mode 100644 index 0000000..2054c9e --- /dev/null +++ b/domain/video_rooms/service.py @@ -0,0 +1,82 @@ +import secrets +from datetime import datetime, timezone +from urllib.parse import quote + +from fastapi import HTTPException +from fastapi.responses import Response + +from domain.virtual_fs import VirtualFSService +from models.database import UserAccount, VideoRoom +from .types import VideoRoomState + + +VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".m4v", ".mov", ".mkv", ".avi", ".flv"} + + +class VideoRoomService: + @classmethod + def _is_video_path(cls, path: str) -> bool: + lower = path.lower() + return any(lower.endswith(ext) for ext in VIDEO_EXTENSIONS) + + @classmethod + async def create_room(cls, user: UserAccount, name: str, path: str) -> VideoRoom: + if not path or path == "/" or ".." in path.split("/"): + raise HTTPException(status_code=400, detail="无效的视频路径") + if not cls._is_video_path(path): + raise HTTPException(status_code=400, detail="仅支持视频文件创建视频房") + + stat = await VirtualFSService.stat_file(path) + if stat.get("is_dir"): + raise HTTPException(status_code=400, detail="目录不能创建视频房") + + token = secrets.token_urlsafe(16) + return await VideoRoom.create( + token=token, + name=name or path.rsplit("/", 1)[-1], + path=path, + user=user, + state_updated_at=datetime.now(timezone.utc), + ) + + @classmethod + async def get_room_by_token(cls, token: str) -> VideoRoom: + room = await VideoRoom.get_or_none(token=token).prefetch_related("user") + if not room: + raise HTTPException(status_code=404, detail="视频房不存在") + return room + + @classmethod + def get_effective_state(cls, room: VideoRoom) -> VideoRoomState: + current_time = float(room.current_time or 0) + updated_at = room.state_updated_at + if not room.paused and updated_at: + if updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=timezone.utc) + current_time += max(0, (datetime.now(timezone.utc) - updated_at).total_seconds()) + return VideoRoomState( + current_time=max(0, current_time), + paused=bool(room.paused), + updated_at=updated_at.isoformat() if updated_at else None, + ) + + @classmethod + async def update_state(cls, room: VideoRoom, current_time: float, paused: bool) -> VideoRoomState: + now = datetime.now(timezone.utc) + room.current_time = max(0, float(current_time or 0)) + room.paused = bool(paused) + room.state_updated_at = now + await room.save(update_fields=["current_time", "paused", "state_updated_at"]) + return VideoRoomState( + current_time=room.current_time, + paused=room.paused, + updated_at=now.isoformat(), + ) + + @classmethod + async def stream_room_file(cls, token: str, range_header: str | None) -> Response: + room = await cls.get_room_by_token(token) + response = await VirtualFSService.stream_file(room.path, range_header) + filename = room.path.rsplit("/", 1)[-1] + response.headers["Content-Disposition"] = f"inline; filename*=UTF-8''{quote(filename)}" + return response diff --git a/domain/video_rooms/types.py b/domain/video_rooms/types.py new file mode 100644 index 0000000..5590c03 --- /dev/null +++ b/domain/video_rooms/types.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel + +from models.database import VideoRoom + + +class VideoRoomCreate(BaseModel): + name: str + path: str + + +class VideoRoomState(BaseModel): + current_time: float + paused: bool + updated_at: str | None = None + + +class VideoRoomInfo(BaseModel): + id: int + token: str + name: str + path: str + created_at: str + state: VideoRoomState + + @classmethod + def from_orm(cls, obj: VideoRoom, state: VideoRoomState | None = None): + return cls( + id=obj.id, + token=obj.token, + name=obj.name, + path=obj.path, + created_at=obj.created_at.isoformat(), + state=state + or VideoRoomState( + current_time=obj.current_time, + paused=obj.paused, + updated_at=obj.state_updated_at.isoformat() if obj.state_updated_at else None, + ), + ) diff --git a/domain/video_rooms/ws.py b/domain/video_rooms/ws.py new file mode 100644 index 0000000..b89bcaf --- /dev/null +++ b/domain/video_rooms/ws.py @@ -0,0 +1,31 @@ +from fastapi import WebSocket + + +class VideoRoomWebSocketManager: + def __init__(self): + self.rooms: dict[str, set[WebSocket]] = {} + + async def connect(self, token: str, websocket: WebSocket): + await websocket.accept() + self.rooms.setdefault(token, set()).add(websocket) + + def disconnect(self, token: str, websocket: WebSocket): + sockets = self.rooms.get(token) + if not sockets: + return + sockets.discard(websocket) + if not sockets: + self.rooms.pop(token, None) + + async def broadcast(self, token: str, message: dict, exclude: WebSocket | None = None): + sockets = list(self.rooms.get(token, set())) + for socket in sockets: + if socket is exclude: + continue + try: + await socket.send_json(message) + except Exception: + self.disconnect(token, socket) + + +video_room_ws_manager = VideoRoomWebSocketManager() diff --git a/models/database.py b/models/database.py index cc4876b..73c0507 100644 --- a/models/database.py +++ b/models/database.py @@ -234,6 +234,23 @@ class ShareLink(Model): table = "share_links" +class VideoRoom(Model): + id = fields.IntField(pk=True) + token = fields.CharField(max_length=100, unique=True, index=True) + name = fields.CharField(max_length=255) + path = fields.CharField(max_length=4096) + user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField( + "models.UserAccount", related_name="video_rooms", on_delete=fields.CASCADE + ) + current_time = fields.FloatField(default=0) + paused = fields.BooleanField(default=True) + state_updated_at = fields.DatetimeField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "video_rooms" + + class RecentFile(Model): id = fields.IntField(pk=True) user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField( diff --git a/pyproject.toml b/pyproject.toml index be86423..7568818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,4 +23,5 @@ dependencies = [ "telethon>=1.42.0", "tortoise-orm>=1.0.0", "uvicorn>=0.40.0", + "websockets", ] diff --git a/uv.lock b/uv.lock index 7d8336c..abf419f 100644 --- a/uv.lock +++ b/uv.lock @@ -459,6 +459,7 @@ dependencies = [ { name = "telethon" }, { name = "tortoise-orm" }, { name = "uvicorn" }, + { name = "websockets" }, ] [package.metadata] @@ -481,6 +482,7 @@ requires-dist = [ { name = "telethon", specifier = ">=1.42.0" }, { name = "tortoise-orm", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.40.0" }, + { name = "websockets" }, ] [[package]] @@ -1429,6 +1431,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "wrapt" version = "1.17.3" diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6689f52..8e83a9b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -73,5 +73,6 @@ async function request(url: string, options: RequestOptions = {}): Prom export { vfsApi, type VfsEntry, type DirListing } from './vfs'; export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeMeta, type AdapterUsage } from './adapters'; export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share'; +export { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from './videoRooms'; export { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads'; export default request; diff --git a/web/src/api/videoRooms.ts b/web/src/api/videoRooms.ts new file mode 100644 index 0000000..a46960c --- /dev/null +++ b/web/src/api/videoRooms.ts @@ -0,0 +1,35 @@ +import request, { API_BASE_URL } from './client'; + +export interface VideoRoomState { + current_time: number; + paused: boolean; + updated_at?: string | null; +} + +export interface VideoRoomInfo { + id: number; + token: string; + name: string; + path: string; + created_at: string; + state: VideoRoomState; +} + +export interface VideoRoomCreatePayload { + name: string; + path: string; +} + +export const videoRoomsApi = { + create: (payload: VideoRoomCreatePayload) => request('/video-rooms', { method: 'POST', json: payload }), + get: (token: string) => request(`/video-rooms/${token}`), + streamUrl: (token: string) => `${API_BASE_URL}/video-rooms/${token}/stream`, + wsUrl: (token: string) => { + const base = API_BASE_URL.startsWith('http') + ? API_BASE_URL + : `${window.location.origin}${API_BASE_URL}`; + const url = new URL(`${base}/video-rooms/${token}/ws`); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.href; + }, +}; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index efa2216..d580b14 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -242,6 +242,15 @@ "File": "File", "Image": "Image", "Video": "Video", + "Create Video Room": "Create Video Room", + "Video room created": "Video room created", + "Share this room link with friends": "Share this room link with friends", + "Video Room Link": "Video Room Link", + "Video Room Name": "Video Room Name", + "Video room load failed": "Failed to load video room", + "Video room not found": "Video room not found", + "Room synced": "Room synced", + "Room disconnected": "Room disconnected", "Audio": "Audio", "PDF": "PDF", "Word": "Word", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 94eaba4..51ced47 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -261,6 +261,15 @@ "File": "文件", "Image": "图片", "Video": "视频", + "Create Video Room": "创建视频房", + "Video room created": "视频房已创建", + "Share this room link with friends": "把这个房间链接分享给好友", + "Video Room Link": "视频房链接", + "Video Room Name": "视频房名称", + "Video room load failed": "加载视频房失败", + "Video room not found": "视频房不存在", + "Room synced": "房间同步中", + "Room disconnected": "房间已断开", "Audio": "音频", "PDF": "PDF", "Word": "Word 文档", diff --git a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx index 400ebe8..671294f 100644 --- a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx +++ b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx @@ -23,6 +23,7 @@ import { ProcessorModal } from './components/Modals/ProcessorModal'; import UploadModal from './components/Modals/UploadModal'; import { ShareModal } from './components/Modals/ShareModal'; import { DirectLinkModal } from './components/Modals/DirectLinkModal'; +import { VideoRoomModal } from './components/Modals/VideoRoomModal'; import { FileDetailModal } from './components/FileDetailModal'; import { MoveCopyModal } from './components/Modals/MoveCopyModal'; import { SearchResultsView } from './components/SearchResultsView'; @@ -58,6 +59,7 @@ const FileExplorerPage = memo(function FileExplorerPage() { const [sharingEntries, setSharingEntries] = useState([]); const [detailEntry, setDetailEntry] = useState(null); const [directLinkEntry, setDirectLinkEntry] = useState(null); + const [videoRoomEntry, setVideoRoomEntry] = useState(null); const [detailData, setDetailData] = useState | { error: string } | null>(null); const [detailLoading, setDetailLoading] = useState(false); const [movingEntries, setMovingEntries] = useState([]); @@ -453,6 +455,12 @@ const FileExplorerPage = memo(function FileExplorerPage() { open={!!directLinkEntry} onCancel={() => setDirectLinkEntry(null)} /> + setVideoRoomEntry(null)} + /> setCreatingFile(true)} onCreateDir={() => setCreatingDir(true)} onShare={doShare} + onCreateVideoRoom={setVideoRoomEntry} onGetDirectLink={doGetDirectLink} onMove={(entriesToMove) => setMovingEntries(entriesToMove)} onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)} diff --git a/web/src/pages/FileExplorerPage/components/ContextMenu.tsx b/web/src/pages/FileExplorerPage/components/ContextMenu.tsx index 26b3a96..7fdccb4 100644 --- a/web/src/pages/FileExplorerPage/components/ContextMenu.tsx +++ b/web/src/pages/FileExplorerPage/components/ContextMenu.tsx @@ -8,7 +8,8 @@ import { useI18n } from '../../../i18n'; import { FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined, EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, - ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined + ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined, + PlayCircleOutlined } from '@ant-design/icons'; interface ContextMenuProps { @@ -32,6 +33,7 @@ interface ContextMenuProps { onCreateFile: () => void; onCreateDir: () => void; onShare: (entries: VfsEntry[]) => void; + onCreateVideoRoom: (entry: VfsEntry) => void; onGetDirectLink: (entry: VfsEntry) => void; onMove: (entries: VfsEntry[]) => void; onCopy: (entries: VfsEntry[]) => void; @@ -39,6 +41,8 @@ interface ContextMenuProps { type MenuItem = Required['items'][number]; +const isVideoFile = (name: string) => /\.(mp4|webm|ogg|m4v|mov|mkv|avi|flv)$/i.test(name); + interface ActionMenuItem { key: string; label: React.ReactNode; @@ -138,6 +142,13 @@ export const ContextMenu: React.FC = (props) => { icon: , onClick: () => actions.onShare(targetEntries), }, + { + key: 'videoRoom', + label: t('Create Video Room'), + icon: , + disabled: targetEntries.length !== 1 || targetEntries[0].is_dir || !isVideoFile(targetEntries[0].name), + onClick: () => actions.onCreateVideoRoom(targetEntries[0]), + }, { key: 'directLink', label: t('Get Direct Link'), diff --git a/web/src/pages/FileExplorerPage/components/Modals/VideoRoomModal.tsx b/web/src/pages/FileExplorerPage/components/Modals/VideoRoomModal.tsx new file mode 100644 index 0000000..a43236d --- /dev/null +++ b/web/src/pages/FileExplorerPage/components/Modals/VideoRoomModal.tsx @@ -0,0 +1,95 @@ +import { memo, useEffect, useMemo, useState } from 'react'; +import { Button, Form, Input, message, Modal, Typography } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import type { VfsEntry } from '../../../../api/client'; +import { videoRoomsApi, type VideoRoomInfo } from '../../../../api/videoRooms'; +import { useSystemStatus } from '../../../../contexts/SystemContext'; +import { useI18n } from '../../../../i18n'; + +interface VideoRoomModalProps { + entry: VfsEntry | null; + path: string; + open: boolean; + onCancel: () => void; +} + +export const VideoRoomModal = memo(function VideoRoomModal({ entry, path, open, onCancel }: VideoRoomModalProps) { + const [form] = Form.useForm(); + const systemStatus = useSystemStatus(); + const { t } = useI18n(); + const [loading, setLoading] = useState(false); + const [createdRoom, setCreatedRoom] = useState(null); + + const defaultName = entry?.name || ''; + const roomUrl = useMemo(() => { + if (!createdRoom) return ''; + const baseUrl = systemStatus?.app_domain || window.location.origin; + return new URL(`/room/${createdRoom.token}`, baseUrl).href; + }, [createdRoom, systemStatus?.app_domain]); + + useEffect(() => { + if (!open) return; + setCreatedRoom(null); + form.setFieldsValue({ name: defaultName }); + }, [defaultName, form, open]); + + const handleCreate = async () => { + if (!entry) return; + try { + const values = await form.validateFields(); + setLoading(true); + const base = path === '/' ? '' : path; + const fullPath = `${base}/${entry.name}`.replace(/\/{2,}/g, '/'); + const room = await videoRoomsApi.create({ + name: values.name || entry.name, + path: fullPath, + }); + setCreatedRoom(room); + message.success(t('Video room created')); + } catch (e: any) { + message.error(e.message || t('Create failed')); + } finally { + setLoading(false); + } + }; + + const handleCopy = () => { + if (!roomUrl) return; + navigator.clipboard.writeText(roomUrl); + message.success(t('Copied to clipboard')); + }; + + return ( + + {createdRoom ? ( +
+ {t('Share this room link with friends')} +
+ +
+ + +
+
+
+
+ ) : ( +
+ + + +
+ )} +
+ ); +}); diff --git a/web/src/pages/VideoRoomPage.tsx b/web/src/pages/VideoRoomPage.tsx new file mode 100644 index 0000000..fc8efe9 --- /dev/null +++ b/web/src/pages/VideoRoomPage.tsx @@ -0,0 +1,180 @@ +import { memo, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router'; +import { Button, Empty, Spin, Typography, message } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import Artplayer from 'artplayer'; +import { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from '../api/videoRooms'; +import { useI18n } from '../i18n'; + +const { Title, Text } = Typography; + +const SYNC_THRESHOLD = 1.2; + +const VideoRoomPage = memo(function VideoRoomPage() { + const { token } = useParams<{ token: string }>(); + const { t } = useI18n(); + const artRef = useRef(null); + const artInstance = useRef(null); + const wsRef = useRef(null); + const applyingRemoteRef = useRef(false); + const sendTimerRef = useRef(null); + const [room, setRoom] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [connected, setConnected] = useState(false); + + useEffect(() => { + let mounted = true; + if (!token) return; + + videoRoomsApi.get(token) + .then((data) => { + if (!mounted) return; + setRoom(data); + }) + .catch((e: any) => { + if (!mounted) return; + setError(e.message || t('Video room load failed')); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + + return () => { + mounted = false; + }; + }, [t, token]); + + useEffect(() => { + if (!token || !room || !artRef.current) return; + + const sendState = () => { + const art = artInstance.current; + const ws = wsRef.current; + if (!art || !ws || ws.readyState !== WebSocket.OPEN || applyingRemoteRef.current) return; + const video = art.video; + const payload = { + type: 'state', + current_time: video.currentTime || 0, + paused: video.paused, + }; + ws.send(JSON.stringify(payload)); + }; + + const sendStateSoon = () => { + if (sendTimerRef.current !== null) { + window.clearTimeout(sendTimerRef.current); + } + sendTimerRef.current = window.setTimeout(() => { + sendTimerRef.current = null; + sendState(); + }, 120); + }; + + const applyState = (state: VideoRoomState) => { + const art = artInstance.current; + if (!art) return; + const video = art.video; + const targetTime = Math.max(0, Number(state.current_time) || 0); + applyingRemoteRef.current = true; + if (Math.abs((video.currentTime || 0) - targetTime) > SYNC_THRESHOLD) { + video.currentTime = targetTime; + } + if (state.paused && !video.paused) { + void video.pause(); + } + if (!state.paused && video.paused) { + void video.play().catch(() => undefined); + } + window.setTimeout(() => { + applyingRemoteRef.current = false; + }, 250); + }; + + const art = new Artplayer({ + container: artRef.current, + url: videoRoomsApi.streamUrl(token), + autoplay: false, + fullscreen: true, + fullscreenWeb: true, + pip: true, + setting: true, + playbackRate: true, + }); + artInstance.current = art; + + art.on('ready', () => applyState(room.state)); + art.on('play', sendStateSoon); + art.on('pause', sendStateSoon); + art.on('seek', sendStateSoon); + + const ws = new WebSocket(videoRoomsApi.wsUrl(token)); + wsRef.current = ws; + ws.onopen = () => setConnected(true); + ws.onclose = () => setConnected(false); + ws.onerror = () => setConnected(false); + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data?.type === 'state' && data.state) { + applyState(data.state); + } + } catch { + void 0; + } + }; + + return () => { + if (sendTimerRef.current !== null) { + window.clearTimeout(sendTimerRef.current); + sendTimerRef.current = null; + } + ws.close(); + art.destroy(); + wsRef.current = null; + artInstance.current = null; + }; + }, [room, token]); + + const handleCopy = () => { + navigator.clipboard.writeText(window.location.href); + message.success(t('Copied to clipboard')); + }; + + if (loading) { + return
; + } + + if (error || !room) { + return
; + } + + return ( +
+
+
+
+ {room.name} + + {connected ? t('Room synced') : t('Room disconnected')} + +
+ +
+
+
+
+ ); +}); + +export default VideoRoomPage; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 5369c45..c1ea71b 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -5,6 +5,7 @@ import LoginPage from '../pages/LoginPage.tsx'; import RegisterPage from '../pages/RegisterPage.tsx'; import SetupPage from '../pages/SetupPage.tsx'; import PublicSharePage from '../pages/PublicSharePage'; +import VideoRoomPage from '../pages/VideoRoomPage'; import ForgotPasswordPage from '../pages/ForgotPasswordPage'; import ResetPasswordPage from '../pages/ResetPasswordPage'; import { useAuth } from '../contexts/AuthContext'; @@ -16,6 +17,7 @@ export const routes: RouteObject[] = [ { path: '/login', element: }, { path: '/register', element: }, { path: '/share/:token', element: }, + { path: '/room/:token', element: }, { path: '/setup', element: }, { path: '/forgot-password', element: }, { path: '/reset-password', element: }, @@ -26,7 +28,7 @@ function RequireAuth({ children }: { children: JSX.Element }) { const location = useLocation(); const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']; const isPublic = publicPaths.some((p) => location.pathname.startsWith(p)); - if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) { + if (!isAuthenticated && !location.pathname.startsWith('/share/') && !location.pathname.startsWith('/room/') && !isPublic) { return ; } return children;