Tuner v1: HLS pipeline with playback/ingest modes
FFmpeg -> MediaMTX -> HLS relay with runtime-configurable modes: - PLAYBACK_MODE: standard (mpegts), fmp4 (HEVC), llhls - INGEST_MODE: rtmp, rtsp - INGEST_AUDIO_MODE: aac, copy - Hourly playlist auto-refresh from upstream URL - Fullscreen video UI with collapsible sidebar, upstream-down banner - Same-origin HLS proxy for HTTPS deployments Tagged as the last HLS-based version before the mpegts.js (v2) rework.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tuner/m3u"
|
||||
"tuner/models"
|
||||
@@ -13,12 +14,34 @@ import (
|
||||
|
||||
// App holds shared application state for all handlers.
|
||||
type App struct {
|
||||
mu sync.RWMutex
|
||||
Channels []models.Channel
|
||||
Status models.StreamStatus
|
||||
PlaylistPath string
|
||||
MediaMTX *process.MediaMTXManager
|
||||
FFmpeg *process.FFmpegManager
|
||||
mu sync.RWMutex
|
||||
Channels []models.Channel
|
||||
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
|
||||
}
|
||||
|
||||
// HandleConfig returns runtime client configuration as JSON.
|
||||
// GET /api/config
|
||||
func (a *App) HandleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
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})
|
||||
}
|
||||
|
||||
// HandleChannels lists, searches, and filters channels.
|
||||
@@ -89,6 +112,9 @@ func (a *App) HandleSetSource(w http.ResponseWriter, r *http.Request) {
|
||||
a.mu.Lock()
|
||||
a.Status.ChannelName = ""
|
||||
a.Status.Transitioning = false
|
||||
a.Status.UpstreamDown = false
|
||||
a.SwitchStartedAt = time.Time{}
|
||||
a.CurrentStreamURL = ""
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -141,6 +167,9 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
a.Status.ChannelName = found.Name
|
||||
a.Status.Live = true
|
||||
a.Status.Transitioning = true
|
||||
a.Status.UpstreamDown = false
|
||||
a.SwitchStartedAt = time.Now()
|
||||
a.CurrentStreamURL = found.StreamURL
|
||||
a.mu.Unlock()
|
||||
|
||||
log.Printf("[admin] channel set to %s (%s)", found.Name, found.ID)
|
||||
@@ -149,7 +178,42 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "channel": found.Name})
|
||||
}
|
||||
|
||||
// HandleReloadPlaylist re-parses the M3U playlist from disk.
|
||||
// ReloadPlaylist refreshes the playlist from its configured source, reparses it,
|
||||
// increments the playlist version, and remaps the active channel name when the
|
||||
// current stream URL still exists under a new display name.
|
||||
func (a *App) ReloadPlaylist() (int, error) {
|
||||
if a.PlaylistURL != "" {
|
||||
log.Printf("[playlist] re-fetching playlist from %s", a.PlaylistURL)
|
||||
if err := FetchPlaylist(a.PlaylistURL, a.PlaylistPath); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
channels, err := m3u.ParseFile(a.PlaylistPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.Channels = channels
|
||||
a.PlaylistVersion++
|
||||
a.Status.PlaylistVersion = a.PlaylistVersion
|
||||
|
||||
if a.CurrentStreamURL != "" {
|
||||
for _, ch := range channels {
|
||||
if ch.StreamURL == a.CurrentStreamURL {
|
||||
a.Status.ChannelName = ch.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return len(channels), nil
|
||||
}
|
||||
|
||||
// HandleReloadPlaylist re-fetches (if a URL is configured) and re-parses the M3U playlist.
|
||||
// POST /api/admin/playlist/reload
|
||||
func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -157,20 +221,16 @@ func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := m3u.ParseFile(a.PlaylistPath)
|
||||
count, err := a.ReloadPlaylist()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to reload playlist: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.Channels = channels
|
||||
a.mu.Unlock()
|
||||
|
||||
log.Printf("[admin] playlist reloaded: %d channels", len(channels))
|
||||
log.Printf("[admin] playlist reloaded: %d channels", count)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "channels": len(channels)})
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "channels": count})
|
||||
}
|
||||
|
||||
// HandleProcessStatus returns the status of managed processes.
|
||||
@@ -189,4 +249,3 @@ func (a *App) HandleProcessStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user