Files
tuner/backend/api/admin.go
Scott Register 723ae1f1ac 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.
2026-06-12 19:33:06 -07:00

179 lines
4.6 KiB
Go

package api
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"tuner/m3u"
"tuner/models"
"tuner/stream"
)
// App holds shared application state for all handlers.
type App struct {
mu sync.RWMutex
Channels []models.Channel
Status models.StreamStatus
PlaylistPath string
PlaylistURL string // if set, reload fetches from this URL before parsing
SwitchStartedAt time.Time
CurrentStreamURL string
PlaylistVersion int64
Hub *stream.Hub
}
// 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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"player": "mpegts"})
}
// HandleChannels lists, searches, and filters channels.
// GET /api/admin/channels?search=<q>&group=<g>
func (a *App) HandleChannels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
search := r.URL.Query().Get("search")
group := r.URL.Query().Get("group")
a.mu.RLock()
channels := m3u.FilterChannels(a.Channels, search, group)
a.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(channels)
}
// HandleGroups returns the list of channel groups.
// GET /api/admin/groups
func (a *App) HandleGroups(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
a.mu.RLock()
groups := m3u.GetGroups(a.Channels)
a.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(groups)
}
// 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 {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ChannelID string `json:"channel_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
// Find the channel
a.mu.RLock()
var found *models.Channel
for i := range a.Channels {
if a.Channels[i].ID == req.ChannelID {
found = &a.Channels[i]
break
}
}
a.mu.RUnlock()
if found == nil {
http.Error(w, "channel not found", http.StatusNotFound)
return
}
// Point the shared fan-out hub at this channel's upstream URL.
a.Hub.SetChannel(found.StreamURL)
a.mu.Lock()
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)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "channel": found.Name})
}
// 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 {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
count, err := a.ReloadPlaylist()
if err != nil {
http.Error(w, "failed to reload playlist: "+err.Error(), http.StatusInternalServerError)
return
}
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": count})
}