package api import ( "encoding/json" "log" "net/http" "sync" "time" "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 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. // GET /api/admin/channels?search=&group= 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.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. // 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.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}) } // 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) }