- Go backend: REST API, M3U parser, FFmpeg/MediaMTX process manager - React/Vite frontend: HLS player, admin panel, channel browser (dark theme) - MediaMTX config for RTMP ingest + HLS output - Multi-stage Dockerfile (Go + Bun + Alpine runtime) - docker-compose.yml for single-container deployment - Sample M3U playlist with test streams Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
190 lines
4.8 KiB
Go
190 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"tuner/m3u"
|
|
"tuner/models"
|
|
"tuner/process"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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.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.
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
a.mu.Lock()
|
|
a.Status.Source = "iptv"
|
|
a.Status.ChannelName = found.Name
|
|
a.Status.Live = true
|
|
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})
|
|
}
|
|
|
|
// HandleReloadPlaylist re-parses the M3U playlist from disk.
|
|
// 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
|
|
}
|
|
|
|
channels, err := m3u.ParseFile(a.PlaylistPath)
|
|
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))
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "channels": len(channels)})
|
|
}
|
|
|
|
// 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)
|
|
}
|