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

@@ -9,7 +9,7 @@ import (
"tuner/m3u"
"tuner/models"
"tuner/process"
"tuner/stream"
)
// App holds shared application state for all handlers.
@@ -19,12 +19,10 @@ type App struct {
Status models.StreamStatus
PlaylistPath string
PlaylistURL string // if set, reload fetches from this URL before parsing
PlaybackMode string // "llhls" or "standard"
SwitchStartedAt time.Time
CurrentStreamURL string
PlaylistVersion int64
MediaMTX *process.MediaMTXManager
FFmpeg *process.FFmpegManager
Hub *stream.Hub
}
// HandleConfig returns runtime client configuration as JSON.
@@ -35,13 +33,8 @@ func (a *App) HandleConfig(w http.ResponseWriter, r *http.Request) {
return
}
mode := a.PlaybackMode
if mode == "" {
mode = "standard"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"playback_mode": mode})
json.NewEncoder(w).Encode(map[string]string{"player": "mpegts"})
}
// HandleChannels lists, searches, and filters channels.
@@ -79,52 +72,7 @@ func (a *App) HandleGroups(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(groups)
}
// HandleSetSource sets the active source (obs or iptv).
// POST /api/admin/source {"source": "obs"|"iptv"}
func (a *App) HandleSetSource(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Source != "obs" && req.Source != "iptv" {
http.Error(w, `source must be "obs" or "iptv"`, http.StatusBadRequest)
return
}
a.mu.Lock()
a.Status.Source = req.Source
a.mu.Unlock()
// If switching to OBS, stop FFmpeg relay
if req.Source == "obs" {
if err := a.FFmpeg.Stop(); err != nil {
log.Printf("[admin] error stopping ffmpeg: %v", err)
}
a.mu.Lock()
a.Status.ChannelName = ""
a.Status.Transitioning = false
a.Status.UpstreamDown = false
a.SwitchStartedAt = time.Time{}
a.CurrentStreamURL = ""
a.mu.Unlock()
}
log.Printf("[admin] source set to %s", req.Source)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "source": req.Source})
}
// HandleSetChannel selects an IPTV channel and starts FFmpeg relay.
// HandleSetChannel selects an IPTV channel and points the hub at its upstream.
// POST /api/admin/channel {"channel_id": "..."}
func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -156,14 +104,10 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
return
}
// Start FFmpeg with the channel's stream URL
if err := a.FFmpeg.Start(found.StreamURL); err != nil {
http.Error(w, "failed to start stream: "+err.Error(), http.StatusInternalServerError)
return
}
// Point the shared fan-out hub at this channel's upstream URL.
a.Hub.SetChannel(found.StreamURL)
a.mu.Lock()
a.Status.Source = "iptv"
a.Status.ChannelName = found.Name
a.Status.Live = true
a.Status.Transitioning = true
@@ -232,20 +176,3 @@ func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "channels": count})
}
// HandleProcessStatus returns the status of managed processes.
// GET /api/admin/process/status
func (a *App) HandleProcessStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
status := models.SystemStatus{
MediaMTX: a.MediaMTX.Status(),
FFmpeg: a.FFmpeg.Status(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}