v2: mpegts.js + single-upstream fan-out hub (no FFmpeg/HLS)

Replaces the FFmpeg -> MediaMTX -> HLS pipeline with a raw MPEG-TS
fan-out architecture:

- Go stream.Hub holds ONE upstream connection per active channel and
  broadcasts the raw TS bytes to all connected browsers via /ts.
  The IPTV provider only ever sees one IP (the server), no matter how
  many viewers are watching (shared single-channel model).
- Frontend uses mpegts.js to play the raw TS in-browser via MSE,
  fixing HEVC/H.265 4K streams that HLS could not package.
- Dropped FFmpeg, MediaMTX, RTMP/RTSP, HLS proxy, playback/ingest
  modes, the OBS source toggle, and FFmpeg health stats.
- UI look preserved (fullscreen video, collapsible sidebar, status
  bar, upstream-down banner, hourly playlist auto-refresh).

Tradeoff: loses iOS Safari (no MSE/mpegts.js); fixes desktop/Android
playback of both H.264 and H.265.
This commit is contained in:
2026-06-12 19:33:06 -07:00
parent 88a189de34
commit 723ae1f1ac
18 changed files with 432 additions and 899 deletions

View File

@@ -6,8 +6,6 @@ import (
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"strings"
@@ -16,7 +14,7 @@ import (
"tuner/api"
"tuner/m3u"
"tuner/process"
"tuner/stream"
)
func getEnv(key, fallback string) string {
@@ -26,65 +24,6 @@ func getEnv(key, fallback string) string {
return fallback
}
// normalizePlaybackMode validates the PLAYBACK_MODE value. Accepts "standard",
// "fmp4", or "llhls" (case-insensitive); anything else falls back to "standard".
func normalizePlaybackMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "llhls", "lowlatency", "low-latency":
return "llhls"
case "fmp4", "mp4", "fragmented-mp4", "fragmented_mp4":
return "fmp4"
default:
return "standard"
}
}
func normalizeIngestMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "rtsp":
return "rtsp"
default:
return "rtmp"
}
}
func normalizeIngestAudioMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "copy":
return "copy"
default:
return "aac"
}
}
// mediamtxEnvForMode returns the MTX_* environment variable overrides for the
// given playback mode.
func mediamtxEnvForMode(mode string) []string {
if mode == "llhls" {
// Low-Latency HLS: minimal latency, more sensitive to upstream jitter.
return []string{
"MTX_HLSVARIANT=lowLatency",
"MTX_HLSSEGMENTCOUNT=7",
"MTX_HLSSEGMENTDURATION=1s",
"MTX_HLSPARTDURATION=200ms",
}
}
if mode == "fmp4" {
// Fragmented MP4 HLS: smooth standard HLS with HEVC/H.265 support.
return []string{
"MTX_HLSVARIANT=fmp4",
"MTX_HLSSEGMENTCOUNT=7",
"MTX_HLSSEGMENTDURATION=2s",
}
}
// Standard HLS (mpegts): smooth, resilient, universally compatible (including iOS Safari).
return []string{
"MTX_HLSVARIANT=mpegts",
"MTX_HLSSEGMENTCOUNT=7",
"MTX_HLSSEGMENTDURATION=2s",
}
}
func parseRefreshInterval(value string) time.Duration {
v := strings.ToLower(strings.TrimSpace(value))
if v == "" || v == "0" || v == "off" || v == "false" || v == "disabled" {
@@ -121,14 +60,9 @@ func copyFile(src, dst string) error {
func main() {
playlistPath := getEnv("PLAYLIST_PATH", "/data/playlist.m3u")
mediamtxBin := getEnv("MEDIAMTX_PATH", "mediamtx")
mediamtxCfg := getEnv("MEDIAMTX_CONFIG", "mediamtx.yml")
port := getEnv("RESTREAMER_PORT", "8080")
playlistURL := getEnv("PLAYLIST_URL", "")
useLocal := getEnv("PLAYLIST_USE_LOCAL", "false") == "true"
playbackMode := normalizePlaybackMode(getEnv("PLAYBACK_MODE", "standard"))
ingestMode := normalizeIngestMode(getEnv("INGEST_MODE", "rtmp"))
ingestAudioMode := normalizeIngestAudioMode(getEnv("INGEST_AUDIO_MODE", "aac"))
playlistRefreshInterval := parseRefreshInterval(getEnv("PLAYLIST_AUTO_REFRESH_INTERVAL", "1h"))
localSrcPath := getEnv("PLAYLIST_LOCAL_SRC", "/data/playlist.m3u.local")
@@ -145,20 +79,13 @@ func main() {
}
}
// Initialize process managers
mtxManager := process.NewMediaMTXManager(mediamtxBin, mediamtxCfg)
mtxManager.SetEnv(mediamtxEnvForMode(playbackMode))
ffmpegManager := process.NewFFmpegManager(ingestMode, ingestAudioMode)
log.Printf("[startup] playback mode: %s", playbackMode)
log.Printf("[startup] ingest mode: %s", ingestMode)
log.Printf("[startup] ingest audio mode: %s", ingestAudioMode)
// Single shared upstream fan-out hub.
hub := stream.NewHub()
// Build shared app state
app := &api.App{
PlaylistPath: playlistPath,
PlaybackMode: playbackMode,
MediaMTX: mtxManager,
FFmpeg: ffmpegManager,
Hub: hub,
}
// Only expose the URL for reloads when we're in URL mode
if !useLocal && playlistURL != "" {
@@ -194,11 +121,6 @@ func main() {
log.Printf("[startup] playlist auto-refresh disabled")
}
// Start MediaMTX (warn but don't crash if binary not found)
if err := mtxManager.Start(); err != nil {
log.Printf("[startup] warning: could not start mediamtx: %v", err)
}
// Register routes
mux := http.NewServeMux()
@@ -207,18 +129,11 @@ func main() {
mux.HandleFunc("/api/status", app.HandleStatus)
mux.HandleFunc("/api/admin/channels", app.HandleChannels)
mux.HandleFunc("/api/admin/groups", app.HandleGroups)
mux.HandleFunc("/api/admin/source", app.HandleSetSource)
mux.HandleFunc("/api/admin/channel", app.HandleSetChannel)
mux.HandleFunc("/api/admin/playlist/reload", app.HandleReloadPlaylist)
mux.HandleFunc("/api/admin/process/status", app.HandleProcessStatus)
// Proxy HLS through the main app origin so HTTPS deployments avoid mixed content.
hlsURL, err := url.Parse("http://127.0.0.1:8888")
if err != nil {
log.Printf("[startup] warning: invalid HLS proxy URL: %v", err)
} else {
mux.Handle("/live/", httputil.NewSingleHostReverseProxy(hlsURL))
}
// Raw MPEG-TS fan-out endpoint: every viewer streams the shared channel here.
mux.HandleFunc("/ts", app.HandleTS)
// Serve frontend static files (falls back to index.html for SPA routing)
frontendDir := getEnv("FRONTEND_DIR", "frontend/dist")
@@ -257,13 +172,7 @@ func main() {
sig := <-sigCh
log.Printf("[shutdown] received %v, shutting down...", sig)
// Stop child processes
if err := ffmpegManager.Stop(); err != nil {
log.Printf("[shutdown] error stopping ffmpeg: %v", err)
}
if err := mtxManager.Stop(); err != nil {
log.Printf("[shutdown] error stopping mediamtx: %v", err)
}
hub.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()