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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -20,29 +20,22 @@ func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := a.Status
|
||||
a.mu.RUnlock()
|
||||
|
||||
// Attach live stream health metrics when FFmpeg is running
|
||||
status.Health = a.FFmpeg.Health()
|
||||
|
||||
// Auto-clear transitioning once FFmpeg is producing health data.
|
||||
// If no progress arrives for a while after a channel switch, flag upstream_down
|
||||
// so all watchers see an "Upstream down?" warning via polling.
|
||||
if status.Transitioning && status.Health != nil && status.Health.Speed != "" {
|
||||
// Detect upstream health from the hub. Once the hub receives data, the
|
||||
// channel is considered live; if no data arrives within the threshold
|
||||
// after a switch, flag upstream_down so all watchers see the warning.
|
||||
gotData, since := a.Hub.GotData()
|
||||
if status.Transitioning && gotData {
|
||||
a.mu.Lock()
|
||||
a.Status.Transitioning = false
|
||||
a.Status.UpstreamDown = false
|
||||
a.mu.Unlock()
|
||||
status.Transitioning = false
|
||||
status.UpstreamDown = false
|
||||
} else if status.Transitioning {
|
||||
a.mu.RLock()
|
||||
switchStartedAt := a.SwitchStartedAt
|
||||
a.mu.RUnlock()
|
||||
if !switchStartedAt.IsZero() && time.Since(switchStartedAt) >= upstreamDownAfter {
|
||||
a.mu.Lock()
|
||||
a.Status.UpstreamDown = true
|
||||
a.mu.Unlock()
|
||||
status.UpstreamDown = true
|
||||
}
|
||||
} else if status.Transitioning && !gotData && since >= upstreamDownAfter {
|
||||
a.mu.Lock()
|
||||
a.Status.UpstreamDown = true
|
||||
a.mu.Unlock()
|
||||
status.UpstreamDown = true
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
45
backend/api/ts.go
Normal file
45
backend/api/ts.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleTS streams the shared channel's raw MPEG-TS bytes to a viewer.
|
||||
// Every connected browser subscribes to the same upstream via the hub, so the
|
||||
// server holds only one connection to the IPTV source regardless of how many
|
||||
// clients are watching.
|
||||
// GET /ts
|
||||
func (a *App) HandleTS(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
sub, unsub := a.Hub.Subscribe()
|
||||
defer unsub()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sub.Done():
|
||||
return
|
||||
case chunk, ok := <-sub.Chan():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(chunk); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user