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=&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) } // 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}) }