完善观众端目录与回放状态降级

This commit is contained in:
admin committed 2026-08-17 14:15:44 +08:00
1 parent 3060828774
commit a8067db922
7 files changed
+196 -9

No files matched your search

+3 -1
View File
@@ -2,6 +2,7 @@ import json
import secrets import secrets
import asyncio import asyncio
import time import time
import ntpath
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
@@ -62,7 +63,8 @@ def _media_path(filename: str | None) -> Path | None:
return None return None
root = Path(settings.replay_media_dir).resolve() root = Path(settings.replay_media_dir).resolve()
supplied = Path(filename) supplied = Path(filename)
if supplied.is_absolute() or supplied.drive or ".." in supplied.parts: windows_drive, _ = ntpath.splitdrive(filename)
if supplied.is_absolute() or supplied.drive or windows_drive or ".." in supplied.parts:
raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中") raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中")
candidate = (root / supplied).resolve() candidate = (root / supplied).resolve()
if candidate != root and root not in candidate.parents: if candidate != root and root not in candidate.parents:
+61
View File
@@ -0,0 +1,61 @@
import json
import tempfile
import unittest
from pathlib import Path, PureWindowsPath
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from routes.streams import _media_path, _normalize_chapters, _status_payload
class ReplayWorkflowTest(unittest.TestCase):
def test_media_path_accepts_nested_file_in_media_root(self):
with tempfile.TemporaryDirectory() as directory:
replay = Path(directory, "events", "session.mp4")
replay.parent.mkdir()
replay.write_bytes(b"video")
with patch("routes.streams.settings.replay_media_dir", directory):
self.assertEqual(_media_path("events/session.mp4"), replay.resolve())
def test_media_path_rejects_parent_traversal_and_absolute_paths(self):
with tempfile.TemporaryDirectory() as directory:
with patch("routes.streams.settings.replay_media_dir", directory):
for unsafe in ("../secret.mp4", "/tmp/secret.mp4"):
with self.subTest(unsafe=unsafe), self.assertRaises(HTTPException):
_media_path(unsafe)
def test_media_path_rejects_windows_absolute_path_on_any_platform(self):
value = str(PureWindowsPath("C:/media/replay.mp4"))
with tempfile.TemporaryDirectory() as directory:
with patch("routes.streams.settings.replay_media_dir", directory):
with self.assertRaises(HTTPException):
_media_path(value)
def test_chapters_are_trimmed_validated_and_sorted(self):
chapters = _normalize_chapters([
{"title": " Closing ", "time": "62.5"},
{"title": "Opening", "time": 0},
])
self.assertEqual(chapters, [
{"title": "Opening", "time": 0.0},
{"title": "Closing", "time": 62.5},
])
def test_chapters_reject_invalid_title_and_time(self):
for chapters in ([{"title": "", "time": 1}], [{"title": "Bad", "time": -1}]):
with self.subTest(chapters=chapters), self.assertRaises(HTTPException):
_normalize_chapters(chapters)
def test_status_payload_exposes_pending_live_and_ended_updates(self):
stream = SimpleNamespace(id=7, replay_file=None, chapters_json="[]")
for status in ("pending", "live", "ended"):
stream.status = SimpleNamespace(value=status)
self.assertEqual(_status_payload(stream)["status"], status)
stream.replay_file = "session.mp4"
stream.chapters_json = json.dumps([{"title": "Intro", "time": 0}])
payload = _status_payload(stream)
self.assertEqual(payload["replay_url"], "/api/streams/7/replay")
self.assertEqual(payload["chapters"], [{"title": "Intro", "time": 0}])
+3 -3
View File
@@ -63,7 +63,7 @@ export default function ReplayEditor() {
if (!stream) return <Alert type="error" message="节目不存在" /> if (!stream) return <Alert type="error" message="节目不存在" />
return ( return (
<div style={{ maxWidth: 980, margin: '0 auto', padding: '8px 0 48px' }}> <div className="replay-editor" style={{ maxWidth: 980, margin: '0 auto', padding: '8px 0 48px', minWidth: 0 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')} style={{ marginBottom: 20 }}>返回节目列表</Button> <Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')} style={{ marginBottom: 20 }}>返回节目列表</Button>
<Title level={2} style={{ marginBottom: 4 }}>回放与章节</Title> <Title level={2} style={{ marginBottom: 4 }}>回放与章节</Title>
<Text type="secondary">{stream.title}</Text> <Text type="secondary">{stream.title}</Text>
@@ -88,12 +88,12 @@ export default function ReplayEditor() {
<Button icon={<PlusOutlined />} onClick={addChapter} disabled={!replayFile}>按当前播放时间添加章节</Button> <Button icon={<PlusOutlined />} onClick={addChapter} disabled={!replayFile}>按当前播放时间添加章节</Button>
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
{chapters.map((chapter, index) => ( {chapters.map((chapter, index) => (
<Space key={index} wrap style={{ width: '100%' }}> <Space key={index} wrap className="replay-chapter-row" style={{ width: '100%', minWidth: 0 }}>
<Input <Input
value={chapter.title} value={chapter.title}
placeholder="章节标题" placeholder="章节标题"
onChange={event => setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))} onChange={event => setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))}
style={{ width: 320, maxWidth: '100%' }} style={{ width: 320, maxWidth: '100%', flex: '1 1 220px' }}
/> />
<InputNumber <InputNumber
min={0} min={0}
+6
View File
@@ -72,3 +72,9 @@ h1, h2, h3, h4, h5, h6 {
background: #EAD7C8; background: #EAD7C8;
color: #1C1A17; color: #1C1A17;
} }
@media (max-width: 600px) {
.replay-editor { overflow: hidden; }
.replay-editor video { max-width: 100%; }
.replay-chapter-row { display: flex !important; }
.replay-chapter-row .ant-input-number { flex: 1 1 140px; max-width: 100%; }
}
+46 -4
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Skeleton } from 'antd' import { Skeleton } from 'antd'
import { VideoCameraOutlined } from '@ant-design/icons' import { SearchOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams' import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext' import { useBrand } from '../../brand/BrandContext'
import './viewer.css' import './viewer.css'
@@ -10,6 +10,8 @@ export default function HomePage() {
const { brand } = useBrand() const { brand } = useBrand()
const [streams, setStreams] = useState<StreamInfo[]>([]) const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('all')
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -25,10 +27,21 @@ export default function HomePage() {
const o: Record<string, number> = { live: 0, pending: 1, ended: 2, cancelled: 3 } const o: Record<string, number> = { live: 0, pending: 1, ended: 2, cancelled: 3 }
return (o[a.status] ?? 9) - (o[b.status] ?? 9) return (o[a.status] ?? 9) - (o[b.status] ?? 9)
}) })
const visibleStreams = sorted.filter(stream => {
const matchesFilter = filter === 'all' || stream.status === filter
const haystack = `${stream.title} ${stream.description || ''}`.toLowerCase()
return matchesFilter && haystack.includes(query.trim().toLowerCase())
})
return ( return (
<div className="viewer-shell"> <div className="viewer-shell">
<header className="viewer-hero"> <header className="viewer-hero">
<nav className="viewer-nav" aria-label="主导航">
<span>{brand.brand_name}</span>
<button type="button" onClick={() => document.getElementById('programmes')?.scrollIntoView()}>
节目目录
</button>
</nav>
<div className="viewer-eyebrow">LIVE PROGRAMME</div> <div className="viewer-eyebrow">LIVE PROGRAMME</div>
{brand.brand_logo_url && ( {brand.brand_logo_url && (
<img src={brand.brand_logo_url} alt={brand.brand_name} <img src={brand.brand_logo_url} alt={brand.brand_name}
@@ -44,7 +57,7 @@ export default function HomePage() {
)} )}
</header> </header>
<main className="viewer-main"> <main className="viewer-main" id="programmes">
<div className="viewer-section-heading"> <div className="viewer-section-heading">
<h2> <h2>
直播频道 直播频道
@@ -54,6 +67,35 @@ export default function HomePage() {
</span> </span>
</div> </div>
<div className="viewer-directory-tools">
<label className="viewer-search">
<SearchOutlined aria-hidden="true" />
<input
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="搜索节目标题或简介"
aria-label="搜索节目"
/>
</label>
<div className="viewer-filters" aria-label="按节目状态筛选">
{[
['all', '全部'],
['live', '直播中'],
['pending', '待开播'],
['ended', '回放'],
].map(([value, label]) => (
<button
key={value}
type="button"
className={filter === value ? 'is-active' : ''}
onClick={() => setFilter(value)}
>
{label}
</button>
))}
</div>
</div>
{loading ? ( {loading ? (
<div className="stream-grid" aria-label="正在加载直播节目"> <div className="stream-grid" aria-label="正在加载直播节目">
{[1,2,3,4,5,6].map(i => ( {[1,2,3,4,5,6].map(i => (
@@ -65,7 +107,7 @@ export default function HomePage() {
</div> </div>
))} ))}
</div> </div>
) : sorted.length === 0 ? ( ) : visibleStreams.length === 0 ? (
<div style={{ <div style={{
textAlign: 'center', padding: '80px 0', textAlign: 'center', padding: '80px 0',
background: '#fff', borderRadius: 14, background: '#fff', borderRadius: 14,
@@ -76,7 +118,7 @@ export default function HomePage() {
</div> </div>
) : ( ) : (
<div className="stream-grid"> <div className="stream-grid">
{sorted.map(s => { {visibleStreams.map(s => {
const isLive = s.status === 'live' const isLive = s.status === 'live'
const isPending = s.status === 'pending' const isPending = s.status === 'pending'
return ( return (
+15 -1
View File
@@ -23,14 +23,28 @@ export default function WatchPage() {
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
let pollTimer: ReturnType<typeof setInterval> | undefined
let disposed = false
const startPolling = () => {
if (pollTimer || disposed) return
loadInfo()
pollTimer = setInterval(loadInfo, 5000)
}
loadInfo() loadInfo()
const events = new EventSource(getStreamStatusUrl(Number(id))) const events = new EventSource(getStreamStatusUrl(Number(id)))
events.addEventListener('status', loadInfo) events.addEventListener('status', loadInfo)
events.addEventListener('deleted', () => setInfo(null)) events.addEventListener('deleted', () => setInfo(null))
events.onerror = () => { events.onerror = () => {
events.close() events.close()
startPolling()
}
return () => {
disposed = true
events.close()
if (pollTimer) clearInterval(pollTimer)
} }
return () => events.close()
}, [id]) }, [id])
async function loadInfo() { async function loadInfo() {
+62
View File
@@ -5,6 +5,61 @@
font-family: "Noto Serif SC", "Songti SC", SimSun, Georgia, serif; font-family: "Noto Serif SC", "Songti SC", SimSun, Georgia, serif;
} }
.viewer-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 72px;
color: #6f695f;
font-family: Inter, "Noto Sans SC", sans-serif;
font-size: 13px;
}
.viewer-nav span { color: #292720; font-weight: 650; }
.viewer-nav a { color: inherit; text-decoration: none; }
.viewer-nav a:hover { color: #292720; }
.viewer-directory-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 30px;
}
.viewer-search {
display: flex;
align-items: center;
width: min(420px, 100%);
gap: 10px;
padding: 11px 13px;
border: 1px solid #d8d2c8;
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
color: #767066;
}
.viewer-search:focus-within { border-color: #aaa296; box-shadow: 0 0 0 3px rgba(117, 108, 94, 0.08); }
.viewer-search input { width: 100%; border: 0; outline: 0; background: transparent; color: #292720; font: 14px/1.4 Inter, "Noto Sans SC", sans-serif; }
.viewer-search input::placeholder { color: #9d978d; }
.viewer-filters { display: flex; flex-wrap: wrap; gap: 6px; }
.viewer-filters button {
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: #777168;
cursor: pointer;
font: 13px/1 Inter, "Noto Sans SC", sans-serif;
}
.viewer-filters button:hover { border-color: #d8d2c8; color: #292720; background: rgba(255, 255, 255, 0.45); }
.viewer-filters button.is-active { border-color: #cbc4b9; background: #eae5dc; color: #292720; }
.stream-card { border-color: #d9d3c9; box-shadow: none; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; }
.stream-card:hover { transform: translateY(-2px); border-color: #bcb4a8; }
.stream-copy h3 { font-size: clamp(17px, 1.6vw, 21px); }
.viewer-hero { .viewer-hero {
max-width: 1180px; max-width: 1180px;
margin: 0 auto; margin: 0 auto;
@@ -364,6 +419,13 @@ button.stream-card:focus-visible {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.viewer-nav { margin-bottom: 48px; }
.viewer-directory-tools { align-items: stretch; flex-direction: column; }
.viewer-search { width: auto; }
.viewer-filters { overflow-x: auto; flex-wrap: nowrap; padding-bottom: 3px; }
.viewer-filters button { flex: none; }
.viewer-hero { .viewer-hero {
padding: 58px 20px 48px; padding: 58px 20px 48px;
} }