Implement Tuner v1 — Go backend, React frontend, Docker setup
- 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>
This commit is contained in:
189
backend/api/admin.go
Normal file
189
backend/api/admin.go
Normal file
@@ -0,0 +1,189 @@
|
||||
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)
|
||||
}
|
||||
32
backend/api/middleware.go
Normal file
32
backend/api/middleware.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CORS wraps a handler with permissive CORS headers (allow all origins for MVP).
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Logging wraps a handler with request logging (method, path, duration).
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Truncate(time.Microsecond))
|
||||
})
|
||||
}
|
||||
22
backend/api/status.go
Normal file
22
backend/api/status.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleStatus returns the current stream status as JSON.
|
||||
// GET /api/status
|
||||
func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
status := a.Status
|
||||
a.mu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
Reference in New Issue
Block a user