Files
live/backend/services/stream_service.py
T
adminandClaude Opus 4.8 996e3fea39 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]>
2026-09-22 18:07:56 +08:00

231 lines
9.6 KiB
Python

from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from models.stream import Stream, StreamStatus
from services.media_service import media_service
from services.load_balancer import lb
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
@staticmethod
def effective_renditions(stream: Stream) -> list:
if stream.renditions_json:
try:
renditions = json.loads(stream.renditions_json)
if isinstance(renditions, list) and renditions:
return renditions
except (TypeError, ValueError, json.JSONDecodeError):
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,
replay_file: str | None = None,
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
scheduled_datetime = None
if scheduled_at:
try:
scheduled_datetime = datetime.fromisoformat(scheduled_at.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("预定开播时间格式无效") from exc
stream = Stream(
title=title,
description=description,
cover_url=cover_url,
replay_file=replay_file,
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,
status=StreamStatus.pending,
scheduled_at=scheduled_datetime,
)
self.db.add(stream)
await self.db.commit()
await self.db.refresh(stream)
return stream
async def get_all_streams(self):
result = await self.db.execute(
select(Stream).order_by(Stream.created_at.desc())
)
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):
result = await self.db.execute(
select(Stream).where(Stream.id == stream_id)
)
return result.scalar_one_or_none()
async def get_stream_by_key(self, stream_key: str):
result = await self.db.execute(
select(Stream).where(Stream.stream_key == stream_key)
)
return result.scalar_one_or_none()
async def update_stream(self, stream_id: int, **kwargs):
stmt = update(Stream).where(Stream.id == stream_id).values(**kwargs)
await self.db.execute(stmt)
await self.db.commit()
async def delete_stream(self, stream_id: int):
stream = await self.get_stream(stream_id)
if stream:
transcode_mgr.stop_transcode(stream.stream_key)
await self.db.delete(stream)
await self.db.commit()
async def set_stream_live(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key)
if stream:
from datetime import datetime
await self.update_stream(
stream.id,
status=StreamStatus.live,
started_at=datetime.utcnow(),
)
rtmp_url = media_service.get_rtmp_url(stream_key)
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)
if stream:
from datetime import datetime
await self.update_stream(
stream.id,
status=StreamStatus.ended,
ended_at=datetime.utcnow(),
)
transcode_mgr.stop_transcode(stream_key)
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 = 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
lines = []
if best_node:
base_hls = f"/hls/{stream_key}"
line_info = {
"node_id": best_node["id"],
"node_name": best_node["name"],
"weight": best_node["weight"],
"type": "hls",
"url": f"{base_hls}/index.m3u8",
"qualities": [],
}
for quality in qualities:
# 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": quality["label"],
"width": quality["width"],
"height": quality["height"],
"url": f"{base_hls}/{quality['path']}/index.m3u8",
})
lines.append(line_info)
return {
"lines": lines,
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in qualities],
"multicast_available": multicast_detector.is_available(),
"selected_node": {
"id": best_node["id"],
"name": best_node["name"],
} if best_node else None,
}