完善观众端目录与回放状态降级
This commit is contained in:
1 parent
3060828774
commit
a8067db922
7 files changed
+196
-9
No files matched your search
@@ -2,6 +2,7 @@ import json
|
||||
import secrets
|
||||
import asyncio
|
||||
import time
|
||||
import ntpath
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
@@ -62,7 +63,8 @@ def _media_path(filename: str | None) -> Path | None:
|
||||
return None
|
||||
root = Path(settings.replay_media_dir).resolve()
|
||||
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="回放文件不在允许的媒体目录中")
|
||||
candidate = (root / supplied).resolve()
|
||||
if candidate != root and root not in candidate.parents:
|
||||
|
||||
@@ -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}])
|
||||
@@ -63,7 +63,7 @@ export default function ReplayEditor() {
|
||||
if (!stream) return <Alert type="error" message="节目不存在" />
|
||||
|
||||
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>
|
||||
<Title level={2} style={{ marginBottom: 4 }}>回放与章节</Title>
|
||||
<Text type="secondary">{stream.title}</Text>
|
||||
@@ -88,12 +88,12 @@ export default function ReplayEditor() {
|
||||
<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%' }}>
|
||||
<Space key={index} wrap className="replay-chapter-row" style={{ width: '100%', minWidth: 0 }}>
|
||||
<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%' }}
|
||||
style={{ width: 320, maxWidth: '100%', flex: '1 1 220px' }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={0}
|
||||
|
||||
@@ -72,3 +72,9 @@ h1, h2, h3, h4, h5, h6 {
|
||||
background: #EAD7C8;
|
||||
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%; }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 { useBrand } from '../../brand/BrandContext'
|
||||
import './viewer.css'
|
||||
@@ -10,6 +10,8 @@ export default function HomePage() {
|
||||
const { brand } = useBrand()
|
||||
const [streams, setStreams] = useState<StreamInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,10 +27,21 @@ export default function HomePage() {
|
||||
const o: Record<string, number> = { live: 0, pending: 1, ended: 2, cancelled: 3 }
|
||||
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 (
|
||||
<div className="viewer-shell">
|
||||
<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>
|
||||
{brand.brand_logo_url && (
|
||||
<img src={brand.brand_logo_url} alt={brand.brand_name}
|
||||
@@ -44,7 +57,7 @@ export default function HomePage() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="viewer-main">
|
||||
<main className="viewer-main" id="programmes">
|
||||
<div className="viewer-section-heading">
|
||||
<h2>
|
||||
直播频道
|
||||
@@ -54,6 +67,35 @@ export default function HomePage() {
|
||||
</span>
|
||||
</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 ? (
|
||||
<div className="stream-grid" aria-label="正在加载直播节目">
|
||||
{[1,2,3,4,5,6].map(i => (
|
||||
@@ -65,7 +107,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
) : visibleStreams.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center', padding: '80px 0',
|
||||
background: '#fff', borderRadius: 14,
|
||||
@@ -76,7 +118,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="stream-grid">
|
||||
{sorted.map(s => {
|
||||
{visibleStreams.map(s => {
|
||||
const isLive = s.status === 'live'
|
||||
const isPending = s.status === 'pending'
|
||||
return (
|
||||
|
||||
@@ -23,14 +23,28 @@ export default function WatchPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||
let disposed = false
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollTimer || disposed) return
|
||||
loadInfo()
|
||||
pollTimer = setInterval(loadInfo, 5000)
|
||||
}
|
||||
|
||||
loadInfo()
|
||||
const events = new EventSource(getStreamStatusUrl(Number(id)))
|
||||
events.addEventListener('status', loadInfo)
|
||||
events.addEventListener('deleted', () => setInfo(null))
|
||||
events.onerror = () => {
|
||||
events.close()
|
||||
startPolling()
|
||||
}
|
||||
return () => {
|
||||
disposed = true
|
||||
events.close()
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
}
|
||||
return () => events.close()
|
||||
}, [id])
|
||||
|
||||
async function loadInfo() {
|
||||
|
||||
@@ -5,6 +5,61 @@
|
||||
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 {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
@@ -364,6 +419,13 @@ button.stream-card:focus-visible {
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding: 58px 20px 48px;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user