feat: consolidate on the shadcn frontend and harden playback
Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
one router and one design system to maintain.
Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
single 4s segment, so playback ran at up to 1.10x and the forward buffer
bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
gesture that restored sound also toggled playback off. One owner now.
Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
transcoded ones, so a source option exists even when every ladder entry
downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.
Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
httpx client open for the streamed body.
Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
.venv; remove generated screenshots, load test scripts and stray files.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
186e32caf1
commit
996e3fea39
70 files changed
+4844
-3070
No files matched your search
@@ -7,9 +7,24 @@ from services.multicast_detector import multicast_detector
|
||||
from services.transcoder import transcode_mgr
|
||||
from config import settings
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# MediaMTX rejects any HLS path that is not alphanumeric ("invalid path name: can
|
||||
# contain only alphanumeric characters"), and the label is also used as a directory
|
||||
# name, so every rendition needs a path-safe slug of its own.
|
||||
def hls_path_slug(label: str) -> str:
|
||||
"""Filesystem- and MediaMTX-safe rendition path for a display label."""
|
||||
slug = re.sub(r"[^A-Za-z0-9]", "", label or "")
|
||||
return slug or "rendition"
|
||||
|
||||
|
||||
def with_hls_path(rendition: dict) -> dict:
|
||||
"""Attach the `path` an admin-configured rendition is stored and served under."""
|
||||
return {**rendition, "path": hls_path_slug(rendition.get("label", ""))}
|
||||
|
||||
|
||||
class StreamService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
@@ -25,6 +40,56 @@ class StreamService:
|
||||
pass
|
||||
return settings.transcode_profiles.get(stream.transcode_profile, settings.qualities)
|
||||
|
||||
@staticmethod
|
||||
def source_label() -> str:
|
||||
"""Display name of the stream-copy rendition, or '' when it is disabled.
|
||||
|
||||
A blank label would leak into the HLS path and produce a URL like
|
||||
/hls/<key>//index.m3u8, so it degrades to "off" instead.
|
||||
"""
|
||||
return settings.source_rendition_label.strip() if settings.source_rendition_enabled else ""
|
||||
|
||||
@classmethod
|
||||
def source_slug(cls) -> str:
|
||||
"""Path segment for that rendition, safe for MediaMTX and the filesystem."""
|
||||
if not cls.source_label():
|
||||
return ""
|
||||
return hls_path_slug(settings.source_rendition_slug)
|
||||
|
||||
def watch_renditions(self, stream: Stream) -> list:
|
||||
"""Renditions a viewer may pick: the stream-copy rendition first, then the configured ones.
|
||||
|
||||
Each entry carries both the label shown to viewers and the `path` used in
|
||||
HLS URLs — MediaMTX only accepts alphanumeric HLS paths, so a Chinese label
|
||||
such as "原画" cannot double as a directory name.
|
||||
|
||||
The source entry carries no size: the input resolution is only known to
|
||||
ffmpeg, so the player measures it from the media it ends up decoding.
|
||||
"""
|
||||
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
|
||||
label, slug = self.source_label(), self.source_slug()
|
||||
if not label or not slug or not renditions or renditions[0]["path"] == slug:
|
||||
return renditions
|
||||
return [{"label": label, "path": slug, "width": None, "height": None, "copy": True}] + renditions
|
||||
|
||||
def transcode_renditions(self, stream: Stream) -> list:
|
||||
"""Renditions to hand to the transcoder, including the stream-copy source rendition.
|
||||
|
||||
Carries the same `path` values as watch_renditions so the files ffmpeg
|
||||
writes are the files the player asks for.
|
||||
|
||||
The copy entry is the top rendition with its label replaced, so it carries
|
||||
every key the transcoder reads. ffmpeg ignores those numbers for a stream
|
||||
copy, but the supervisor in main.py compares TranscodeManager's recorded
|
||||
signature against this list every tick — an entry missing a field would
|
||||
both crash the start and make the signature drift.
|
||||
"""
|
||||
renditions = [with_hls_path(r) for r in self.effective_renditions(stream)]
|
||||
label, slug = self.source_label(), self.source_slug()
|
||||
if not label or not slug or not renditions or renditions[0]["path"] == slug:
|
||||
return renditions
|
||||
return [{**renditions[0], "label": label, "path": slug, "copy": True}] + renditions
|
||||
|
||||
async def create_stream(self, title: str, description: str | None = None,
|
||||
cover_url: str | None = None,
|
||||
scheduled_at: str | None = None,
|
||||
@@ -32,6 +97,7 @@ class StreamService:
|
||||
replay_url: str | None = None,
|
||||
chapters: list[dict] | None = None,
|
||||
transcode_profile: str = "standard",
|
||||
renditions_json: str | None = None,
|
||||
homepage_visible: bool = True,
|
||||
remote_control_enabled: bool = False) -> Stream:
|
||||
import uuid
|
||||
@@ -49,6 +115,7 @@ class StreamService:
|
||||
replay_url=replay_url,
|
||||
chapters_json=json.dumps(chapters or [], ensure_ascii=False),
|
||||
transcode_profile=transcode_profile,
|
||||
renditions_json=renditions_json,
|
||||
homepage_visible=homepage_visible,
|
||||
remote_control_enabled=remote_control_enabled,
|
||||
stream_key=uuid.uuid4().hex,
|
||||
@@ -108,7 +175,7 @@ class StreamService:
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
rtmp_url = media_service.get_rtmp_url(stream_key)
|
||||
transcode_mgr.start_transcode(stream_key, rtmp_url, self.effective_renditions(stream))
|
||||
transcode_mgr.start_transcode(stream_key, rtmp_url, self.transcode_renditions(stream))
|
||||
|
||||
async def set_stream_ended(self, stream_key: str):
|
||||
stream = await self.get_stream_by_key(stream_key)
|
||||
@@ -125,7 +192,7 @@ class StreamService:
|
||||
stream = await self.get_stream_by_key(stream_key)
|
||||
if not stream:
|
||||
return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None}
|
||||
qualities = self.effective_renditions(stream)
|
||||
qualities = self.watch_renditions(stream)
|
||||
best_node = lb.select_best_node(client_ip)
|
||||
if not best_node:
|
||||
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
|
||||
@@ -142,12 +209,13 @@ class StreamService:
|
||||
"qualities": [],
|
||||
}
|
||||
for quality in qualities:
|
||||
label = quality["label"]
|
||||
# The URL segment is the rendition's path slug, not its label:
|
||||
# labels are display text and may be Chinese.
|
||||
line_info["qualities"].append({
|
||||
"label": label,
|
||||
"label": quality["label"],
|
||||
"width": quality["width"],
|
||||
"height": quality["height"],
|
||||
"url": f"{base_hls}/{label}/index.m3u8",
|
||||
"url": f"{base_hls}/{quality['path']}/index.m3u8",
|
||||
})
|
||||
lines.append(line_info)
|
||||
|
||||
|
||||
Reference in new issue
Block a user