diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..07892fe --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,20 @@ +# Deployment + +All runtime paths are resolved relative to the repository by default and can be overridden with environment variables. + +- `LS_DATABASE_URL`: SQLAlchemy database URL. +- `LS_REPLAY_MEDIA_DIR`: server-local directory containing replay files. + +## Linux and macOS + +Run `./start.sh`. The script resolves its own directory and does not depend on `/workspace`. + +## Windows + +1. Create a virtual environment with `py -m venv backend\.venv`. +2. Install dependencies with `backend\.venv\Scripts\pip install -r backend\requirements.txt`. +3. Optionally set `LS_REPLAY_MEDIA_DIR` to an absolute media directory. +4. Run `powershell -ExecutionPolicy Bypass -File .\start.ps1`. +5. In another terminal, run `npm install` and `npm run dev` from `frontend`. + +Replay records store paths relative to `LS_REPLAY_MEDIA_DIR`. Absolute paths and parent-directory traversal are rejected. diff --git a/backend/config.py b/backend/config.py index 27af34f..0464bd5 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,16 +1,22 @@ from pydantic_settings import BaseSettings +from pathlib import Path from typing import Literal +APP_DIR = Path(__file__).resolve().parent +DEFAULT_DATA_DIR = APP_DIR / "data" +DEFAULT_DATA_DIR.mkdir(parents=True, exist_ok=True) + + class Settings(BaseSettings): - database_url: str = "sqlite+aiosqlite:///./data.db" + database_url: str = f"sqlite+aiosqlite:///{(DEFAULT_DATA_DIR / 'streamcore.db').as_posix()}" admin_password: str = "admin123" media_origin_host: str = "127.0.0.1" media_origin_rtmp_port: int = 1935 media_origin_api_port: int = 9997 media_origin_hls_port: int = 8888 - replay_media_dir: str = "/workspace/media" + replay_media_dir: str = str(APP_DIR.parent / "media") hls_segment_seconds: int = 4 edge_nodes: list[dict] = [ diff --git a/backend/routes/streams.py b/backend/routes/streams.py index 3e16d51..7107406 100644 --- a/backend/routes/streams.py +++ b/backend/routes/streams.py @@ -1,10 +1,11 @@ import json import secrets +import asyncio import time from pathlib import Path from fastapi import APIRouter, HTTPException, Depends, Request -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, Field from typing import Optional @@ -60,12 +61,40 @@ def _media_path(filename: str | None) -> Path | None: if not filename: return None root = Path(settings.replay_media_dir).resolve() - candidate = (root / filename).resolve() - if root not in candidate.parents or not candidate.is_file(): + supplied = Path(filename) + if supplied.is_absolute() or supplied.drive or ".." in supplied.parts: + raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中") + candidate = (root / supplied).resolve() + if candidate != root and root not in candidate.parents: + raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中") + if not candidate.is_file(): raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中") return candidate +def _status_payload(stream) -> dict: + return { + "stream_id": stream.id, + "status": stream.status.value, + "replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None, + "chapters": json.loads(stream.chapters_json or "[]"), + } + + +def _normalize_chapters(chapters: list[dict]) -> list: + normalized = [] + for chapter in chapters: + title = str(chapter.get("title", "")).strip() + try: + chapter_time = float(chapter.get("time", 0)) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=422, detail="章节时间必须是非负数字") from exc + if not title or chapter_time < 0: + raise HTTPException(status_code=422, detail="章节标题不能为空,时间必须是非负数字") + normalized.append({"title": title, "time": chapter_time}) + return sorted(normalized, key=lambda chapter: chapter["time"]) + + def _serialize(s, include_private: bool = True): data = { "id": s.id, "title": s.title, "description": s.description, @@ -167,12 +196,17 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D if not existing: raise HTTPException(status_code=404, detail="Stream not found") updates = {k: v for k, v in body.model_dump().items() if v is not None} + if ("replay_file" in updates or "chapters" in updates) and existing.status.value != "ended": + raise HTTPException(status_code=409, detail="仅已结束的节目可以配置回放和章节") if "replay_file" in updates: _media_path(updates["replay_file"]) if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles: raise HTTPException(status_code=422, detail="转码配置不存在") if "chapters" in updates: - updates["chapters_json"] = json.dumps(updates.pop("chapters"), ensure_ascii=False) + if not updates.get("replay_file") and not existing.replay_file: + raise HTTPException(status_code=409, detail="请先选择回放文件再编辑章节") + chapters = _normalize_chapters(updates.pop("chapters")) + updates["chapters_json"] = json.dumps(chapters, ensure_ascii=False) if updates: await svc.update_stream(stream_id, **updates) return {"ok": True} @@ -213,12 +247,39 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De } +@router.get("/{stream_id}/events") +async def stream_events(stream_id: int, request: Request, db: AsyncSession = Depends(get_db)): + if not await StreamService(db).get_stream(stream_id): + raise HTTPException(status_code=404, detail="Stream not found") + + async def events(): + previous = None + while not await request.is_disconnected(): + stream = await StreamService(db).get_stream(stream_id) + if not stream: + yield "event: deleted\ndata: {}\n\n" + return + payload = json.dumps(_status_payload(stream), ensure_ascii=False) + if payload != previous: + yield f"event: status\ndata: {payload}\n\n" + previous = payload + else: + yield ": keep-alive\n\n" + await asyncio.sleep(2) + + return StreamingResponse(events(), media_type="text/event-stream", headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }) + + @router.get("/{stream_id}/replay") -async def serve_replay(stream_id: int, db: AsyncSession = Depends(get_db)): +async def serve_replay(stream_id: int, preview: str | None = None, + db: AsyncSession = Depends(get_db)): stream = await StreamService(db).get_stream(stream_id) if not stream: raise HTTPException(status_code=404, detail="Program not found") - path = _media_path(stream.replay_file) + path = _media_path(preview if preview is not None else stream.replay_file) if not path: raise HTTPException(status_code=404, detail="Replay not available") return FileResponse(path) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 09ca973..51fb299 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ const LoginPage = lazy(() => import('./admin/LoginPage')) const Dashboard = lazy(() => import('./admin/pages/Dashboard')) const StreamList = lazy(() => import('./admin/pages/StreamList')) const StreamForm = lazy(() => import('./admin/pages/StreamForm')) +const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor')) const NodeManager = lazy(() => import('./admin/pages/NodeManager')) const SettingsPage = lazy(() => import('./admin/pages/Settings')) @@ -48,6 +49,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/admin/pages/ReplayEditor.tsx b/frontend/src/admin/pages/ReplayEditor.tsx new file mode 100644 index 0000000..521877d --- /dev/null +++ b/frontend/src/admin/pages/ReplayEditor.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Alert, Button, Input, InputNumber, Select, Space, Spin, Typography, message } from 'antd' +import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons' +import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams' + +const { Title, Text } = Typography + +export default function ReplayEditor() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const videoRef = useRef(null) + const [stream, setStream] = useState(null) + const [mediaFiles, setMediaFiles] = useState([]) + const [replayFile, setReplayFile] = useState() + const [chapters, setChapters] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + const streamId = Number(id) + if (!Number.isInteger(streamId)) return + Promise.all([getStream(streamId), fetchMediaFiles()]) + .then(([program, files]) => { + setStream(program) + setReplayFile(program.replay_file) + setChapters(program.chapters || []) + setMediaFiles(files) + }) + .catch(() => message.error('无法载入节目回放配置')) + .finally(() => setLoading(false)) + }, [id]) + + const move = (index: number, offset: number) => { + setChapters(current => { + const target = index + offset + if (target < 0 || target >= current.length) return current + const next = [...current] + ;[next[index], next[target]] = [next[target], next[index]] + return next + }) + } + + const addChapter = () => { + const time = Math.max(0, Math.round((videoRef.current?.currentTime || 0) * 10) / 10) + setChapters(current => [...current, { title: `章节 ${current.length + 1}`, time }]) + } + + const save = async () => { + if (!stream || !replayFile) return message.warning('请先选择回放文件') + setSaving(true) + try { + await updateStream(stream.id, { replay_file: replayFile, chapters }) + message.success('回放和章节已保存') + } catch { + message.error('保存失败,请检查章节标题、时间和媒体文件') + } finally { + setSaving(false) + } + } + + if (loading) return
+ if (!stream) return + + return ( +
+ + 回放与章节 + {stream.title} + {stream.status !== 'ended' ? ( + + ) : ( + + setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))} + style={{ width: 320, maxWidth: '100%' }} + /> + setChapters(current => current.map((item, i) => i === index ? { ...item, time: Number(value || 0) } : item))} + /> + + + )} +
+ ) +} diff --git a/frontend/src/admin/pages/StreamForm.tsx b/frontend/src/admin/pages/StreamForm.tsx index c56d8d1..d91a795 100644 --- a/frontend/src/admin/pages/StreamForm.tsx +++ b/frontend/src/admin/pages/StreamForm.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Form, Input, Button, DatePicker, Typography, message, Space, Alert, Select, Switch } from 'antd' import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons' -import { createStream, fetchMediaFiles, type Chapter } from '../../api/streams' +import { createStream } from '../../api/streams' import axios from 'axios' const { Title, Text } = Typography @@ -10,15 +10,9 @@ const { TextArea } = Input export default function StreamForm() { const [loading, setLoading] = useState(false) - const [mediaFiles, setMediaFiles] = useState([]) - const [chapters, setChapters] = useState([]) const [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null) const navigate = useNavigate() - useEffect(() => { - fetchMediaFiles().then(setMediaFiles).catch(() => message.warning('无法读取服务器回放目录')) - }, []) - const handleSubmit = async (values: any) => { setLoading(true) try { @@ -27,8 +21,6 @@ export default function StreamForm() { description: values.description, cover_url: values.cover_url, scheduled_at: values.scheduled_at?.toISOString(), - replay_file: values.replay_file, - chapters: chapters.filter(chapter => chapter.title.trim() && chapter.time >= 0), transcode_profile: values.transcode_profile, homepage_visible: values.homepage_visible, remote_control_enabled: values.remote_control_enabled, @@ -105,21 +97,6 @@ export default function StreamForm() { - - setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, title: event.target.value } : item))} /> - setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, time: Number(event.target.value) } : item))} /> - - - ))} - - -