diff --git a/backend/routes/streams.py b/backend/routes/streams.py
index 7107406..16562c9 100644
--- a/backend/routes/streams.py
+++ b/backend/routes/streams.py
@@ -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:
diff --git a/backend/tests/test_replay_workflow.py b/backend/tests/test_replay_workflow.py
new file mode 100644
index 0000000..56f10ac
--- /dev/null
+++ b/backend/tests/test_replay_workflow.py
@@ -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}])
diff --git a/frontend/src/admin/pages/ReplayEditor.tsx b/frontend/src/admin/pages/ReplayEditor.tsx
index 521877d..07dc2de 100644
--- a/frontend/src/admin/pages/ReplayEditor.tsx
+++ b/frontend/src/admin/pages/ReplayEditor.tsx
@@ -63,7 +63,7 @@ export default function ReplayEditor() {
if (!stream) return
return (
-
+
} onClick={() => navigate('/admin/streams')} style={{ marginBottom: 20 }}>返回节目列表
回放与章节
{stream.title}
@@ -88,12 +88,12 @@ export default function ReplayEditor() {
} onClick={addChapter} disabled={!replayFile}>按当前播放时间添加章节
{chapters.map((chapter, index) => (
-
+
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' }}
/>
([])
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 = { 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 (
+
LIVE PROGRAMME
{brand.brand_logo_url && (
-
+
直播频道
@@ -54,6 +67,35 @@ export default function HomePage() {
+
+
+
+ {[
+ ['all', '全部'],
+ ['live', '直播中'],
+ ['pending', '待开播'],
+ ['ended', '回放'],
+ ].map(([value, label]) => (
+
+ ))}
+
+
+
{loading ? (
{[1,2,3,4,5,6].map(i => (
@@ -65,7 +107,7 @@ export default function HomePage() {
))}
- ) : sorted.length === 0 ? (
+ ) : visibleStreams.length === 0 ? (
) : (
- {sorted.map(s => {
+ {visibleStreams.map(s => {
const isLive = s.status === 'live'
const isPending = s.status === 'pending'
return (
diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx
index 9b4f27d..bab325e 100644
--- a/frontend/src/viewer/pages/WatchPage.tsx
+++ b/frontend/src/viewer/pages/WatchPage.tsx
@@ -23,14 +23,28 @@ export default function WatchPage() {
useEffect(() => {
if (!id) return
+ let pollTimer: ReturnType | 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() {
diff --git a/frontend/src/viewer/pages/viewer.css b/frontend/src/viewer/pages/viewer.css
index 9b6544d..203176c 100644
--- a/frontend/src/viewer/pages/viewer.css
+++ b/frontend/src/viewer/pages/viewer.css
@@ -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;
}