Compare commits

..

9 Commits

Author SHA1 Message Date
dependabot[bot]
8ec8568686 chore(deps): bump aiohttp in the uv group across 1 directory (#126)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-21 21:47:33 +08:00
shiyu
c8b43dbf4d feat: add OpenAI protocol support and enhance AI provider configuration 2026-06-20 20:16:32 +08:00
dependabot[bot]
64fe02c23a chore(deps): bump idna in the uv group across 1 directory (#125)
Bumps the uv group with 1 update in the / directory: [idna](https://github.com/kjd/idna).


Updates `idna` from 3.11 to 3.15
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 15:58:15 +08:00
shiyu
687259511f feat: refactor state management in VideoRoomPage to improve local state updates and WebSocket communication 2026-05-16 18:51:37 +08:00
shiyu
e3a5317f6f feat: add raw stream upload functionality 2026-05-16 18:14:52 +08:00
shiyu
2af2a8756f feat: refactor VideoRoomPage by removing unused components and optimizing state management 2026-05-16 13:55:51 +08:00
shiyu
6cfafbe066 feat: add video room page with UI enhancements, state management, and localization support 2026-05-16 11:57:15 +08:00
dependabot[bot]
938f55d978 chore(deps): bump the uv group across 1 directory with 2 updates (#122)
Bumps the uv group with 2 updates in the / directory: [paramiko](https://github.com/paramiko/paramiko) and [urllib3](https://github.com/urllib3/urllib3).


Updates `paramiko` from 4.0.0 to 5.0.0
- [Commits](https://github.com/paramiko/paramiko/compare/4.0.0...5.0.0)

Updates `urllib3` from 2.6.3 to 2.7.0
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: paramiko
  dependency-version: 5.0.0
  dependency-type: direct:production
  dependency-group: uv
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-16 11:01:23 +08:00
shiyu
3de2615cd0 feat: add video room feature with API, database model, and UI integration 2026-05-16 10:51:23 +08:00
36 changed files with 1662 additions and 621 deletions

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
@@ -21,7 +22,6 @@ from domain.permission import api as permission
from domain.user import api as user from domain.user import api as user
from domain.role import api as role from domain.role import api as role
from domain.recent_files import api as recent_files from domain.recent_files import api as recent_files
from domain.video_room import api as video_room
def include_routers(app: FastAPI): def include_routers(app: FastAPI):
@@ -33,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)
@@ -49,5 +50,3 @@ def include_routers(app: FastAPI):
app.include_router(permission.router) app.include_router(permission.router)
app.include_router(user.router) app.include_router(user.router)
app.include_router(role.router) app.include_router(role.router)
app.include_router(video_room.router)
app.include_router(video_room.public_router)

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)
# ----------------- # -----------------
# 上传(大文件分片) # 上传(大文件分片)

View File

@@ -22,8 +22,11 @@ from .types import (
AIModelUpdate, AIModelUpdate,
AIProviderCreate, AIProviderCreate,
AIProviderUpdate, AIProviderUpdate,
OPENAI_PROTOCOL_CHAT_COMPLETIONS,
OPENAI_PROTOCOL_RESPONSES,
VectorDBConfigPayload, VectorDBConfigPayload,
normalize_capabilities, normalize_capabilities,
normalize_openai_protocol,
) )
from .vector_providers import ( from .vector_providers import (
BaseVectorProvider, BaseVectorProvider,
@@ -58,6 +61,9 @@ __all__ = [
"get_provider_class", "get_provider_class",
"ABILITIES", "ABILITIES",
"normalize_capabilities", "normalize_capabilities",
"normalize_openai_protocol",
"OPENAI_PROTOCOL_CHAT_COMPLETIONS",
"OPENAI_PROTOCOL_RESPONSES",
"AIDefaultsUpdate", "AIDefaultsUpdate",
"AIModelCreate", "AIModelCreate",
"AIModelUpdate", "AIModelUpdate",

View File

@@ -34,7 +34,7 @@ async def list_providers_endpoint(
@audit( @audit(
action=AuditAction.CREATE, action=AuditAction.CREATE,
description="创建 AI 提供商", description="创建 AI 提供商",
body_fields=["name", "identifier", "provider_type", "api_format", "base_url", "logo_url"], body_fields=["name", "identifier", "provider_type", "api_format", "base_url", "logo_url", "extra_config"],
redact_fields=["api_key"], redact_fields=["api_key"],
) )
@router_ai.post("/providers") @router_ai.post("/providers")
@@ -61,7 +61,7 @@ async def get_provider(
@audit( @audit(
action=AuditAction.UPDATE, action=AuditAction.UPDATE,
description="更新 AI 提供商", description="更新 AI 提供商",
body_fields=["name", "provider_type", "api_format", "base_url", "logo_url", "api_key"], body_fields=["name", "provider_type", "api_format", "base_url", "logo_url", "api_key", "extra_config"],
redact_fields=["api_key"], redact_fields=["api_key"],
) )
@router_ai.put("/providers/{provider_id}") @router_ai.put("/providers/{provider_id}")

View File

@@ -6,6 +6,7 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from models.database import AIModel, AIProvider from models.database import AIModel, AIProvider
from .service import AIProviderService from .service import AIProviderService
from .types import OPENAI_PROTOCOL_RESPONSES, normalize_openai_protocol
provider_service = AIProviderService provider_service = AIProviderService
@@ -227,6 +228,248 @@ def _is_azure_openai(provider: AIProvider) -> bool:
return ".openai.azure.com" in base_url return ".openai.azure.com" in base_url
def _openai_protocol(provider: AIProvider) -> str:
extra = provider.extra_config if isinstance(provider.extra_config, dict) else {}
return normalize_openai_protocol(extra.get("openai_protocol"))
def _content_to_text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for part in content:
if not isinstance(part, dict):
continue
text = part.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
try:
return json.dumps(content, ensure_ascii=False)
except TypeError:
return str(content)
def _openai_content_to_responses_input(content: Any) -> Any:
if content is None:
return ""
if isinstance(content, str):
return content
if not isinstance(content, list):
return _content_to_text(content)
blocks: List[Dict[str, Any]] = []
for part in content:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type in {"text", "input_text"} and isinstance(part.get("text"), str):
blocks.append({"type": "input_text", "text": part["text"]})
continue
if part_type == "image_url":
image_url = part.get("image_url")
url = image_url.get("url") if isinstance(image_url, dict) else image_url
if not isinstance(url, str) or not url.strip():
continue
block: Dict[str, Any] = {"type": "input_image", "image_url": url}
detail = image_url.get("detail") if isinstance(image_url, dict) else None
if isinstance(detail, str) and detail.strip():
block["detail"] = detail
blocks.append(block)
continue
if part_type == "input_image" and isinstance(part.get("image_url"), str):
block = {"type": "input_image", "image_url": part["image_url"]}
detail = part.get("detail")
if isinstance(detail, str) and detail.strip():
block["detail"] = detail
blocks.append(block)
continue
if part_type == "input_file" and isinstance(part.get("file_id") or part.get("filename"), str):
blocks.append(dict(part))
return blocks or ""
def _openai_tool_call_to_responses_item(call: Dict[str, Any], idx: int) -> Dict[str, Any] | None:
fn = call.get("function")
fn = fn if isinstance(fn, dict) else {}
name = fn.get("name")
if not isinstance(name, str) or not name.strip():
return None
raw_args = fn.get("arguments")
if isinstance(raw_args, dict):
args_text = json.dumps(raw_args, ensure_ascii=False)
elif isinstance(raw_args, str):
args_text = raw_args
else:
args_text = ""
return {
"type": "function_call",
"call_id": str(call.get("id") or f"call_{idx}"),
"name": name,
"arguments": args_text,
}
def _openai_messages_to_responses_input(messages: List[Dict[str, Any]]) -> Tuple[str, List[Dict[str, Any]]]:
instructions: List[str] = []
input_items: List[Dict[str, Any]] = []
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role in {"system", "developer"}:
text = _content_to_text(msg.get("content")).strip()
if text:
instructions.append(text)
continue
if role == "tool":
content = msg.get("content")
output = content if isinstance(content, str) else _content_to_text(content)
call_id = str(msg.get("tool_call_id") or "").strip()
if call_id:
input_items.append({
"type": "function_call_output",
"call_id": call_id,
"output": output,
})
elif output:
input_items.append({"role": "user", "content": output})
continue
if role == "user":
input_items.append({
"role": "user",
"content": _openai_content_to_responses_input(msg.get("content")),
})
continue
if role == "assistant":
text = _content_to_text(msg.get("content"))
if text:
input_items.append({"role": "assistant", "content": text})
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for idx, call in enumerate(tool_calls):
if not isinstance(call, dict):
continue
item = _openai_tool_call_to_responses_item(call, idx)
if item:
input_items.append(item)
if not input_items:
input_items.append({"role": "user", "content": ""})
return "\n\n".join(instructions).strip(), input_items
def _openai_tools_to_responses(tools: List[Dict[str, Any]] | None) -> List[Dict[str, Any]] | None:
if not tools:
return None
out: List[Dict[str, Any]] = []
for tool in tools:
if not isinstance(tool, dict) or tool.get("type") != "function":
continue
fn = tool.get("function")
fn = fn if isinstance(fn, dict) else {}
name = fn.get("name")
if not isinstance(name, str) or not name.strip():
continue
entry: Dict[str, Any] = {
"type": "function",
"name": name,
"description": fn.get("description") if isinstance(fn.get("description"), str) else "",
"parameters": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {},
}
if isinstance(fn.get("strict"), bool):
entry["strict"] = fn["strict"]
out.append(entry)
return out or None
def _openai_tool_choice_to_responses(tool_choice: Any) -> Any | None:
if tool_choice is None:
return None
if isinstance(tool_choice, str):
return tool_choice
if not isinstance(tool_choice, dict):
return None
if tool_choice.get("type") == "function":
fn = tool_choice.get("function")
name = fn.get("name") if isinstance(fn, dict) else tool_choice.get("name")
if isinstance(name, str) and name.strip():
return {"type": "function", "name": name}
return None
def _responses_function_call_to_openai(item: Dict[str, Any], idx: int) -> Dict[str, Any] | None:
name = item.get("name")
if not isinstance(name, str) or not name.strip():
return None
raw_args = item.get("arguments")
if isinstance(raw_args, dict):
args_text = json.dumps(raw_args, ensure_ascii=False)
elif isinstance(raw_args, str):
args_text = raw_args
else:
args_text = ""
return {
"id": str(item.get("call_id") or item.get("id") or f"call_{idx}"),
"type": "function",
"function": {"name": name, "arguments": args_text},
}
def _responses_content_to_text(content: Any) -> str:
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
parts: List[str] = []
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") in {"output_text", "text"} and isinstance(block.get("text"), str):
parts.append(block["text"])
return "".join(parts)
def _responses_body_to_openai_message(body: Dict[str, Any]) -> Dict[str, Any]:
text_parts: List[str] = []
tool_calls: List[Dict[str, Any]] = []
output = body.get("output")
if isinstance(output, list):
for idx, item in enumerate(output):
if not isinstance(item, dict):
continue
item_type = item.get("type")
if item_type == "message":
text = _responses_content_to_text(item.get("content"))
if text:
text_parts.append(text)
continue
if item_type == "function_call":
tool_call = _responses_function_call_to_openai(item, idx)
if tool_call:
tool_calls.append(tool_call)
if not text_parts and isinstance(body.get("output_text"), str):
text_parts.append(body["output_text"])
message: Dict[str, Any] = {"role": "assistant", "content": "".join(text_parts)}
if tool_calls:
message["tool_calls"] = tool_calls
return message
def _gemini_endpoint(provider: AIProvider, path: str) -> str: def _gemini_endpoint(provider: AIProvider, path: str) -> str:
base = (provider.base_url or "").rstrip("/") base = (provider.base_url or "").rstrip("/")
if not base: if not base:
@@ -808,6 +1051,12 @@ async def _chat_stream_with_ollama(
async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str: async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
if _openai_protocol(provider) == OPENAI_PROTOCOL_RESPONSES:
return await _describe_with_openai_responses(provider, model, base64_image, detail)
return await _describe_with_openai_chat_completions(provider, model, base64_image, detail)
async def _describe_with_openai_chat_completions(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
url = _openai_endpoint(provider, "/chat/completions") url = _openai_endpoint(provider, "/chat/completions")
payload = { payload = {
"model": model.name, "model": model.name,
@@ -834,6 +1083,32 @@ async def _describe_with_openai(provider: AIProvider, model: AIModel, base64_ima
return body["choices"][0]["message"]["content"] return body["choices"][0]["message"]["content"]
async def _describe_with_openai_responses(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
url = _openai_endpoint(provider, "/responses")
payload = {
"model": model.name,
"input": [
{
"role": "user",
"content": [
{
"type": "input_image",
"image_url": f"data:image/jpeg;base64,{base64_image}",
"detail": detail,
},
{"type": "input_text", "text": "描述这个图片"},
],
}
],
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=_openai_headers(provider), json=payload)
response.raise_for_status()
body = response.json()
message = _responses_body_to_openai_message(body if isinstance(body, dict) else {})
return str(message.get("content") or "")
async def _describe_with_anthropic(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str: async def _describe_with_anthropic(provider: AIProvider, model: AIModel, base64_image: str, detail: str) -> str:
url = _anthropic_endpoint(provider, "/messages") url = _anthropic_endpoint(provider, "/messages")
detail_text = f"描述这个图片,细节等级:{detail}" detail_text = f"描述这个图片,细节等级:{detail}"
@@ -1080,6 +1355,37 @@ async def _chat_with_openai(
tool_choice: Any | None, tool_choice: Any | None,
temperature: float | None, temperature: float | None,
timeout: float, timeout: float,
) -> Dict[str, Any]:
if _openai_protocol(provider) == OPENAI_PROTOCOL_RESPONSES:
return await _chat_with_openai_responses(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
)
return await _chat_with_openai_chat_completions(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
)
async def _chat_with_openai_chat_completions(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
tool_choice: Any | None,
temperature: float | None,
timeout: float,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
url = _openai_endpoint(provider, "/chat/completions") url = _openai_endpoint(provider, "/chat/completions")
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
@@ -1106,6 +1412,41 @@ async def _chat_with_openai(
return message return message
async def _chat_with_openai_responses(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
tool_choice: Any | None,
temperature: float | None,
timeout: float,
) -> Dict[str, Any]:
url = _openai_endpoint(provider, "/responses")
instructions, input_items = _openai_messages_to_responses_input(messages)
payload: Dict[str, Any] = {
"model": model.name,
"input": input_items,
}
if instructions:
payload["instructions"] = instructions
response_tools = _openai_tools_to_responses(tools)
if response_tools:
payload["tools"] = response_tools
payload["tool_choice"] = _openai_tool_choice_to_responses(tool_choice) or "auto"
if temperature is not None:
payload["temperature"] = float(temperature)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, headers=_openai_headers(provider), json=payload)
response.raise_for_status()
body = response.json()
if not isinstance(body, dict):
raise RuntimeError("Responses 接口返回格式异常")
return _responses_body_to_openai_message(body)
async def chat_completion_stream( async def chat_completion_stream(
messages: List[Dict[str, Any]], messages: List[Dict[str, Any]],
*, *,
@@ -1174,6 +1515,40 @@ async def _chat_stream_with_openai(
tool_choice: Any | None, tool_choice: Any | None,
temperature: float | None, temperature: float | None,
timeout: float, timeout: float,
) -> AsyncIterator[Dict[str, Any]]:
if _openai_protocol(provider) == OPENAI_PROTOCOL_RESPONSES:
async for event in _chat_stream_with_openai_responses(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
):
yield event
return
async for event in _chat_stream_with_openai_chat_completions(
provider,
model,
messages,
tools=tools,
tool_choice=tool_choice,
temperature=temperature,
timeout=timeout,
):
yield event
async def _chat_stream_with_openai_chat_completions(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
tool_choice: Any | None,
temperature: float | None,
timeout: float,
) -> AsyncIterator[Dict[str, Any]]: ) -> AsyncIterator[Dict[str, Any]]:
url = _openai_endpoint(provider, "/chat/completions") url = _openai_endpoint(provider, "/chat/completions")
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
@@ -1273,3 +1648,100 @@ async def _chat_stream_with_openai(
message["tool_calls"] = tool_calls message["tool_calls"] = tool_calls
yield {"type": "message", "message": message, "finish_reason": finish_reason} yield {"type": "message", "message": message, "finish_reason": finish_reason}
async def _chat_stream_with_openai_responses(
provider: AIProvider,
model: AIModel,
messages: List[Dict[str, Any]],
*,
tools: List[Dict[str, Any]] | None,
tool_choice: Any | None,
temperature: float | None,
timeout: float,
) -> AsyncIterator[Dict[str, Any]]:
url = _openai_endpoint(provider, "/responses")
instructions, input_items = _openai_messages_to_responses_input(messages)
payload: Dict[str, Any] = {
"model": model.name,
"input": input_items,
"stream": True,
}
if instructions:
payload["instructions"] = instructions
response_tools = _openai_tools_to_responses(tools)
if response_tools:
payload["tools"] = response_tools
payload["tool_choice"] = _openai_tool_choice_to_responses(tool_choice) or "auto"
if temperature is not None:
payload["temperature"] = float(temperature)
content_parts: List[str] = []
output_items: Dict[int, Dict[str, Any]] = {}
completed_body: Dict[str, Any] | None = None
current_event: str | None = None
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, headers=_openai_headers(provider), json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
if line.startswith("event:"):
current_event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data or data == "[DONE]":
continue
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if not isinstance(chunk, dict):
continue
event_type = chunk.get("type") or current_event
if event_type == "response.output_text.delta":
delta = chunk.get("delta")
if isinstance(delta, str) and delta:
content_parts.append(delta)
yield {"type": "delta", "delta": delta}
continue
if event_type in {"response.output_item.added", "response.output_item.done"}:
item = chunk.get("item")
idx = chunk.get("output_index")
if isinstance(item, dict) and isinstance(idx, int):
output_items[idx] = item
continue
if event_type == "response.completed":
response_body = chunk.get("response")
if isinstance(response_body, dict):
completed_body = response_body
elif isinstance(chunk.get("output"), list):
completed_body = chunk
break
if event_type == "error":
error = chunk.get("error") if isinstance(chunk.get("error"), dict) else chunk
raise RuntimeError(str(error.get("message") if isinstance(error, dict) else error))
if completed_body is not None:
message = _responses_body_to_openai_message(completed_body)
if not message.get("content") and content_parts:
message["content"] = "".join(content_parts)
yield {"type": "message", "message": message, "finish_reason": None}
return
if output_items:
body = {"output": [output_items[idx] for idx in sorted(output_items.keys())]}
message = _responses_body_to_openai_message(body)
if not message.get("content") and content_parts:
message["content"] = "".join(content_parts)
yield {"type": "message", "message": message, "finish_reason": None}
return
yield {"type": "message", "message": {"role": "assistant", "content": "".join(content_parts)}, "finish_reason": None}

View File

@@ -3,6 +3,19 @@ from typing import Any, Dict, Iterable, List, Optional
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
ABILITIES = ["chat", "vision", "embedding", "rerank", "voice", "tools"] ABILITIES = ["chat", "vision", "embedding", "rerank", "voice", "tools"]
OPENAI_PROTOCOL_CHAT_COMPLETIONS = "chat_completions"
OPENAI_PROTOCOL_RESPONSES = "responses"
OPENAI_PROTOCOLS = {OPENAI_PROTOCOL_CHAT_COMPLETIONS, OPENAI_PROTOCOL_RESPONSES}
OPENAI_PROTOCOL_ALIASES = {
"chat": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
"chat_completion": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
"chat_completions": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
"chat/completions": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
"/chat/completions": OPENAI_PROTOCOL_CHAT_COMPLETIONS,
"response": OPENAI_PROTOCOL_RESPONSES,
"responses": OPENAI_PROTOCOL_RESPONSES,
"/responses": OPENAI_PROTOCOL_RESPONSES,
}
def normalize_capabilities(items: Optional[Iterable[str]]) -> List[str]: def normalize_capabilities(items: Optional[Iterable[str]]) -> List[str]:
@@ -16,6 +29,34 @@ def normalize_capabilities(items: Optional[Iterable[str]]) -> List[str]:
return normalized return normalized
def normalize_openai_protocol(value: Any) -> str:
if value is None:
return OPENAI_PROTOCOL_CHAT_COMPLETIONS
key = str(value).strip().lower().replace("-", "_").replace(".", "_")
if not key:
return OPENAI_PROTOCOL_CHAT_COMPLETIONS
normalized = OPENAI_PROTOCOL_ALIASES.get(key)
if normalized:
return normalized
normalized = OPENAI_PROTOCOL_ALIASES.get(key.replace("_", "/"))
if normalized:
return normalized
if key in OPENAI_PROTOCOLS:
return key
raise ValueError("openai_protocol must be 'chat_completions' or 'responses'")
def normalize_provider_extra_config(config: Optional[dict]) -> Optional[dict]:
if config is None:
return None
if not isinstance(config, dict):
raise ValueError("extra_config must be an object")
normalized = dict(config)
if "openai_protocol" in normalized:
normalized["openai_protocol"] = normalize_openai_protocol(normalized.get("openai_protocol"))
return normalized
class AIProviderBase(BaseModel): class AIProviderBase(BaseModel):
name: str name: str
identifier: str = Field(..., pattern=r"^[a-z0-9_\-\.]+$") identifier: str = Field(..., pattern=r"^[a-z0-9_\-\.]+$")
@@ -34,6 +75,11 @@ class AIProviderBase(BaseModel):
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'") raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
return fmt return fmt
@field_validator("extra_config")
@classmethod
def normalize_extra_config(cls, value: Optional[dict]) -> Optional[dict]:
return normalize_provider_extra_config(value)
class AIProviderCreate(AIProviderBase): class AIProviderCreate(AIProviderBase):
pass pass
@@ -58,6 +104,11 @@ class AIProviderUpdate(BaseModel):
raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'") raise ValueError("api_format must be 'openai', 'gemini', 'anthropic', or 'ollama'")
return fmt return fmt
@field_validator("extra_config")
@classmethod
def normalize_extra_config(cls, value: Optional[dict]) -> Optional[dict]:
return normalize_provider_extra_config(value)
class AIModelBase(BaseModel): class AIModelBase(BaseModel):
name: str name: str

View File

@@ -1,3 +0,0 @@
from .service import VideoRoomService
__all__ = ["VideoRoomService"]

View File

@@ -1,97 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
from api.response import success
from domain.auth import User, get_current_active_user
from domain.video_room.service import VideoRoomService
from domain.video_room.types import PlaybackEvent, VideoRoomCreate, VideoRoomInfo
router = APIRouter(prefix="/api/video-rooms", tags=["Video Rooms"])
public_router = APIRouter(prefix="/api/watch", tags=["Video Rooms - Public"])
@router.post("", response_model=VideoRoomInfo)
async def create_video_room(
request: Request,
payload: VideoRoomCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
):
room = await VideoRoomService.create_room(
user_id=current_user.id,
path=payload.path,
name=payload.name,
expires_in_days=payload.expires_in_days,
control_mode=payload.control_mode,
)
return VideoRoomInfo(
id=room.id,
name=room.name,
token=room.token,
path=room.path,
control_mode=room.control_mode,
created_at=room.created_at.isoformat(),
expires_at=room.expires_at.isoformat() if room.expires_at else None,
)
@public_router.get("/{token}")
async def get_watch_room(request: Request, token: str):
room = await VideoRoomService.get_room_by_token(token)
state = await VideoRoomService.get_state(room.id)
return success({"room": VideoRoomInfo(
id=room.id,
name=room.name,
token=room.token,
path=room.path,
control_mode=room.control_mode,
created_at=room.created_at.isoformat(),
expires_at=room.expires_at.isoformat() if room.expires_at else None,
).model_dump(), "playback": state})
@public_router.websocket("/{token}/ws")
async def watch_room_ws(websocket: WebSocket, token: str):
room = await VideoRoomService.get_room_by_token(token)
actor = websocket.query_params.get("actor") or "guest"
await VideoRoomService.ws_connect(room.id, websocket)
try:
state = await VideoRoomService.get_state(room.id)
await websocket.send_json({"type": "snapshot", "playback": state})
while True:
msg = await websocket.receive_json()
if msg.get("type") == "ping":
await websocket.send_json({"type": "pong"})
continue
event_type = msg.get("event")
if event_type not in {"play", "pause", "seek", "rate"}:
continue
position_ms = int(msg.get("position_ms") or 0)
playback_rate = float(msg.get("playback_rate") or 1.0)
state = await VideoRoomService.apply_event(
room=room,
actor=actor,
event_type=event_type,
position_ms=position_ms,
playback_rate=playback_rate,
)
await VideoRoomService.ws_broadcast(room.id, {"type": "playback", "event": event_type, "playback": state, "actor": actor})
except WebSocketDisconnect:
pass
finally:
await VideoRoomService.ws_disconnect(room.id, websocket)
@public_router.post("/{token}/events")
async def push_watch_event(request: Request, token: str, payload: PlaybackEvent):
room = await VideoRoomService.get_room_by_token(token)
actor = request.headers.get("X-Watch-Actor", "guest")
state = await VideoRoomService.apply_event(
room=room,
actor=actor,
event_type=payload.type,
position_ms=payload.position_ms,
playback_rate=payload.playback_rate,
)
await VideoRoomService.ws_broadcast(room.id, {"type": "playback", "event": payload.type, "playback": state, "actor": actor})
return success({"playback": state})

View File

@@ -1,142 +0,0 @@
import asyncio
import json
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import HTTPException, WebSocket
from starlette.websockets import WebSocketState
from models.database import VideoRoom
VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".m4v"}
class VideoRoomService:
_runtime_states: dict[int, dict] = {}
_room_clients: dict[int, set[WebSocket]] = {}
_lock = asyncio.Lock()
@classmethod
def _now(cls) -> datetime:
return datetime.now(timezone.utc)
@classmethod
def _iso_now(cls) -> str:
return cls._now().isoformat()
@classmethod
def _ensure_video_path(cls, path: str) -> None:
p = path.lower()
if not any(p.endswith(ext) for ext in VIDEO_EXTS):
raise HTTPException(status_code=400, detail="仅支持视频文件创建视频间")
@classmethod
def _calc_expires_at(cls, expires_in_days: Optional[int]) -> Optional[datetime]:
if expires_in_days is None or expires_in_days <= 0:
return None
return cls._now() + timedelta(days=expires_in_days)
@classmethod
async def create_room(cls, *, user_id: int, path: str, name: Optional[str], expires_in_days: Optional[int], control_mode: str):
cls._ensure_video_path(path)
token = secrets.token_urlsafe(18)
room_name = name or f"{path.split('/')[-1]} 的视频间"
room = await VideoRoom.create(
token=token,
name=room_name,
path=path,
owner_id=user_id,
control_mode=control_mode,
expires_at=cls._calc_expires_at(expires_in_days),
)
cls._runtime_states[room.id] = {
"position_ms": 0,
"is_paused": True,
"playback_rate": 1.0,
"updated_at": cls._iso_now(),
"updated_by": f"user:{user_id}",
}
return room
@classmethod
async def get_room_by_token(cls, token: str) -> VideoRoom:
room = await VideoRoom.get_or_none(token=token)
if not room:
raise HTTPException(status_code=404, detail="视频间不存在")
if room.expires_at and room.expires_at < cls._now():
raise HTTPException(status_code=410, detail="视频间已过期")
return room
@classmethod
async def get_state(cls, room_id: int) -> dict:
return cls._runtime_states.setdefault(
room_id,
{
"position_ms": 0,
"is_paused": True,
"playback_rate": 1.0,
"updated_at": cls._iso_now(),
"updated_by": "system",
},
)
@classmethod
async def apply_event(cls, *, room: VideoRoom, actor: str, event_type: str, position_ms: int, playback_rate: float):
state = await cls.get_state(room.id)
if room.control_mode == "host_only" and actor != f"user:{room.owner_id}":
raise HTTPException(status_code=403, detail="仅房主可控制播放")
if event_type == "play":
state["is_paused"] = False
elif event_type == "pause":
state["is_paused"] = True
elif event_type == "seek":
state["position_ms"] = max(position_ms, 0)
elif event_type == "rate":
state["playback_rate"] = playback_rate
state["updated_at"] = cls._iso_now()
state["updated_by"] = actor
return state
@classmethod
async def ws_connect(cls, room_id: int, websocket: WebSocket):
await websocket.accept()
async with cls._lock:
clients = cls._room_clients.setdefault(room_id, set())
clients.add(websocket)
@classmethod
async def ws_disconnect(cls, room_id: int, websocket: WebSocket):
async with cls._lock:
clients = cls._room_clients.get(room_id)
if not clients:
return
clients.discard(websocket)
if not clients:
cls._room_clients.pop(room_id, None)
@classmethod
async def ws_broadcast(cls, room_id: int, payload: dict):
clients = list(cls._room_clients.get(room_id, set()))
if not clients:
return
text = json.dumps(payload)
stale: list[WebSocket] = []
for ws in clients:
try:
if ws.application_state == WebSocketState.CONNECTED:
await ws.send_text(text)
else:
stale.append(ws)
except Exception:
stale.append(ws)
if stale:
async with cls._lock:
pool = cls._room_clients.get(room_id, set())
for ws in stale:
pool.discard(ws)
if not pool:
cls._room_clients.pop(room_id, None)

View File

@@ -1,48 +0,0 @@
from typing import Literal, Optional
from pydantic import BaseModel, Field
VideoEventType = Literal["play", "pause", "seek", "rate"]
class VideoRoomCreate(BaseModel):
path: str
name: Optional[str] = None
expires_in_days: Optional[int] = 1
control_mode: Literal["host_only", "everyone"] = "everyone"
class VideoRoomJoin(BaseModel):
nickname: Optional[str] = None
class VideoRoomInfo(BaseModel):
id: int
name: str
token: str
path: str
control_mode: str
created_at: str
expires_at: Optional[str] = None
class PlaybackState(BaseModel):
position_ms: int = 0
is_paused: bool = True
playback_rate: float = Field(default=1.0, ge=0.25, le=4.0)
updated_at: str
updated_by: str
class RoomStateResponse(BaseModel):
room: VideoRoomInfo
playback: PlaybackState
members_online: int
class PlaybackEvent(BaseModel):
type: VideoEventType
position_ms: int = 0
playback_rate: float = Field(default=1.0, ge=0.25, le=4.0)
client_ts: Optional[int] = None

View File

@@ -0,0 +1,9 @@
from .service import VideoRoomService
from .types import VideoRoomCreate, VideoRoomInfo, VideoRoomState
__all__ = [
"VideoRoomService",
"VideoRoomCreate",
"VideoRoomInfo",
"VideoRoomState",
]

72
domain/video_rooms/api.py Normal file
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)

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

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
domain/video_rooms/ws.py Normal file
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()

View File

@@ -172,6 +172,19 @@ async def upload_stream(
return success(result) return success(result)
@router.put("/upload-raw/{full_path:path}")
@audit(action=AuditAction.UPLOAD, description="原始流上传文件")
@require_path_permission(PathAction.WRITE, "full_path")
async def upload_raw_stream(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
full_path: str,
overwrite: bool = Query(True, description="是否覆盖已存在文件"),
):
result = await VirtualFSService.upload_raw_stream(full_path, request, overwrite)
return success(result)
@router.get("/{full_path:path}") @router.get("/{full_path:path}")
@audit(action=AuditAction.READ, description="浏览目录") @audit(action=AuditAction.READ, description="浏览目录")
@require_path_permission(PathAction.READ, "full_path") @require_path_permission(PathAction.READ, "full_path")

View File

@@ -2,7 +2,7 @@ import mimetypes
import re import re
from urllib.parse import quote from urllib.parse import quote
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, Request, UploadFile
from fastapi.responses import Response from fastapi.responses import Response
from domain.config import ConfigService from domain.config import ConfigService
@@ -271,6 +271,24 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
size = int(result or 0) size = int(result or 0)
return {"uploaded": True, "path": path, "size": size, "overwrite": overwrite} return {"uploaded": True, "path": path, "size": size, "overwrite": overwrite}
@classmethod
async def upload_raw_stream(cls, full_path: str, request: Request, overwrite: bool):
full_path = cls._normalize_path(full_path)
if full_path.endswith("/"):
raise HTTPException(400, detail="Path must be a file")
result = await cls.write_file_stream(full_path, request.stream(), overwrite=overwrite)
path = full_path
size = 0
if isinstance(result, dict):
path = result.get("path") or path
size_val = result.get("size")
if isinstance(size_val, int):
size = size_val
else:
size = int(result or 0)
return {"uploaded": True, "path": path, "size": size, "overwrite": overwrite}
@classmethod @classmethod
async def list_directory(cls, full_path: str, page_num: int, page_size: int, sort_by: str, sort_order: str): async def list_directory(cls, full_path: str, page_num: int, page_size: int, sort_by: str, sort_order: str):
full_path = cls._normalize_path(full_path) full_path = cls._normalize_path(full_path)

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(
@@ -291,19 +308,3 @@ class Plugin(Model):
class Meta: class Meta:
table = "plugins" table = "plugins"
class VideoRoom(Model):
id = fields.IntField(pk=True)
token = fields.CharField(max_length=120, unique=True, index=True)
name = fields.CharField(max_length=255)
path = fields.CharField(max_length=4096)
owner: fields.ForeignKeyRelation[UserAccount] = fields.ForeignKeyField(
"models.UserAccount", related_name="video_rooms", on_delete=fields.CASCADE
)
control_mode = fields.CharField(max_length=20, default="everyone")
created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField(null=True)
class Meta:
table = "video_rooms"

View File

@@ -10,7 +10,7 @@ dependencies = [
"croniter>=6.0.0", "croniter>=6.0.0",
"fastapi>=0.127.0", "fastapi>=0.127.0",
"mcp>=1.26.0", "mcp>=1.26.0",
"paramiko>=4.0.0", "paramiko>=5.0.0",
"pillow>=12.2.0", "pillow>=12.2.0",
"pydantic[email]>=2.12.5", "pydantic[email]>=2.12.5",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
@@ -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",
] ]

128
uv.lock generated
View File

@@ -63,7 +63,7 @@ wheels = [
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
version = "3.13.4" version = "3.14.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiohappyeyeballs" }, { name = "aiohappyeyeballs" },
@@ -74,42 +74,49 @@ dependencies = [
{ name = "propcache" }, { name = "propcache" },
{ name = "yarl" }, { name = "yarl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, { url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, { url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" },
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, { url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, { url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, { url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" },
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, { url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, { url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, { url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, { url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" },
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, { url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, { url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, { url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, { url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" },
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, { url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" },
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, { url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" },
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, { url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" },
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, { url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" },
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, { url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" },
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, { url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, { url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, { url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, { url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, { url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, { url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, { url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, { url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, { url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, { url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" },
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, { url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, { url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" },
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, { url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, { url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, { url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, { url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" },
{ url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" },
{ url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" },
{ url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" },
{ url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" },
{ url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" },
{ url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" },
{ url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" },
] ]
[[package]] [[package]]
@@ -459,6 +466,7 @@ dependencies = [
{ name = "telethon" }, { name = "telethon" },
{ name = "tortoise-orm" }, { name = "tortoise-orm" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "websockets" },
] ]
[package.metadata] [package.metadata]
@@ -468,7 +476,7 @@ requires-dist = [
{ name = "croniter", specifier = ">=6.0.0" }, { name = "croniter", specifier = ">=6.0.0" },
{ name = "fastapi", specifier = ">=0.127.0" }, { name = "fastapi", specifier = ">=0.127.0" },
{ name = "mcp", specifier = ">=1.26.0" }, { name = "mcp", specifier = ">=1.26.0" },
{ name = "paramiko", specifier = ">=4.0.0" }, { name = "paramiko", specifier = ">=5.0.0" },
{ name = "pillow", specifier = ">=12.2.0" }, { name = "pillow", specifier = ">=12.2.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" },
{ name = "pyjwt", specifier = ">=2.10.1" }, { name = "pyjwt", specifier = ">=2.10.1" },
@@ -481,6 +489,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]]
@@ -629,11 +638,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.15"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
] ]
[[package]] [[package]]
@@ -857,7 +866,7 @@ wheels = [
[[package]] [[package]]
name = "paramiko" name = "paramiko"
version = "4.0.0" version = "5.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "bcrypt" }, { name = "bcrypt" },
@@ -865,9 +874,9 @@ dependencies = [
{ name = "invoke" }, { name = "invoke" },
{ name = "pynacl" }, { name = "pynacl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
] ]
[[package]] [[package]]
@@ -1409,11 +1418,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]
@@ -1429,6 +1438,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"

View File

@@ -1,6 +1,7 @@
import request from './client'; import request from './client';
export type AIAbility = 'chat' | 'vision' | 'embedding' | 'rerank' | 'voice' | 'tools'; export type AIAbility = 'chat' | 'vision' | 'embedding' | 'rerank' | 'voice' | 'tools';
export type OpenAIProtocol = 'chat_completions' | 'responses';
export interface AIProviderPayload { export interface AIProviderPayload {
name: string; name: string;

View File

@@ -73,7 +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;
export { videoRoomApi, type VideoRoomInfo, type VideoRoomState } from './videoRoom';

View File

@@ -100,6 +100,40 @@ export const vfsApi = {
getTempLinkToken: (path: string, expiresIn: number = 3600) => getTempLinkToken: (path: string, expiresIn: number = 3600) =>
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`), request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`, getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
uploadRaw: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
return new Promise<any>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', `${API_BASE_URL}/fs/upload-raw/${enc}?overwrite=${overwrite}`);
const token = localStorage.getItem('token');
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable && onProgress) onProgress(ev.loaded, ev.total);
};
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const json = JSON.parse(xhr.responseText);
if (json.code === 0) return resolve(json.data);
return reject(new Error(json.msg || json.message || 'Upload failed'));
} catch {
return reject(new Error('Invalid response'));
}
} else {
let err = 'Upload failed';
try {
const json = JSON.parse(xhr.responseText);
err = json.detail || json.msg || json.message || err;
} catch { void 0; }
reject(new Error(err));
}
}
};
xhr.send(file);
});
},
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => { uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
const enc = encodeURI(fullPath.replace(/^\/+/, '')); const enc = encodeURI(fullPath.replace(/^\/+/, ''));
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {

View File

@@ -1,47 +0,0 @@
import request, { API_BASE_URL } from './client';
export interface VideoRoomInfo {
id: number;
name: string;
token: string;
path: string;
control_mode: 'host_only' | 'everyone';
created_at: string;
expires_at?: string | null;
}
export interface VideoPlaybackState {
position_ms: number;
is_paused: boolean;
playback_rate: number;
updated_at: string;
updated_by: string;
}
export interface VideoRoomState {
room: VideoRoomInfo;
playback: VideoPlaybackState;
}
export interface VideoRoomCreatePayload {
path: string;
name?: string;
expires_in_days?: number;
control_mode?: 'host_only' | 'everyone';
}
export const videoRoomApi = {
create: (payload: VideoRoomCreatePayload) => request<VideoRoomInfo>('/video-rooms', { method: 'POST', json: payload }),
getState: (token: string) => request<VideoRoomState>(`/watch/${token}`),
pushEvent: (token: string, payload: { type: 'play' | 'pause' | 'seek' | 'rate'; position_ms?: number; playback_rate?: number }, actorId?: string) =>
request<{ playback: VideoPlaybackState }>(`/watch/${token}/events`, {
method: 'POST',
json: payload,
headers: actorId ? { 'X-Watch-Actor': actorId } : undefined,
}),
streamUrl: (token: string, path: string) => `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`,
connectWs: (token: string, actorId: string) => {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
return new WebSocket(`${proto}://${window.location.host}/api/watch/${token}/ws?actor=${encodeURIComponent(actorId)}`);
},
};

35
web/src/api/videoRooms.ts Normal file
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;
},
};

View File

@@ -242,6 +242,27 @@
"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",
"Share room": "Share room",
"Now watching": "Now watching",
"Everyone follows the same playback": "Everyone follows the same playback",
"Waiting for sync connection": "Waiting for sync connection",
"Playback state is shared in this room": "Playback state is shared in this room",
"Room status": "Room status",
"Playback": "Playback",
"Playing": "Playing",
"Paused": "Paused",
"Current position": "Current position",
"Resync playback": "Resync playback",
"Room link": "Room link",
"Audio": "Audio", "Audio": "Audio",
"PDF": "PDF", "PDF": "PDF",
"Word": "Word", "Word": "Word",
@@ -495,6 +516,7 @@
"Enter identifier": "Enter identifier", "Enter identifier": "Enter identifier",
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "Only lowercase letters, numbers, dash, dot and underscore are allowed", "Only lowercase letters, numbers, dash, dot and underscore are allowed": "Only lowercase letters, numbers, dash, dot and underscore are allowed",
"API Format": "API Format", "API Format": "API Format",
"OpenAI Protocol": "OpenAI Protocol",
"Base URL": "Base URL", "Base URL": "Base URL",
"Enter base url": "Enter base URL", "Enter base url": "Enter base URL",
"Optional, can also be provided per request": "Optional, can also be provided per request", "Optional, can also be provided per request": "Optional, can also be provided per request",

View File

@@ -261,6 +261,27 @@
"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": "房间已断开",
"Share room": "分享房间",
"Now watching": "正在观看",
"Everyone follows the same playback": "所有人同步播放",
"Waiting for sync connection": "等待同步连接",
"Playback state is shared in this room": "房间内会同步播放、暂停和进度",
"Room status": "房间状态",
"Playback": "播放状态",
"Playing": "播放中",
"Paused": "已暂停",
"Current position": "当前进度",
"Resync playback": "重新同步",
"Room link": "房间链接",
"Audio": "音频", "Audio": "音频",
"PDF": "PDF", "PDF": "PDF",
"Word": "Word 文档", "Word": "Word 文档",
@@ -494,6 +515,7 @@
"Enter identifier": "请输入标识符", "Enter identifier": "请输入标识符",
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "仅允许小写字母、数字、连字符、点和下划线", "Only lowercase letters, numbers, dash, dot and underscore are allowed": "仅允许小写字母、数字、连字符、点和下划线",
"API Format": "API 格式", "API Format": "API 格式",
"OpenAI Protocol": "OpenAI 协议",
"Base URL": "基础 URL", "Base URL": "基础 URL",
"Enter base url": "请输入基础 URL", "Enter base url": "请输入基础 URL",
"Optional, can also be provided per request": "可选,也可在请求时提供", "Optional, can also be provided per request": "可选,也可在请求时提供",

View File

@@ -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)}

View File

@@ -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'),

View File

@@ -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>
);
});

View File

@@ -43,6 +43,8 @@ interface RawUploadDirectory {
type RawUploadItem = RawUploadFile | RawUploadDirectory; type RawUploadItem = RawUploadFile | RawUploadDirectory;
const MAX_UPLOAD_CONCURRENCY = 3;
const generateId = (() => { const generateId = (() => {
const cryptoApi = typeof crypto !== 'undefined' ? crypto : undefined; const cryptoApi = typeof crypto !== 'undefined' ? crypto : undefined;
return () => { return () => {
@@ -457,15 +459,15 @@ export function useUploader(path: string, onUploadComplete: () => void) {
} }
}, [ensureDirectory, updateFile, t]); }, [ensureDirectory, updateFile, t]);
const processFileTask = useCallback(async (task: UploadFile) => { const prepareFileTask = useCallback(async (task: UploadFile): Promise<{ task: UploadFile; overwrite: boolean } | null> => {
if (!task.file) { if (!task.file) {
updateFile(task.id, { status: 'error', error: t('Missing file content') }); updateFile(task.id, { status: 'error', error: t('Missing file content') });
return; return null;
} }
if (skipAllRef.current) { if (skipAllRef.current) {
updateFile(task.id, { status: 'skipped', progress: 0 }); updateFile(task.id, { status: 'skipped', progress: 0 });
return; return null;
} }
let shouldOverwrite = overwriteAllRef.current; let shouldOverwrite = overwriteAllRef.current;
@@ -475,19 +477,28 @@ export function useUploader(path: string, onUploadComplete: () => void) {
const decision = await awaitConflictDecision(task); const decision = await awaitConflictDecision(task);
if (decision === 'skip') { if (decision === 'skip') {
updateFile(task.id, { status: 'skipped', progress: 0 }); updateFile(task.id, { status: 'skipped', progress: 0 });
return; return null;
} }
shouldOverwrite = true; shouldOverwrite = true;
} }
} }
setConflict(null); setConflict(null);
updateFile(task.id, { status: 'uploading', progress: 0, loadedBytes: 0 });
const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/'; const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/';
await ensureDirectoryTree(parentDir);
updateFile(task.id, { status: 'pending', progress: 0, loadedBytes: 0 });
return { task, overwrite: shouldOverwrite };
}, [ensureDirectoryTree, awaitConflictDecision, updateFile, t]);
const uploadPreparedFile = useCallback(async (task: UploadFile, shouldOverwrite: boolean) => {
if (!task.file) {
updateFile(task.id, { status: 'error', error: t('Missing file content') });
return;
}
updateFile(task.id, { status: 'uploading', progress: 0, loadedBytes: 0 });
try { try {
await ensureDirectoryTree(parentDir); const uploadResult = await vfsApi.uploadRaw(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
const uploadResult = await vfsApi.uploadStream(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
mutateFiles((prev) => prev.map((f) => { mutateFiles((prev) => prev.map((f) => {
if (f.id !== task.id) return f; if (f.id !== task.id) return f;
const effectiveTotal = total > 0 ? total : f.size; const effectiveTotal = total > 0 ? total : f.size;
@@ -506,22 +517,34 @@ export function useUploader(path: string, onUploadComplete: () => void) {
const finalSize = typeof uploadResult?.size === 'number' && uploadResult.size > 0 const finalSize = typeof uploadResult?.size === 'number' && uploadResult.size > 0
? uploadResult.size ? uploadResult.size
: task.size; : task.size;
const link = await vfsApi.getTempLinkToken(actualPath, 60 * 60 * 24 * 365 * 10);
const permanentLink = vfsApi.getTempPublicUrl(link.token);
updateFile(task.id, { updateFile(task.id, {
status: 'success', status: 'success',
progress: 100, progress: 100,
loadedBytes: finalSize, loadedBytes: finalSize,
size: finalSize, size: finalSize,
targetPath: actualPath, targetPath: actualPath,
permanentLink,
}); });
} catch (err: unknown) { } catch (err: unknown) {
const error = err instanceof Error ? err.message : t('Upload failed'); const error = err instanceof Error ? err.message : t('Upload failed');
updateFile(task.id, { status: 'error', error, progress: 0 }); updateFile(task.id, { status: 'error', error, progress: 0 });
message.error(`${task.relativePath}: ${error}`); message.error(`${task.relativePath}: ${error}`);
} }
}, [ensureDirectoryTree, awaitConflictDecision, mutateFiles, updateFile, t]); }, [mutateFiles, updateFile, t]);
const uploadPreparedFiles = useCallback(async (preparedFiles: Array<{ task: UploadFile; overwrite: boolean }>) => {
let nextIndex = 0;
const workerCount = Math.min(MAX_UPLOAD_CONCURRENCY, preparedFiles.length);
const runWorker = async () => {
while (nextIndex < preparedFiles.length) {
const current = preparedFiles[nextIndex];
nextIndex += 1;
await uploadPreparedFile(current.task, current.overwrite);
}
};
await Promise.all(Array.from({ length: workerCount }, runWorker));
}, [uploadPreparedFile]);
const startUpload = useCallback(async () => { const startUpload = useCallback(async () => {
if (isUploadingRef.current) return; if (isUploadingRef.current) return;
@@ -530,6 +553,7 @@ export function useUploader(path: string, onUploadComplete: () => void) {
isUploadingRef.current = true; isUploadingRef.current = true;
setIsUploading(true); setIsUploading(true);
try { try {
const preparedFiles: Array<{ task: UploadFile; overwrite: boolean }> = [];
for (const task of filesRef.current) { for (const task of filesRef.current) {
if (task.status !== 'pending' && task.status !== 'waiting') { if (task.status !== 'pending' && task.status !== 'waiting') {
continue; continue;
@@ -537,15 +561,17 @@ export function useUploader(path: string, onUploadComplete: () => void) {
if (task.type === 'directory') { if (task.type === 'directory') {
await processDirectoryTask(task); await processDirectoryTask(task);
} else { } else {
await processFileTask(task); const prepared = await prepareFileTask(task);
if (prepared) preparedFiles.push(prepared);
} }
} }
await uploadPreparedFiles(preparedFiles);
onUploadComplete(); onUploadComplete();
} finally { } finally {
isUploadingRef.current = false; isUploadingRef.current = false;
setIsUploading(false); setIsUploading(false);
} }
}, [onUploadComplete, processDirectoryTask, processFileTask]); }, [onUploadComplete, processDirectoryTask, prepareFileTask, uploadPreparedFiles]);
const totalFileBytes = useMemo( const totalFileBytes = useMemo(
() => files.reduce((acc, f) => acc + (f.type === 'file' ? f.size : 0), 0), () => files.reduce((acc, f) => acc + (f.type === 'file' ? f.size : 0), 0),

View File

@@ -1,147 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { Alert, Button, Card, Empty, Input, Space, Spin, Typography, message } from 'antd';
import { videoRoomApi, type VideoRoomState } from '../api/videoRoom';
const { Title, Text } = Typography;
export default function PublicWatchPage() {
const { token } = useParams();
const [data, setData] = useState<VideoRoomState | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const syncingRef = useRef(false);
const wsRef = useRef<WebSocket | null>(null);
const actorId = useMemo(() => {
const key = 'watch_actor_id';
const cached = localStorage.getItem(key);
if (cached) return cached;
const v = `guest:${Math.random().toString(36).slice(2, 10)}`;
localStorage.setItem(key, v);
return v;
}, []);
useEffect(() => {
if (!token) return;
const load = async () => {
try {
const res = await videoRoomApi.getState(token);
setData(res);
setErr('');
} catch (e: any) {
setErr(e.message || '加载视频间失败');
} finally {
setLoading(false);
}
};
void load();
}, [token]);
useEffect(() => {
if (!token) return;
let closedByCleanup = false;
let reconnectTimer: number | null = null;
const connect = () => {
const ws = videoRoomApi.connectWs(token, actorId);
wsRef.current = ws;
ws.onopen = () => setWsConnected(true);
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === 'snapshot' || msg.type === 'playback') {
setData((prev) => {
if (!prev) return prev;
return { ...prev, playback: msg.playback };
});
}
} catch {
void 0;
}
};
ws.onclose = () => {
setWsConnected(false);
if (!closedByCleanup) {
reconnectTimer = window.setTimeout(connect, 1500);
}
};
ws.onerror = () => {
setWsConnected(false);
};
};
connect();
return () => {
closedByCleanup = true;
setWsConnected(false);
if (reconnectTimer) window.clearTimeout(reconnectTimer);
wsRef.current?.close();
wsRef.current = null;
};
}, [token, actorId]);
useEffect(() => {
const video = videoRef.current;
const pb = data?.playback;
if (!video || !pb) return;
syncingRef.current = true;
const targetSec = (pb.position_ms || 0) / 1000;
if (Math.abs(video.currentTime - targetSec) > 1.2) video.currentTime = targetSec;
if (Math.abs(video.playbackRate - pb.playback_rate) > 0.01) video.playbackRate = pb.playback_rate;
if (pb.is_paused && !video.paused) video.pause();
if (!pb.is_paused && video.paused) void video.play().catch(() => void 0);
setTimeout(() => { syncingRef.current = false; }, 120);
}, [data?.playback?.updated_at]);
const sendEvent = (payload: { event: 'play' | 'pause' | 'seek' | 'rate'; position_ms?: number; playback_rate?: number }) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(payload));
};
if (loading) return <div style={{ padding: 40, textAlign: 'center' }}><Spin /></div>;
if (err || !data) return <div style={{ padding: 40 }}><Empty description={err || '房间不存在'} /></div>;
return (
<div style={{ maxWidth: 980, margin: '24px auto', padding: '0 16px' }}>
<Card>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Title level={4} style={{ margin: 0 }}>{data.room.name}</Title>
<Text type="secondary">{data.playback.is_paused ? '暂停' : '播放中'} | {data.playback.playback_rate}x</Text>
<Text type={wsConnected ? 'success' : 'warning'}>{wsConnected ? '实时同步已连接' : '实时同步断开,正在重连…'}</Text>
<Input readOnly value={`${window.location.origin}/watch/${data.room.token}`} addonBefore="分享链接" />
</Space>
</Card>
<Card style={{ marginTop: 16 }}>
<video
ref={videoRef}
src={videoRoomApi.streamUrl(data.room.token, data.room.path)}
style={{ width: '100%', background: '#000', borderRadius: 8 }}
controls
onPlay={() => { if (!syncingRef.current) sendEvent({ event: 'play' }); }}
onPause={() => { if (!syncingRef.current) sendEvent({ event: 'pause' }); }}
onSeeked={() => {
if (syncingRef.current) return;
const ms = Math.floor((videoRef.current?.currentTime || 0) * 1000);
sendEvent({ event: 'seek', position_ms: ms });
}}
onRateChange={() => {
if (syncingRef.current) return;
const rate = videoRef.current?.playbackRate || 1;
sendEvent({ event: 'rate', playback_rate: rate });
}}
/>
<Alert type="info" showIcon style={{ marginTop: 12 }} message="已改为 WebSocket 实时同步,不再使用定时轮询。" />
<Space style={{ marginTop: 12 }}>
<Button onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/watch/${data.room.token}`); message.success('已复制'); }}></Button>
</Space>
</Card>
</div>
);
}

View File

@@ -48,6 +48,7 @@ import type {
AIModelPayload, AIModelPayload,
AIProvider, AIProvider,
AIProviderPayload, AIProviderPayload,
OpenAIProtocol,
} from '../../../api/aiProviders'; } from '../../../api/aiProviders';
import { import {
createModel, createModel,
@@ -90,6 +91,11 @@ interface ProviderTemplate {
} }
const abilityOrder: AIAbility[] = ['chat', 'vision', 'embedding', 'rerank', 'voice', 'tools']; const abilityOrder: AIAbility[] = ['chat', 'vision', 'embedding', 'rerank', 'voice', 'tools'];
const defaultOpenAIProtocol: OpenAIProtocol = 'chat_completions';
function normalizeOpenAIProtocol(value: unknown): OpenAIProtocol {
return value === 'responses' ? 'responses' : defaultOpenAIProtocol;
}
const abilityInfo: Record<AIAbility, { icon: ReactNode; label: string; color: string; description: string }> = { const abilityInfo: Record<AIAbility, { icon: ReactNode; label: string; color: string; description: string }> = {
chat: { chat: {
@@ -242,6 +248,7 @@ type AIProviderFormValues = {
name?: string; name?: string;
identifier?: string; identifier?: string;
api_format: AIProviderPayload['api_format']; api_format: AIProviderPayload['api_format'];
openai_protocol?: OpenAIProtocol;
base_url?: string; base_url?: string;
api_key?: string; api_key?: string;
logo_url?: string; logo_url?: string;
@@ -275,12 +282,19 @@ export default function AiSettingsTab() {
const [addingRemoteModels, setAddingRemoteModels] = useState<boolean>(false); const [addingRemoteModels, setAddingRemoteModels] = useState<boolean>(false);
const [modelModalTab, setModelModalTab] = useState<'remote' | 'manual'>('remote'); const [modelModalTab, setModelModalTab] = useState<'remote' | 'manual'>('remote');
const [remoteSearchKeyword, setRemoteSearchKeyword] = useState<string>(''); const [remoteSearchKeyword, setRemoteSearchKeyword] = useState<string>('');
const providerApiFormat = Form.useWatch('api_format', providerForm);
const capabilitiesValue = Form.useWatch('capabilities', modelForm); const capabilitiesValue = Form.useWatch('capabilities', modelForm);
const showEmbeddingDimensions = useMemo(() => { const showEmbeddingDimensions = useMemo(() => {
const capabilities = Array.isArray(capabilitiesValue) ? capabilitiesValue : []; const capabilities = Array.isArray(capabilitiesValue) ? capabilitiesValue : [];
return capabilities.includes('embedding') || capabilities.includes('rerank'); return capabilities.includes('embedding') || capabilities.includes('rerank');
}, [capabilitiesValue]); }, [capabilitiesValue]);
useEffect(() => {
if (providerApiFormat === 'openai' && !providerForm.getFieldValue('openai_protocol')) {
providerForm.setFieldValue('openai_protocol', defaultOpenAIProtocol);
}
}, [providerApiFormat, providerForm]);
useEffect(() => { useEffect(() => {
if (!showEmbeddingDimensions) { if (!showEmbeddingDimensions) {
modelForm.setFieldsValue({ embedding_dimensions: null }); modelForm.setFieldsValue({ embedding_dimensions: null });
@@ -338,6 +352,7 @@ export default function AiSettingsTab() {
name: existing.name, name: existing.name,
identifier: existing.identifier, identifier: existing.identifier,
api_format: existing.api_format, api_format: existing.api_format,
openai_protocol: normalizeOpenAIProtocol(existing.extra_config?.openai_protocol),
base_url: existing.base_url ?? undefined, base_url: existing.base_url ?? undefined,
api_key: '', api_key: '',
logo_url: existing.logo_url ?? undefined, logo_url: existing.logo_url ?? undefined,
@@ -345,7 +360,7 @@ export default function AiSettingsTab() {
}); });
} else { } else {
providerForm.resetFields(); providerForm.resetFields();
providerForm.setFieldsValue({ api_format: 'openai' }); providerForm.setFieldsValue({ api_format: 'openai', openai_protocol: defaultOpenAIProtocol });
setSelectedTemplate(null); setSelectedTemplate(null);
setProviderModal({ open: true, step: 1 }); setProviderModal({ open: true, step: 1 });
} }
@@ -364,6 +379,7 @@ export default function AiSettingsTab() {
name: t(template.nameKey), name: t(template.nameKey),
identifier: template.identifier, identifier: template.identifier,
api_format: template.api_format, api_format: template.api_format,
openai_protocol: template.api_format === 'openai' ? defaultOpenAIProtocol : undefined,
base_url: template.base_url ?? '', base_url: template.base_url ?? '',
api_key: '', api_key: '',
logo_url: template.logo_url ?? '', logo_url: template.logo_url ?? '',
@@ -375,7 +391,7 @@ export default function AiSettingsTab() {
setProviderModal((prev) => ({ ...prev, step: 1, editing: undefined })); setProviderModal((prev) => ({ ...prev, step: 1, editing: undefined }));
setSelectedTemplate(null); setSelectedTemplate(null);
providerForm.resetFields(); providerForm.resetFields();
providerForm.setFieldsValue({ api_format: 'openai' }); providerForm.setFieldsValue({ api_format: 'openai', openai_protocol: defaultOpenAIProtocol });
}; };
const handleSubmitProvider = async () => { const handleSubmitProvider = async () => {
@@ -384,6 +400,12 @@ export default function AiSettingsTab() {
const trimmedApiKey = values.api_key?.trim(); const trimmedApiKey = values.api_key?.trim();
const trimmedLogoUrl = values.logo_url?.trim(); const trimmedLogoUrl = values.logo_url?.trim();
const trimmedProviderType = values.provider_type?.trim(); const trimmedProviderType = values.provider_type?.trim();
const extraConfig = { ...(providerModal.editing?.extra_config ?? {}) };
if (values.api_format === 'openai') {
extraConfig.openai_protocol = normalizeOpenAIProtocol(values.openai_protocol);
} else {
delete extraConfig.openai_protocol;
}
const payload: AIProviderPayload = { const payload: AIProviderPayload = {
name: (values.name || '').trim(), name: (values.name || '').trim(),
identifier: (values.identifier || '').trim(), identifier: (values.identifier || '').trim(),
@@ -391,6 +413,7 @@ export default function AiSettingsTab() {
base_url: trimmedBaseUrl ? trimmedBaseUrl : null, base_url: trimmedBaseUrl ? trimmedBaseUrl : null,
logo_url: trimmedLogoUrl ? trimmedLogoUrl : null, logo_url: trimmedLogoUrl ? trimmedLogoUrl : null,
provider_type: trimmedProviderType ? trimmedProviderType : null, provider_type: trimmedProviderType ? trimmedProviderType : null,
extra_config: Object.keys(extraConfig).length ? extraConfig : null,
}; };
if (trimmedApiKey) { if (trimmedApiKey) {
payload.api_key = trimmedApiKey; payload.api_key = trimmedApiKey;
@@ -1117,16 +1140,30 @@ export default function AiSettingsTab() {
label={t('API Format')} label={t('API Format')}
rules={[{ required: true }]} rules={[{ required: true }]}
> >
<Select <Select
disabled={!allowFormatChange} disabled={!allowFormatChange}
options={[ options={[
{ value: 'openai', label: 'OpenAI Compatible' }, { value: 'openai', label: 'OpenAI Compatible' },
{ value: 'gemini', label: 'Gemini Compatible' }, { value: 'gemini', label: 'Gemini Compatible' },
{ value: 'anthropic', label: 'Anthropic Native' }, { value: 'anthropic', label: 'Anthropic Native' },
{ value: 'ollama', label: 'Ollama Native' }, { value: 'ollama', label: 'Ollama Native' },
]} ]}
/> />
</Form.Item> </Form.Item>
{providerApiFormat === 'openai' ? (
<Form.Item
name="openai_protocol"
label={t('OpenAI Protocol')}
rules={[{ required: true }]}
>
<Select
options={[
{ value: 'chat_completions', label: 'Chat Completions' },
{ value: 'responses', label: 'Responses' },
]}
/>
</Form.Item>
) : null}
<Form.Item name="base_url" label={t('Base URL')} rules={[{ required: true, message: t('Enter base url') }]}> <Form.Item name="base_url" label={t('Base URL')} rules={[{ required: true, message: t('Enter base url') }]}>
<Input placeholder="https://" /> <Input placeholder="https://" />
</Form.Item> </Form.Item>

View File

@@ -0,0 +1,124 @@
.video-room-page {
min-height: 100vh;
padding: 24px;
background: #0b0f12;
}
.video-room-page--center {
display: flex;
align-items: center;
justify-content: center;
}
.video-room-page--center .ant-empty-description {
color: rgba(255, 255, 255, 0.72);
}
.video-room-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
max-width: 1720px;
margin: 0 auto 16px;
}
.video-room-header__left {
min-width: 0;
}
.video-room-header__right {
display: flex;
flex: none;
align-items: center;
gap: 10px;
}
.video-room-title-block {
min-width: 0;
}
.video-room-title.ant-typography {
max-width: min(980px, 70vw);
margin: 0 0 4px;
overflow: hidden;
color: rgba(255, 255, 255, 0.92);
text-overflow: ellipsis;
white-space: nowrap;
}
.video-room-file-name {
max-width: min(720px, 58vw);
}
.video-room-status-tag.ant-tag {
display: inline-flex;
align-items: center;
height: 32px;
margin: 0;
padding: 0 12px;
border-radius: 8px;
}
.video-room-shell {
max-width: 1720px;
margin: 0 auto;
}
.video-room-main {
min-width: 0;
}
.video-room-stage {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: #000;
}
.video-room-player {
width: 100%;
height: min(72vh, 760px);
min-height: 480px;
background: #000;
}
.video-room-note {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px;
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.video-room-note .anticon {
color: var(--ant-color-primary, #1677ff);
}
@media (max-width: 760px) {
.video-room-page {
padding: 14px;
}
.video-room-header {
align-items: stretch;
flex-direction: column;
}
.video-room-header__right {
display: grid;
grid-template-columns: 1fr 1fr;
}
.video-room-title.ant-typography,
.video-room-file-name {
max-width: 100%;
}
.video-room-player {
height: 56vh;
min-height: 280px;
}
}

View File

@@ -0,0 +1,261 @@
import { memo, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { Button, Empty, Space, Spin, Tag, Tooltip, Typography, message } from 'antd';
import {
CheckCircleFilled,
DisconnectOutlined,
LinkOutlined,
} from '@ant-design/icons';
import Artplayer from 'artplayer';
import { videoRoomsApi, type VideoRoomInfo, type VideoRoomState } from '../api/videoRooms';
import { useI18n } from '../i18n';
import './VideoRoomPage.css';
const { Text, Title } = Typography;
const SYNC_THRESHOLD = 1.2;
function getSyncedTime(state: VideoRoomState) {
const baseTime = Math.max(0, Number(state.current_time) || 0);
if (state.paused || !state.updated_at) return baseTime;
const updatedAt = Date.parse(state.updated_at);
if (Number.isNaN(updatedAt)) return baseTime;
return baseTime + Math.max(0, (Date.now() - updatedAt) / 1000);
}
function formatTime(seconds: number) {
const safeSeconds = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const secs = safeSeconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
}
function getFileName(path: string) {
return path.split('/').filter(Boolean).pop() || path;
}
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 liveStateRef = useRef<VideoRoomState | null>(null);
const [room, setRoom] = useState<VideoRoomInfo | null>(null);
const [liveState, setLiveState] = useState<VideoRoomState | 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);
setLiveState(data.state);
liveStateRef.current = data.state;
})
.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 updateLocalState = () => {
const art = artInstance.current;
if (!art || applyingRemoteRef.current) return null;
const video = art.video;
const state: VideoRoomState = {
current_time: video.currentTime || 0,
paused: video.paused,
updated_at: new Date().toISOString(),
};
liveStateRef.current = state;
setLiveState(state);
return state;
};
const sendState = () => {
const ws = wsRef.current;
const state = updateLocalState();
if (!state || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload = {
type: 'state',
current_time: state.current_time,
paused: state.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;
liveStateRef.current = state;
setLiveState(state);
if (!art) return;
const video = art.video;
const targetTime = getSyncedTime(state);
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 applyLatestState = () => {
applyState(liveStateRef.current || room.state);
};
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', applyLatestState);
art.video.addEventListener('loadedmetadata', applyLatestState);
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;
}
art.video.removeEventListener('loadedmetadata', applyLatestState);
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 className="video-room-page video-room-page--center">
<Spin size="large" />
</div>
);
}
if (error || !room) {
return (
<div className="video-room-page video-room-page--center">
<Empty description={error || t('Video room not found')} />
</div>
);
}
const fileName = getFileName(room.path);
const state = liveState || room.state;
return (
<div className="video-room-page">
<header className="video-room-header">
<div className="video-room-header__left">
<div className="video-room-title-block">
<Title level={3} className="video-room-title">{room.name}</Title>
<Space size={8} wrap>
<Text type="secondary" ellipsis className="video-room-file-name">{fileName}</Text>
<Text type="secondary">{formatTime(state.current_time)}</Text>
</Space>
</div>
</div>
<div className="video-room-header__right">
<Tag
className="video-room-status-tag"
color={connected ? 'success' : 'error'}
icon={connected ? <CheckCircleFilled /> : <DisconnectOutlined />}
>
{connected ? t('Room synced') : t('Room disconnected')}
</Tag>
<Tooltip title={t('Copy Link')}>
<Button icon={<LinkOutlined />} onClick={handleCopy}>
{t('Share room')}
</Button>
</Tooltip>
</div>
</header>
<main className="video-room-shell">
<section className="video-room-main">
<div className="video-room-stage">
<div ref={artRef} className="video-room-player" />
</div>
<div className="video-room-note">
<CheckCircleFilled />
<span>{t('Playback state is shared in this room')}</span>
</div>
</section>
</main>
</div>
);
});
export default VideoRoomPage;

View File

@@ -5,9 +5,9 @@ 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 PublicWatchPage from '../pages/PublicWatchPage';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import type { JSX } from 'react'; import type { JSX } from 'react';
@@ -17,7 +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: '/watch/:token', element: <PublicWatchPage /> }, { 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 /> },
@@ -28,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/') && !location.pathname.startsWith('/watch/') && !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;