完善节目管理与回放播放体验

This commit is contained in:
admin committed 2026-08-17 09:59:04 +08:00
1 parent b265518084
commit a493ef2309
14 files changed
+444 -94

No files matched your search

+11
View File
@@ -10,6 +10,8 @@ class Settings(BaseSettings):
media_origin_rtmp_port: int = 1935 media_origin_rtmp_port: int = 1935
media_origin_api_port: int = 9997 media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888 media_origin_hls_port: int = 8888
replay_media_dir: str = "/workspace/media"
hls_segment_seconds: int = 4
edge_nodes: list[dict] = [ edge_nodes: list[dict] = [
{ {
@@ -34,6 +36,15 @@ class Settings(BaseSettings):
{"label": "360p", "width": 640, "height": 360, "bitrate": "600k"}, {"label": "360p", "width": 640, "height": 360, "bitrate": "600k"},
] ]
transcode_profiles: dict[str, list[dict]] = {
"standard": qualities,
"bandwidth-saver": [
{"label": "720p", "width": 1280, "height": 720, "bitrate": "1800k"},
{"label": "480p", "width": 854, "height": 480, "bitrate": "800k"},
{"label": "360p", "width": 640, "height": 360, "bitrate": "450k"},
],
}
multicast_enabled: bool = True multicast_enabled: bool = True
multicast_address_base: str = "239.0.0.1" multicast_address_base: str = "239.0.0.1"
multicast_port_base: int = 5000 multicast_port_base: int = 5000
+14
View File
@@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import text
from config import settings from config import settings
@@ -19,3 +20,16 @@ async def get_db() -> AsyncSession:
async def init_db(): async def init_db():
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
if settings.database_url.startswith("sqlite"):
columns = {
"replay_file": "VARCHAR(500)",
"chapters_json": "TEXT",
"transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'",
"homepage_visible": "BOOLEAN NOT NULL DEFAULT 1",
"remote_control_enabled": "BOOLEAN NOT NULL DEFAULT 0",
}
rows = await conn.execute(text("PRAGMA table_info(streams)"))
existing = {row[1] for row in rows}
for name, definition in columns.items():
if name not in existing:
await conn.execute(text(f"ALTER TABLE streams ADD COLUMN {name} {definition}"))
+1 -1
View File
@@ -66,7 +66,7 @@ async def lifespan(app: FastAPI):
transcode_mgr.stop_all() transcode_mgr.stop_all()
app = FastAPI(title="StreamCore", lifespan=lifespan) app = FastAPI(title="ProgramCore", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+5
View File
@@ -20,6 +20,11 @@ class Stream(Base):
title: Mapped[str] = mapped_column(String(200), nullable=False) title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True) cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
replay_file: Mapped[str | None] = mapped_column(String(500), nullable=True)
chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True)
transcode_profile: Mapped[str] = mapped_column(String(64), default="standard")
homepage_visible: Mapped[bool] = mapped_column(Boolean, default=True)
remote_control_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
stream_key: Mapped[str] = mapped_column(String(64), unique=True, default=lambda: uuid.uuid4().hex) stream_key: Mapped[str] = mapped_column(String(64), unique=True, default=lambda: uuid.uuid4().hex)
status: Mapped[StreamStatus] = mapped_column(Enum(StreamStatus), default=StreamStatus.pending) status: Mapped[StreamStatus] = mapped_column(Enum(StreamStatus), default=StreamStatus.pending)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+143 -35
View File
@@ -1,6 +1,12 @@
from fastapi import APIRouter, HTTPException, Depends, Query, Request import json
import secrets
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import get_db from database import get_db
from routes.auth import require_auth from routes.auth import require_auth
@@ -17,6 +23,11 @@ class StreamCreate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
cover_url: Optional[str] = None cover_url: Optional[str] = None
scheduled_at: Optional[str] = None scheduled_at: Optional[str] = None
replay_file: Optional[str] = None
chapters: list[dict] = Field(default_factory=list)
transcode_profile: str = "standard"
homepage_visible: bool = True
remote_control_enabled: bool = False
class StreamUpdate(BaseModel): class StreamUpdate(BaseModel):
@@ -24,6 +35,55 @@ class StreamUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
cover_url: Optional[str] = None cover_url: Optional[str] = None
scheduled_at: Optional[str] = None scheduled_at: Optional[str] = None
replay_file: Optional[str] = None
chapters: Optional[list[dict]] = None
transcode_profile: Optional[str] = None
homepage_visible: Optional[bool] = None
remote_control_enabled: Optional[bool] = None
class RemoteSessionCreate(BaseModel):
expires_in: int = 300
class RemoteCommand(BaseModel):
token: str
action: str
value: float | None = None
_remote_sessions: dict[str, dict] = {}
_remote_commands: dict[int, list[dict]] = {}
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():
raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中")
return candidate
def _serialize(s, include_private: bool = True):
data = {
"id": s.id, "title": s.title, "description": s.description,
"cover_url": s.cover_url, "status": s.status.value,
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
"started_at": str(s.started_at) if s.started_at else None,
"ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled,
"replay_file": s.replay_file,
"chapters": json.loads(s.chapters_json or "[]"),
"transcode_profile": s.transcode_profile,
"homepage_visible": s.homepage_visible,
"remote_control_enabled": s.remote_control_enabled,
"created_at": str(s.created_at),
}
if include_private:
data["stream_key"] = s.stream_key
return data
def _get_client_ip(request: Request) -> str | None: def _get_client_ip(request: Request) -> str | None:
@@ -39,25 +99,18 @@ def _get_client_ip(request: Request) -> str | None:
@router.get("") @router.get("")
async def list_streams(db: AsyncSession = Depends(get_db)): async def list_streams(admin: bool = False, db: AsyncSession = Depends(get_db)):
svc = StreamService(db) svc = StreamService(db)
streams = await svc.get_all_streams() streams = await svc.get_all_streams() if admin else await svc.get_public_streams()
return [ return [_serialize(s, include_private=admin) for s in streams]
{
"id": s.id,
"title": s.title, @router.get("/media-files")
"description": s.description, async def list_media_files(_: bool = Depends(require_auth)):
"cover_url": s.cover_url, root = Path(settings.replay_media_dir)
"stream_key": s.stream_key, root.mkdir(parents=True, exist_ok=True)
"status": s.status.value, extensions = {".mp4", ".m4v", ".mov", ".webm", ".mkv"}
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None, return [p.name for p in root.iterdir() if p.is_file() and p.suffix.lower() in extensions]
"started_at": str(s.started_at) if s.started_at else None,
"ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled,
"created_at": str(s.created_at),
}
for s in streams
]
@router.post("") @router.post("")
@@ -65,7 +118,10 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)): _: bool = Depends(require_auth)):
title = body.title.strip() title = body.title.strip()
if not title: if not title:
raise HTTPException(status_code=422, detail="直播标题不能为空") raise HTTPException(status_code=422, detail="节目标题不能为空")
_media_path(body.replay_file)
if body.transcode_profile not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在")
svc = StreamService(db) svc = StreamService(db)
try: try:
stream = await svc.create_stream( stream = await svc.create_stream(
@@ -73,12 +129,17 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
description=body.description, description=body.description,
cover_url=body.cover_url, cover_url=body.cover_url,
scheduled_at=body.scheduled_at, scheduled_at=body.scheduled_at,
replay_file=body.replay_file,
chapters=body.chapters,
transcode_profile=body.transcode_profile,
homepage_visible=body.homepage_visible,
remote_control_enabled=body.remote_control_enabled,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
await db.rollback() await db.rollback()
raise HTTPException(status_code=500, detail="创建直播失败,请检查日期格式和数据库状态") from exc raise HTTPException(status_code=500, detail="创建节目失败,请检查日期格式和数据库状态") from exc
rtmp_url = media_service.get_rtmp_url(stream.stream_key) rtmp_url = media_service.get_rtmp_url(stream.stream_key)
return { return {
"id": stream.id, "id": stream.id,
@@ -95,19 +156,7 @@ async def get_stream(stream_id: int, db: AsyncSession = Depends(get_db)):
s = await svc.get_stream(stream_id) s = await svc.get_stream(stream_id)
if not s: if not s:
raise HTTPException(status_code=404, detail="Stream not found") raise HTTPException(status_code=404, detail="Stream not found")
return { return _serialize(s)
"id": s.id,
"title": s.title,
"description": s.description,
"cover_url": s.cover_url,
"stream_key": s.stream_key,
"status": s.status.value,
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
"started_at": str(s.started_at) if s.started_at else None,
"ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled,
"created_at": str(s.created_at),
}
@router.put("/{stream_id}") @router.put("/{stream_id}")
@@ -118,6 +167,12 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Stream not found") 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} updates = {k: v for k, v in body.model_dump().items() if v is not None}
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 updates: if updates:
await svc.update_stream(stream_id, **updates) await svc.update_stream(stream_id, **updates)
return {"ok": True} return {"ok": True}
@@ -150,9 +205,62 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De
"selected_node": urls.get("selected_node"), "selected_node": urls.get("selected_node"),
"lines": urls["lines"], "lines": urls["lines"],
"qualities": urls["qualities"], "qualities": urls["qualities"],
"description": stream.description,
"scheduled_at": str(stream.scheduled_at) if stream.scheduled_at else None,
"replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"chapters": json.loads(stream.chapters_json or "[]"),
"remote_control_enabled": stream.remote_control_enabled,
} }
@router.get("/{stream_id}/replay")
async def serve_replay(stream_id: int, 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)
if not path:
raise HTTPException(status_code=404, detail="Replay not available")
return FileResponse(path)
@router.post("/{stream_id}/remote-session")
async def create_remote_session(stream_id: int, body: RemoteSessionCreate,
db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
stream = await StreamService(db).get_stream(stream_id)
if not stream or not stream.remote_control_enabled:
raise HTTPException(status_code=409, detail="节目未启用远程控制")
token = secrets.token_urlsafe(24)
_remote_sessions[token] = {
"stream_id": stream_id,
"expires_at": time.time() + min(max(body.expires_in, 30), 3600),
}
return {"token": token, "expires_in": min(max(body.expires_in, 30), 3600)}
@router.post("/{stream_id}/remote-command")
async def send_remote_command(stream_id: int, body: RemoteCommand,
_: bool = Depends(require_auth)):
session = _remote_sessions.get(body.token)
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
if body.action not in {"play", "pause", "seek"}:
raise HTTPException(status_code=422, detail="不支持的远程命令")
command = {"action": body.action, "value": body.value, "created_at": time.time()}
_remote_commands.setdefault(stream_id, []).append(command)
return {"ok": True, "command": command}
@router.get("/{stream_id}/remote-command")
async def poll_remote_command(stream_id: int, token: str):
session = _remote_sessions.get(token)
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
commands = _remote_commands.get(stream_id, [])
return commands[-1] if commands else None
@router.post("/{stream_id}/go-live") @router.post("/{stream_id}/go-live")
async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db), async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)): _: bool = Depends(require_auth)):
+26 -4
View File
@@ -16,7 +16,12 @@ class StreamService:
async def create_stream(self, title: str, description: str | None = None, async def create_stream(self, title: str, description: str | None = None,
cover_url: str | None = None, cover_url: str | None = None,
scheduled_at: str | None = None) -> Stream: scheduled_at: str | None = None,
replay_file: str | None = None,
chapters: list[dict] | None = None,
transcode_profile: str = "standard",
homepage_visible: bool = True,
remote_control_enabled: bool = False) -> Stream:
import uuid import uuid
scheduled_datetime = None scheduled_datetime = None
if scheduled_at: if scheduled_at:
@@ -28,6 +33,11 @@ class StreamService:
title=title, title=title,
description=description, description=description,
cover_url=cover_url, cover_url=cover_url,
replay_file=replay_file,
chapters_json=json.dumps(chapters or [], ensure_ascii=False),
transcode_profile=transcode_profile,
homepage_visible=homepage_visible,
remote_control_enabled=remote_control_enabled,
stream_key=uuid.uuid4().hex, stream_key=uuid.uuid4().hex,
status=StreamStatus.pending, status=StreamStatus.pending,
scheduled_at=scheduled_datetime, scheduled_at=scheduled_datetime,
@@ -43,6 +53,14 @@ class StreamService:
) )
return result.scalars().all() return result.scalars().all()
async def get_public_streams(self):
result = await self.db.execute(
select(Stream)
.where(Stream.homepage_visible.is_(True))
.order_by(Stream.created_at.desc())
)
return result.scalars().all()
async def get_stream(self, stream_id: int): async def get_stream(self, stream_id: int):
result = await self.db.execute( result = await self.db.execute(
select(Stream).where(Stream.id == stream_id) select(Stream).where(Stream.id == stream_id)
@@ -77,7 +95,7 @@ class StreamService:
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
) )
rtmp_url = media_service.get_rtmp_url(stream_key) rtmp_url = media_service.get_rtmp_url(stream_key)
transcode_mgr.start_transcode(stream_key, rtmp_url) transcode_mgr.start_transcode(stream_key, rtmp_url, stream.transcode_profile)
async def set_stream_ended(self, stream_key: str): async def set_stream_ended(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key) stream = await self.get_stream_by_key(stream_key)
@@ -91,6 +109,10 @@ class StreamService:
transcode_mgr.stop_transcode(stream_key) transcode_mgr.stop_transcode(stream_key)
async def get_watch_urls(self, stream_key: str, client_ip: str | None = None): async def get_watch_urls(self, stream_key: str, client_ip: str | None = None):
stream = await self.get_stream_by_key(stream_key)
if not stream:
return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None}
qualities = settings.transcode_profiles.get(stream.transcode_profile, settings.qualities)
best_node = lb.select_best_node(client_ip) best_node = lb.select_best_node(client_ip)
if not best_node: if not best_node:
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
@@ -106,7 +128,7 @@ class StreamService:
"url": f"{base_hls}/index.m3u8", "url": f"{base_hls}/index.m3u8",
"qualities": [], "qualities": [],
} }
for quality in settings.qualities: for quality in qualities:
label = quality["label"] label = quality["label"]
line_info["qualities"].append({ line_info["qualities"].append({
"label": label, "label": label,
@@ -118,7 +140,7 @@ class StreamService:
return { return {
"lines": lines, "lines": lines,
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in settings.qualities], "qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in qualities],
"multicast_available": multicast_detector.is_available(), "multicast_available": multicast_detector.is_available(),
"selected_node": { "selected_node": {
"id": best_node["id"], "id": best_node["id"],
+12 -5
View File
@@ -1,4 +1,3 @@
import asyncio
import subprocess import subprocess
import os import os
from config import settings from config import settings
@@ -7,12 +6,15 @@ from config import settings
class TranscodeManager: class TranscodeManager:
_processes: dict[str, list[subprocess.Popen]] = {} _processes: dict[str, list[subprocess.Popen]] = {}
def start_transcode(self, stream_key: str, input_url: str): def start_transcode(self, stream_key: str, input_url: str, profile: str = "standard"):
if stream_key in self._processes: if stream_key in self._processes:
return return
procs = [] procs = []
for quality in settings.qualities: qualities = settings.transcode_profiles.get(profile, settings.qualities)
segment_seconds = max(1, settings.hls_segment_seconds)
keyframe_frames = segment_seconds * 30
for quality in qualities:
label = quality["label"] label = quality["label"]
width = quality["width"] width = quality["width"]
height = quality["height"] height = quality["height"]
@@ -25,11 +27,16 @@ class TranscodeManager:
"-c:v", "libx264", "-preset", "ultrafast", "-c:v", "libx264", "-preset", "ultrafast",
"-b:v", bitrate, "-b:v", bitrate,
"-s", f"{width}x{height}", "-s", f"{width}x{height}",
"-sc_threshold", "0",
"-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{segment_seconds})",
"-copyts", "-start_at_zero",
"-c:a", "aac", "-b:a", "128k", "-c:a", "aac", "-b:a", "128k",
"-f", "hls", "-f", "hls",
"-hls_time", "4", "-hls_time", str(segment_seconds),
"-hls_list_size", "6", "-hls_list_size", "6",
"-hls_flags", "delete_segments", "-hls_flags", "delete_segments+independent_segments+program_date_time",
"-hls_segment_filename", f"{hls_dir}/segment_%03d.ts", "-hls_segment_filename", f"{hls_dir}/segment_%03d.ts",
f"{hls_dir}/index.m3u8", f"{hls_dir}/index.m3u8",
] ]
+2 -2
View File
@@ -13,8 +13,8 @@ const { Text } = Typography
const menuItems = [ const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' }, { key: '/admin', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '直播列表' }, { key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建直播' }, { key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' }, { key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' }, { key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
] ]
+5 -5
View File
@@ -14,7 +14,7 @@ export default function Dashboard() {
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, []) useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
async function loadData() { async function loadData() {
try { setStreams(await fetchStreams()) } catch { /* */ } try { setStreams(await fetchStreams(true)) } catch { /* */ }
setLoading(false) setLoading(false)
} }
@@ -51,7 +51,7 @@ export default function Dashboard() {
border: '1px solid #EBE5DB', border: '1px solid #EBE5DB',
borderTop: '3px solid #5B8C5A', borderTop: '3px solid #5B8C5A',
}}> }}>
<Statistic title="直播中" value={liveCount} loading={loading} <Statistic title="播出中" value={liveCount} loading={loading}
prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} /> prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} />
</div> </div>
</Col> </Col>
@@ -71,7 +71,7 @@ export default function Dashboard() {
border: '1px solid #EBE5DB', border: '1px solid #EBE5DB',
borderTop: '3px solid #C2693A', borderTop: '3px solid #C2693A',
}}> }}>
<Statistic title="直播总数" value={streams.length} loading={loading} <Statistic title="节目总数" value={streams.length} loading={loading}
prefix={<PlayCircleOutlined style={{ color: '#C2693A', fontSize: 20 }} />} /> prefix={<PlayCircleOutlined style={{ color: '#C2693A', fontSize: 20 }} />} />
</div> </div>
</Col> </Col>
@@ -82,10 +82,10 @@ export default function Dashboard() {
border: '1px solid #EBE5DB', border: '1px solid #EBE5DB',
padding: '28px 32px', padding: '28px 32px',
}}> }}>
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近直播</Title> <Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title>
<Table dataSource={streams.slice(0, 10)} columns={columns} <Table dataSource={streams.slice(0, 10)} columns={columns}
rowKey="id" loading={loading} pagination={false} size="middle" rowKey="id" loading={loading} pagination={false} size="middle"
locale={{ emptyText: '暂无直播数据' }} /> locale={{ emptyText: '暂无节目数据' }} />
</div> </div>
</div> </div>
) )
+45 -5
View File
@@ -1,8 +1,8 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd' import { Form, Input, Button, DatePicker, Typography, message, Space, Alert, Select, Switch } from 'antd'
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons' import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { createStream } from '../../api/streams' import { createStream, fetchMediaFiles, type Chapter } from '../../api/streams'
import axios from 'axios' import axios from 'axios'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -10,13 +10,29 @@ const { TextArea } = Input
export default function StreamForm() { export default function StreamForm() {
const [loading, setLoading] = useState(false) 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 [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => {
fetchMediaFiles().then(setMediaFiles).catch(() => message.warning('无法读取服务器回放目录'))
}, [])
const handleSubmit = async (values: any) => { const handleSubmit = async (values: any) => {
setLoading(true) setLoading(true)
try { try {
const res = await createStream({ title: values.title, description: values.description, cover_url: values.cover_url, scheduled_at: values.scheduled_at?.toISOString() }) const res = await createStream({
title: values.title,
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,
})
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key }) setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
message.success('直播已创建') message.success('直播已创建')
} catch (error) { } catch (error) {
@@ -75,7 +91,7 @@ export default function StreamForm() {
填写直播信息,系统将生成推流码与 RTMP 地址 填写直播信息,系统将生成推流码与 RTMP 地址
</Text> </Text>
<Form layout="vertical" onFinish={handleSubmit} size="large"> <Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}>
<Form.Item name="title" label="直播标题" <Form.Item name="title" label="直播标题"
rules={[{ required: true, message: '请输入直播标题' }]}> rules={[{ required: true, message: '请输入直播标题' }]}>
<Input placeholder="输入直播标题" style={{ borderRadius: 8 }} /> <Input placeholder="输入直播标题" style={{ borderRadius: 8 }} />
@@ -89,6 +105,30 @@ export default function StreamForm() {
<Form.Item name="scheduled_at" label="预定开播时间"> <Form.Item name="scheduled_at" label="预定开播时间">
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" /> <DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
</Form.Item> </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>
<Form.Item name="homepage_visible" label="首页公开" valuePropName="checked">
<Switch checkedChildren="公开" unCheckedChildren="仅私有链接" />
</Form.Item>
<Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} /> <div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}> <Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
+6 -6
View File
@@ -22,7 +22,7 @@ export default function StreamList() {
async function loadData() { async function loadData() {
setLoading(true) setLoading(true)
try { setStreams(await fetchStreams()) } catch { message.error('加载失败') } try { setStreams(await fetchStreams(true)) } catch { message.error('加载失败') }
setLoading(false) setLoading(false)
} }
async function handleDelete(id: number) { async function handleDelete(id: number) {
@@ -49,7 +49,7 @@ export default function StreamList() {
<Space size={4}> <Space size={4}>
<Tooltip title="复制推流码"> <Tooltip title="复制推流码">
<Button size="small" icon={<LinkOutlined />} <Button size="small" icon={<LinkOutlined />}
onClick={() => { navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} /> onClick={() => { if (r.stream_key) navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} />
</Tooltip> </Tooltip>
{r.status !== 'live' ? ( {r.status !== 'live' ? (
<Tooltip title="开播"><Button size="small" type="primary" ghost icon={<PlayCircleOutlined />} onClick={() => handleGoLive(r.id)} /></Tooltip> <Tooltip title="开播"><Button size="small" type="primary" ghost icon={<PlayCircleOutlined />} onClick={() => handleGoLive(r.id)} /></Tooltip>
@@ -69,11 +69,11 @@ export default function StreamList() {
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
<div> <div>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>直播列表</Title> <Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
<Text style={{ color: '#949088', fontSize: 14 }}>管理所有直播流</Text> <Text style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</Text>
</div> </div>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}> <Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
创建直播 创建节目
</Button> </Button>
</div> </div>
<div style={{ <div style={{
@@ -82,7 +82,7 @@ export default function StreamList() {
}}> }}>
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading} <Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle" pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"
locale={{ emptyText: '暂无直播,创建第一条直播吧' }} /> locale={{ emptyText: '暂无节目,创建第一个节目吧' }} />
</div> </div>
</div> </div>
) )
+28 -3
View File
@@ -5,13 +5,23 @@ export interface StreamInfo {
title: string title: string
description?: string description?: string
cover_url?: string cover_url?: string
stream_key: string stream_key?: string
status: string status: string
scheduled_at?: string scheduled_at?: string
started_at?: string started_at?: string
ended_at?: string ended_at?: string
multicast_enabled: boolean multicast_enabled: boolean
created_at: string created_at: string
replay_file?: string
chapters: Chapter[]
transcode_profile: string
homepage_visible: boolean
remote_control_enabled: boolean
}
export interface Chapter {
title: string
time: number
} }
export interface StreamCreateReq { export interface StreamCreateReq {
@@ -19,6 +29,11 @@ export interface StreamCreateReq {
description?: string description?: string
cover_url?: string cover_url?: string
scheduled_at?: string scheduled_at?: string
replay_file?: string
chapters?: Chapter[]
transcode_profile?: string
homepage_visible?: boolean
remote_control_enabled?: boolean
} }
export interface WatchInfo { export interface WatchInfo {
@@ -29,6 +44,11 @@ export interface WatchInfo {
selected_node?: { id: string; name: string } | null selected_node?: { id: string; name: string } | null
lines: LineInfo[] lines: LineInfo[]
qualities: QualityInfo[] qualities: QualityInfo[]
description?: string
scheduled_at?: string
replay_url?: string
chapters: Chapter[]
remote_control_enabled: boolean
} }
export interface LineInfo { export interface LineInfo {
@@ -54,8 +74,13 @@ export interface QualityInfo {
height: number height: number
} }
export async function fetchStreams(): Promise<StreamInfo[]> { export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
const { data } = await api.get('/streams') const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
return data
}
export async function fetchMediaFiles(): Promise<string[]> {
const { data } = await api.get('/streams/media-files')
return data return data
} }
+47 -28
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Select, Button, Typography, Space, Tag, Spin, Empty, message } from 'antd' import { Select, Button, Typography, Tag, Spin, Empty, message } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons' import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js' import Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams' import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
@@ -52,7 +52,14 @@ export default function WatchPage() {
} }
useEffect(() => { useEffect(() => {
if (!info || !activeLine || !videoRef.current) return if (!info || !videoRef.current) return
if (info.replay_url && info.status !== 'live') {
hlsRef.current?.destroy()
hlsRef.current = null
videoRef.current.src = info.replay_url
return
}
if (!activeLine) return
const line = info.lines.find(l => l.node_id === activeLine) const line = info.lines.find(l => l.node_id === activeLine)
if (!line) return if (!line) return
const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0] const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0]
@@ -74,6 +81,12 @@ export default function WatchPage() {
} }
}, [activeLine, activeQuality, info]) }, [activeLine, activeQuality, info])
function jumpToChapter(time: number) {
if (!videoRef.current) return
videoRef.current.currentTime = time
videoRef.current.play().catch(() => {})
}
useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand]) useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand])
if (loading) return ( if (loading) return (
@@ -91,55 +104,61 @@ export default function WatchPage() {
return ( return (
<div className="watch-shell"> <div className="watch-shell">
{/* Top bar */}
<header className="watch-header"> <header className="watch-header">
<Space size={16}> <div className="watch-heading">
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }} <Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }}
onClick={() => navigate('/')}>{brand.brand_name}</Button> onClick={() => navigate('/')}>{brand.brand_name}</Button>
<div style={{ width: 1, height: 18, background: 'rgba(235,229,219,0.1)' }} /> <span className="watch-divider" />
<Text style={{ color: '#EBE5DB', fontSize: 14, fontWeight: 500 }}>{info.title}</Text> <Text className="watch-title">{info.title}</Text>
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}> <Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}>
{statusLabel} {statusLabel}
</Tag> </Tag>
</Space> </div>
<Space size={16}> <div className="watch-options">
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag> <Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
<Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag> <Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag>
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}> <Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} /> <SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
{info.multicast_available ? '组播' : '单播'} {info.multicast_available ? '组播' : '单播'}
</Text> </Text>
<Select value={activeQuality} onChange={setActiveQuality} style={{ width: 100 }} size="small" <Select value={activeQuality || undefined} onChange={setActiveQuality} className="watch-quality" size="small"
options={info.qualities.map(q => ({ value: q.label, label: q.label }))} placeholder="清晰度" /> options={info.qualities.map(q => ({ value: q.label, label: q.label }))} placeholder="清晰度" />
<Select value={activeLine} onChange={setActiveLine} style={{ width: 150 }} size="small" <Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))} options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
prefix={<WifiOutlined />} placeholder="线路" /> prefix={<WifiOutlined />} placeholder="线路" />
</Space> </div>
</header> </header>
{/* Video */}
<main className="watch-stage"> <main className="watch-stage">
<div className="watch-content">
<div className="watch-frame"> <div className="watch-frame">
<video ref={videoRef} controls autoPlay playsInline <video ref={videoRef} controls autoPlay playsInline className="watch-video" />
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} /> {info.status !== 'live' && !info.replay_url && (
{info.status !== 'live' && ( <div className="watch-waiting">
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(10,10,8,0.92)',
}}>
<Space direction="vertical" align="center" size={16}>
<Spin size="large" /> <Spin size="large" />
<Text style={{ <Text>等待主播开播...</Text>
color: '#949088', fontSize: 17,
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
}}>
等待主播开播...
</Text>
</Space>
</div> </div>
)} )}
</div> </div>
{(info.description || info.chapters.length > 0) && (
<aside className="watch-details">
{info.description && <p>{info.description}</p>}
{info.chapters.length > 0 && (
<section>
<h2>节目章节</h2>
<div className="chapter-list">
{info.chapters.map((chapter, index) => (
<button key={`${chapter.time}-${index}`} type="button" onClick={() => jumpToChapter(chapter.time)}>
<span>{chapter.title}</span>
<time>{Math.floor(chapter.time / 60)}:{String(chapter.time % 60).padStart(2, '0')}</time>
</button>
))}
</div>
</section>
)}
</aside>
)}
</div>
</main> </main>
</div> </div>
) )
+99
View File
@@ -219,6 +219,28 @@ button.stream-card:focus-visible {
background: #1d1a16; background: #1d1a16;
} }
.watch-heading,
.watch-options {
display: flex;
align-items: center;
gap: 16px;
}
.watch-divider {
width: 1px;
height: 18px;
background: rgba(235, 229, 219, 0.1);
}
.watch-title {
color: #ebe5db;
font-size: 14px;
font-weight: 500;
}
.watch-quality { width: 100px; }
.watch-line { width: 150px; }
.watch-stage { .watch-stage {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -227,6 +249,11 @@ button.stream-card:focus-visible {
padding: 36px; padding: 36px;
} }
.watch-content {
width: 100%;
max-width: 1280px;
}
.watch-frame { .watch-frame {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -239,6 +266,52 @@ button.stream-card:focus-visible {
background: #080706; background: #080706;
} }
.watch-video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.watch-waiting {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(10, 10, 8, 0.92);
}
.watch-waiting .ant-typography { color: #949088; font-size: 17px; }
.watch-details {
margin-top: 24px;
padding: 24px;
border: 1px solid #3d3730;
border-radius: 8px;
background: #1d1a16;
}
.watch-details p { margin: 0 0 20px; color: #b7aea2; line-height: 1.7; }
.watch-details h2 { margin: 0 0 12px; font-size: 16px; }
.chapter-list { display: grid; gap: 8px; }
.chapter-list button {
display: flex;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: 1px solid #3d3730;
border-radius: 6px;
background: #24201b;
color: #eee7dc;
cursor: pointer;
text-align: left;
}
.chapter-list button:hover { border-color: #806d56; background: #2b261f; }
.chapter-list time { color: #949088; font-variant-numeric: tabular-nums; }
@media (max-width: 720px) { @media (max-width: 720px) {
.watch-header { .watch-header {
align-items: flex-start; align-items: flex-start;
@@ -246,6 +319,27 @@ button.stream-card:focus-visible {
padding: 14px 16px; padding: 14px 16px;
} }
.watch-heading,
.watch-options {
width: 100%;
flex-wrap: wrap;
gap: 10px;
}
.watch-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.watch-options .ant-tag,
.watch-options .ant-typography { display: none; }
.watch-quality,
.watch-line { flex: 1; width: auto; min-width: 120px; }
.watch-stage { .watch-stage {
align-items: flex-start; align-items: flex-start;
padding: 20px 0; padding: 20px 0;
@@ -256,6 +350,11 @@ button.stream-card:focus-visible {
border-left: 0; border-left: 0;
border-radius: 0; border-radius: 0;
} }
.watch-details {
margin: 16px;
padding: 18px;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {