完善跨平台回放与实时状态更新

This commit is contained in:
admin committed 2026-08-17 13:16:12 +08:00
1 parent a493ef2309
commit 3060828774
11 files changed
+244 -37

No files matched your search

+20
View File
@@ -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.
+8 -2
View File
@@ -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] = [
+67 -6
View File
@@ -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)
+2
View File
@@ -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() {
<Route index element={<Dashboard />} />
<Route path="streams" element={<StreamList />} />
<Route path="streams/new" element={<StreamForm />} />
<Route path="streams/:id/edit" element={<ReplayEditor />} />
<Route path="nodes" element={<NodeManager />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
+116
View File
@@ -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<HTMLVideoElement>(null)
const [stream, setStream] = useState<StreamInfo | null>(null)
const [mediaFiles, setMediaFiles] = useState<string[]>([])
const [replayFile, setReplayFile] = useState<string>()
const [chapters, setChapters] = useState<Chapter[]>([])
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 <div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
if (!stream) return <Alert type="error" message="节目不存在" />
return (
<div style={{ maxWidth: 980, margin: '0 auto', padding: '8px 0 48px' }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')} style={{ marginBottom: 20 }}>返回节目列表</Button>
<Title level={2} style={{ marginBottom: 4 }}>回放与章节</Title>
<Text type="secondary">{stream.title}</Text>
{stream.status !== 'ended' ? (
<Alert style={{ marginTop: 24 }} type="warning" showIcon message="节目结束后才能配置回放和章节" />
) : (
<Space direction="vertical" size={24} style={{ width: '100%', marginTop: 24 }}>
<Select
showSearch
value={replayFile}
placeholder="选择媒体目录中的回放文件"
options={mediaFiles.map(file => ({ value: file, label: file }))}
onChange={setReplayFile}
style={{ width: '100%' }}
/>
<video
ref={videoRef}
controls
src={replayFile ? `/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}` : undefined}
style={{ width: '100%', maxHeight: 520, borderRadius: 12, background: '#111' }}
/>
<Button icon={<PlusOutlined />} onClick={addChapter} disabled={!replayFile}>按当前播放时间添加章节</Button>
<Space direction="vertical" style={{ width: '100%' }}>
{chapters.map((chapter, index) => (
<Space key={index} wrap style={{ width: '100%' }}>
<Input
value={chapter.title}
placeholder="章节标题"
onChange={event => setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))}
style={{ width: 320, maxWidth: '100%' }}
/>
<InputNumber
min={0}
step={0.1}
value={chapter.time}
addonAfter="秒"
onChange={value => setChapters(current => current.map((item, i) => i === index ? { ...item, time: Number(value || 0) } : item))}
/>
<Button icon={<ArrowUpOutlined />} disabled={index === 0} onClick={() => move(index, -1)} />
<Button icon={<ArrowDownOutlined />} disabled={index === chapters.length - 1} onClick={() => move(index, 1)} />
<Button danger icon={<DeleteOutlined />} onClick={() => setChapters(current => current.filter((_, i) => i !== index))} />
</Space>
))}
</Space>
<Button type="primary" loading={saving} onClick={save}>保存回放与章节</Button>
</Space>
)}
</div>
)
}
+1 -24
View File
@@ -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<string[]>([])
const [chapters, setChapters] = useState<Chapter[]>([])
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() {
<Form.Item name="scheduled_at" label="预定开播时间">
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
</Form.Item>
<Form.Item name="replay_file" label="服务器回放文件" extra="只能选择已配置媒体目录中的文件">
<Select allowClear placeholder="不使用回放" options={mediaFiles.map(file => ({ value: file, label: file }))} />
</Form.Item>
<Form.Item label="回放章节与时间点">
<Space direction="vertical" style={{ width: '100%' }}>
{chapters.map((chapter, index) => (
<Space key={index} style={{ width: '100%' }}>
<Input value={chapter.title} placeholder="章节名称" onChange={event => setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, title: event.target.value } : item))} />
<Input type="number" min={0} value={chapter.time} addonAfter="秒" onChange={event => setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, time: Number(event.target.value) } : item))} />
<Button danger onClick={() => setChapters(current => current.filter((_, itemIndex) => itemIndex !== index))}>删除</Button>
</Space>
))}
<Button onClick={() => setChapters(current => [...current, { title: '', time: 0 }])}>添加章节</Button>
</Space>
</Form.Item>
<Form.Item name="transcode_profile" label="转码配置">
<Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} />
</Form.Item>
+2 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, Button, Tag, Space, Typography, message, Tooltip, Popconfirm } from 'antd'
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined, EyeOutlined } from '@ant-design/icons'
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined, EyeOutlined, EditOutlined } from '@ant-design/icons'
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '../../api/streams'
const { Title, Text } = Typography
@@ -57,6 +57,7 @@ export default function StreamList() {
<Tooltip title="结束"><Button size="small" danger icon={<StopOutlined />} onClick={() => handleEnd(r.id)} /></Tooltip>
)}
<Tooltip title="观看"><Button size="small" icon={<EyeOutlined />} onClick={() => window.open(`/watch/${r.id}`, '_blank')} /></Tooltip>
<Tooltip title="编辑"><Button size="small" icon={<EditOutlined />} onClick={() => navigate(`/admin/streams/${r.id}/edit`)} /></Tooltip>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Tooltip title="删除"><Button size="small" danger icon={<DeleteOutlined />} /></Tooltip>
</Popconfirm>
+4
View File
@@ -116,3 +116,7 @@ export async function getWatchUrls(streamId: number): Promise<WatchInfo> {
const { data } = await api.get(`/streams/${streamId}/watch`)
return data
}
export function getStreamStatusUrl(streamId: number): string {
return `/api/streams/${streamId}/events`
}
+8 -3
View File
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { Select, Button, Typography, Tag, Spin, Empty, message } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
import { getStreamStatusUrl, getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
import './viewer.css'
@@ -24,8 +24,13 @@ export default function WatchPage() {
useEffect(() => {
if (!id) return
loadInfo()
const timer = setInterval(loadInfo, 15000)
return () => clearInterval(timer)
const events = new EventSource(getStreamStatusUrl(Number(id)))
events.addEventListener('status', loadInfo)
events.addEventListener('deleted', () => setInfo(null))
events.onerror = () => {
events.close()
}
return () => events.close()
}, [id])
async function loadInfo() {
+15
View File
@@ -0,0 +1,15 @@
$ErrorActionPreference = "Stop"
$Base = Split-Path -Parent $MyInvocation.MyCommand.Path
Write-Host "[StreamCore] Starting backend..."
$Python = Join-Path $Base "backend\.venv\Scripts\python.exe"
if (-not (Test-Path $Python)) {
$Python = Join-Path $Base "backend\venv\Scripts\python.exe"
}
if (-not (Test-Path $Python)) {
throw "Python virtual environment not found. Create backend\.venv and install backend\requirements.txt."
}
Start-Process -FilePath $Python -ArgumentList "run.py" -WorkingDirectory (Join-Path $Base "backend")
Write-Host "[StreamCore] Backend started on http://127.0.0.1:8080"
Write-Host "Run npm install and npm run dev in frontend for the development UI."
+1 -1
View File
@@ -2,7 +2,7 @@
# StreamCore - Start all services
set -e
BASE="/workspace/livestream-platform"
BASE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
echo "[StreamCore] Starting services..."