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:
105
backend/main.go
105
backend/main.go
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user