feat: add video room feature with API, database model, and UI integration

This commit is contained in:
shiyu
2026-05-16 10:51:23 +08:00
parent d5a24c69e1
commit 3de2615cd0
19 changed files with 690 additions and 38 deletions
+2
View File
@@ -11,6 +11,7 @@ from domain.plugins import api as plugins
from domain.processors import api as processors from domain.processors import api as processors
from domain.share import api as share from domain.share import api as share
from domain.tasks import api as tasks 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.ai import api as ai
from domain.agent import api as agent from domain.agent import api as agent
from domain.virtual_fs import api as virtual_fs 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(config.router)
app.include_router(processors.router) app.include_router(processors.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(video_rooms.router)
app.include_router(share.router) app.include_router(share.router)
app.include_router(share.public_router) app.include_router(share.public_router)
app.include_router(backup.router) app.include_router(backup.router)
+55 -36
View File
@@ -360,25 +360,14 @@ class QuarkAdapter:
if tr: if tr:
url = tr url = tr
dl_headers = self._download_headers() dl_headers = self._download_headers()
file_size = int(it.get("size") or 0)
# 预获取大小/是否支持范围
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
mime, _ = mimetypes.guess_type(rel) mime, _ = mimetypes.guess_type(rel)
content_type = mime or "application/octet-stream" content_type = mime or "application/octet-stream"
# 解析 Range # 解析 Range
start = 0 start = 0
end: Optional[int] = None end: Optional[int] = file_size - 1 if file_size > 0 else None
status_code = 200 status_code = 200
if range_header and range_header.startswith("bytes="): if range_header and range_header.startswith("bytes="):
status_code = 206 status_code = 206
@@ -388,35 +377,65 @@ class QuarkAdapter:
start = int(s) start = int(s)
if e.strip(): if e.strip():
end = int(e) 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: client = httpx.AsyncClient(timeout=None, follow_redirects=True)
end = total_size - 1 req = client.build_request("GET", url, headers=headers)
if end is not None and total_size is not None and end >= total_size: resp = await client.send(req, stream=True)
end = total_size - 1 if resp.status_code == 404:
if total_size is not None and start >= total_size: 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") 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} resp_headers: Dict[str, str] = {
if status_code == 206 and total_size is not None and end is not None: "Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
resp_headers["Content-Range"] = f"bytes {start}-{end}/{total_size}" "Content-Type": resp.headers.get("Content-Type", content_type),
resp_headers["Content-Length"] = str(end - start + 1) }
elif total_size is not None: content_range = resp.headers.get("Content-Range")
resp_headers["Content-Length"] = str(total_size) 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(): async def iterator():
headers = dict(dl_headers) try:
if status_code == 206 and end is not None: async for chunk in resp.aiter_bytes():
headers["Range"] = f"bytes={start}-{end}" if chunk:
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: yield chunk
async with client.stream("GET", url, headers=headers) as resp: finally:
if resp.status_code in (404, 416): await resp.aclose()
await resp.aclose() await client.aclose()
raise HTTPException(resp.status_code, detail="Upstream not available")
async for chunk in resp.aiter_bytes():
if chunk:
yield chunk
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)
# ----------------- # -----------------
# 上传(大文件分片) # 上传(大文件分片)
+9
View File
@@ -0,0 +1,9 @@
from .service import VideoRoomService
from .types import VideoRoomCreate, VideoRoomInfo, VideoRoomState
__all__ = [
"VideoRoomService",
"VideoRoomCreate",
"VideoRoomInfo",
"VideoRoomState",
]
+72
View File
@@ -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)
+82
View File
@@ -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
+39
View File
@@ -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,
),
)
+31
View File
@@ -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()
+17
View File
@@ -234,6 +234,23 @@ class ShareLink(Model):
table = "share_links" 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): class RecentFile(Model):
id = fields.IntField(pk=True) id = fields.IntField(pk=True)
user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField( user: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(
+1
View File
@@ -23,4 +23,5 @@ dependencies = [
"telethon>=1.42.0", "telethon>=1.42.0",
"tortoise-orm>=1.0.0", "tortoise-orm>=1.0.0",
"uvicorn>=0.40.0", "uvicorn>=0.40.0",
"websockets",
] ]
Generated
+29
View File
@@ -459,6 +459,7 @@ dependencies = [
{ name = "telethon" }, { name = "telethon" },
{ name = "tortoise-orm" }, { name = "tortoise-orm" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "websockets" },
] ]
[package.metadata] [package.metadata]
@@ -481,6 +482,7 @@ requires-dist = [
{ name = "telethon", specifier = ">=1.42.0" }, { name = "telethon", specifier = ">=1.42.0" },
{ name = "tortoise-orm", specifier = ">=1.0.0" }, { name = "tortoise-orm", specifier = ">=1.0.0" },
{ name = "uvicorn", specifier = ">=0.40.0" }, { name = "uvicorn", specifier = ">=0.40.0" },
{ name = "websockets" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "wrapt" name = "wrapt"
version = "1.17.3" version = "1.17.3"
+1
View File
@@ -73,5 +73,6 @@ async function request<T = any>(url: string, options: RequestOptions = {}): Prom
export { vfsApi, type VfsEntry, type DirListing } from './vfs'; export { vfsApi, type VfsEntry, type DirListing } from './vfs';
export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeMeta, type AdapterUsage } from './adapters'; export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeMeta, type AdapterUsage } from './adapters';
export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share'; 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 { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads';
export default request; export default request;
+35
View File
@@ -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<VideoRoomInfo>('/video-rooms', { method: 'POST', json: payload }),
get: (token: string) => request<VideoRoomInfo>(`/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;
},
};
+9
View File
@@ -242,6 +242,15 @@
"File": "File", "File": "File",
"Image": "Image", "Image": "Image",
"Video": "Video", "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", "Audio": "Audio",
"PDF": "PDF", "PDF": "PDF",
"Word": "Word", "Word": "Word",
+9
View File
@@ -261,6 +261,15 @@
"File": "文件", "File": "文件",
"Image": "图片", "Image": "图片",
"Video": "视频", "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": "音频", "Audio": "音频",
"PDF": "PDF", "PDF": "PDF",
"Word": "Word 文档", "Word": "Word 文档",
@@ -23,6 +23,7 @@ import { ProcessorModal } from './components/Modals/ProcessorModal';
import UploadModal from './components/Modals/UploadModal'; import UploadModal from './components/Modals/UploadModal';
import { ShareModal } from './components/Modals/ShareModal'; import { ShareModal } from './components/Modals/ShareModal';
import { DirectLinkModal } from './components/Modals/DirectLinkModal'; import { DirectLinkModal } from './components/Modals/DirectLinkModal';
import { VideoRoomModal } from './components/Modals/VideoRoomModal';
import { FileDetailModal } from './components/FileDetailModal'; import { FileDetailModal } from './components/FileDetailModal';
import { MoveCopyModal } from './components/Modals/MoveCopyModal'; import { MoveCopyModal } from './components/Modals/MoveCopyModal';
import { SearchResultsView } from './components/SearchResultsView'; import { SearchResultsView } from './components/SearchResultsView';
@@ -58,6 +59,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]); const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null); const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null); const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
const [videoRoomEntry, setVideoRoomEntry] = useState<VfsEntry | null>(null);
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null); const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]); const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
@@ -453,6 +455,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
open={!!directLinkEntry} open={!!directLinkEntry}
onCancel={() => setDirectLinkEntry(null)} onCancel={() => setDirectLinkEntry(null)}
/> />
<VideoRoomModal
entry={videoRoomEntry}
path={entryBasePath}
open={!!videoRoomEntry}
onCancel={() => setVideoRoomEntry(null)}
/>
<ProcessorModal <ProcessorModal
entry={processorHook.processorModal.entry} entry={processorHook.processorModal.entry}
visible={processorHook.processorModal.visible} visible={processorHook.processorModal.visible}
@@ -495,6 +503,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
onCreateFile={() => setCreatingFile(true)} onCreateFile={() => setCreatingFile(true)}
onCreateDir={() => setCreatingDir(true)} onCreateDir={() => setCreatingDir(true)}
onShare={doShare} onShare={doShare}
onCreateVideoRoom={setVideoRoomEntry}
onGetDirectLink={doGetDirectLink} onGetDirectLink={doGetDirectLink}
onMove={(entriesToMove) => setMovingEntries(entriesToMove)} onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)} onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
@@ -8,7 +8,8 @@ import { useI18n } from '../../../i18n';
import { import {
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined, FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined,
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined,
PlayCircleOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
interface ContextMenuProps { interface ContextMenuProps {
@@ -32,6 +33,7 @@ interface ContextMenuProps {
onCreateFile: () => void; onCreateFile: () => void;
onCreateDir: () => void; onCreateDir: () => void;
onShare: (entries: VfsEntry[]) => void; onShare: (entries: VfsEntry[]) => void;
onCreateVideoRoom: (entry: VfsEntry) => void;
onGetDirectLink: (entry: VfsEntry) => void; onGetDirectLink: (entry: VfsEntry) => void;
onMove: (entries: VfsEntry[]) => void; onMove: (entries: VfsEntry[]) => void;
onCopy: (entries: VfsEntry[]) => void; onCopy: (entries: VfsEntry[]) => void;
@@ -39,6 +41,8 @@ interface ContextMenuProps {
type MenuItem = Required<MenuProps>['items'][number]; type MenuItem = Required<MenuProps>['items'][number];
const isVideoFile = (name: string) => /\.(mp4|webm|ogg|m4v|mov|mkv|avi|flv)$/i.test(name);
interface ActionMenuItem { interface ActionMenuItem {
key: string; key: string;
label: React.ReactNode; label: React.ReactNode;
@@ -138,6 +142,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
icon: <ShareAltOutlined />, icon: <ShareAltOutlined />,
onClick: () => actions.onShare(targetEntries), onClick: () => actions.onShare(targetEntries),
}, },
{
key: 'videoRoom',
label: t('Create Video Room'),
icon: <PlayCircleOutlined />,
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir || !isVideoFile(targetEntries[0].name),
onClick: () => actions.onCreateVideoRoom(targetEntries[0]),
},
{ {
key: 'directLink', key: 'directLink',
label: t('Get Direct Link'), label: t('Get Direct Link'),
@@ -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<VideoRoomInfo | null>(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 (
<Modal
title={createdRoom ? t('Video room created') : t('Create Video Room')}
open={open}
onCancel={onCancel}
onOk={createdRoom ? onCancel : handleCreate}
okText={createdRoom ? t('Done') : t('Create')}
confirmLoading={loading}
destroyOnHidden
>
{createdRoom ? (
<div>
<Typography.Paragraph>{t('Share this room link with friends')}</Typography.Paragraph>
<Form layout="vertical">
<Form.Item label={t('Video Room Link')}>
<div style={{ display: 'flex', gap: 8 }}>
<Input readOnly value={roomUrl} style={{ flex: 1 }} />
<Button icon={<CopyOutlined />} onClick={handleCopy}>
{t('Copy')}
</Button>
</div>
</Form.Item>
</Form>
</div>
) : (
<Form form={form} layout="vertical" initialValues={{ name: defaultName }}>
<Form.Item name="name" label={t('Video Room Name')} rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
)}
</Modal>
);
});
+180
View File
@@ -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<HTMLDivElement | null>(null);
const artInstance = useRef<Artplayer | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const applyingRemoteRef = useRef(false);
const sendTimerRef = useRef<number | null>(null);
const [room, setRoom] = useState<VideoRoomInfo | null>(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 <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
}
if (error || !room) {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error || t('Video room not found')} /></div>;
}
return (
<div style={{ minHeight: '100vh', background: '#111', color: '#fff', padding: 24 }}>
<div style={{ maxWidth: 1120, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center', marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<Title level={3} style={{ color: '#fff', margin: 0 }}>{room.name}</Title>
<Text style={{ color: connected ? '#7dd3fc' : '#fca5a5' }}>
{connected ? t('Room synced') : t('Room disconnected')}
</Text>
</div>
<Button icon={<CopyOutlined />} onClick={handleCopy}>
{t('Copy Link')}
</Button>
</div>
<div
ref={artRef}
style={{
width: '100%',
height: 'min(70vh, 680px)',
minHeight: 360,
background: '#000',
}}
/>
</div>
</div>
);
});
export default VideoRoomPage;
+3 -1
View File
@@ -5,6 +5,7 @@ import LoginPage from '../pages/LoginPage.tsx';
import RegisterPage from '../pages/RegisterPage.tsx'; import RegisterPage from '../pages/RegisterPage.tsx';
import SetupPage from '../pages/SetupPage.tsx'; import SetupPage from '../pages/SetupPage.tsx';
import PublicSharePage from '../pages/PublicSharePage'; import PublicSharePage from '../pages/PublicSharePage';
import VideoRoomPage from '../pages/VideoRoomPage';
import ForgotPasswordPage from '../pages/ForgotPasswordPage'; import ForgotPasswordPage from '../pages/ForgotPasswordPage';
import ResetPasswordPage from '../pages/ResetPasswordPage'; import ResetPasswordPage from '../pages/ResetPasswordPage';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -16,6 +17,7 @@ export const routes: RouteObject[] = [
{ path: '/login', element: <LoginPage /> }, { path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> }, { path: '/register', element: <RegisterPage /> },
{ path: '/share/:token', element: <PublicSharePage /> }, { path: '/share/:token', element: <PublicSharePage /> },
{ path: '/room/:token', element: <VideoRoomPage /> },
{ path: '/setup', element: <SetupPage /> }, { path: '/setup', element: <SetupPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> }, { path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/reset-password', element: <ResetPasswordPage /> }, { path: '/reset-password', element: <ResetPasswordPage /> },
@@ -26,7 +28,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
const location = useLocation(); const location = useLocation();
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']; const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
const isPublic = publicPaths.some((p) => location.pathname.startsWith(p)); 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 <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
return children; return children;