完善跨平台回放与实时状态更新
This commit is contained in:
1 parent
a493ef2309
commit
3060828774
11 files changed
+244
-37
No files matched your search
@@ -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
@@ -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] = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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."
|
||||
Reference in new issue
Block a user