Files
tuner/backend/main.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

190 lines
5.4 KiB
Go

package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"tuner/api"
"tuner/m3u"
"tuner/stream"
)
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func parseRefreshInterval(value string) time.Duration {
v := strings.ToLower(strings.TrimSpace(value))
if v == "" || v == "0" || v == "off" || v == "false" || v == "disabled" {
return 0
}
d, err := time.ParseDuration(v)
if err != nil {
log.Printf("[startup] warning: invalid PLAYLIST_AUTO_REFRESH_INTERVAL=%q, using 1h", value)
return time.Hour
}
return d
}
// copyFile copies src to dst, creating dst if it doesn't exist.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
if err := os.MkdirAll("/data", 0o755); err != nil {
return fmt.Errorf("mkdir /data: %w", err)
}
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dest: %w", err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy: %w", err)
}
return nil
}
func main() {
playlistPath := getEnv("PLAYLIST_PATH", "/data/playlist.m3u")
port := getEnv("RESTREAMER_PORT", "8080")
playlistURL := getEnv("PLAYLIST_URL", "")
useLocal := getEnv("PLAYLIST_USE_LOCAL", "false") == "true"
playlistRefreshInterval := parseRefreshInterval(getEnv("PLAYLIST_AUTO_REFRESH_INTERVAL", "1h"))
localSrcPath := getEnv("PLAYLIST_LOCAL_SRC", "/data/playlist.m3u.local")
// Fetch playlist from URL unless PLAYLIST_USE_LOCAL=true
if !useLocal && playlistURL != "" {
if err := api.FetchPlaylist(playlistURL, playlistPath); err != nil {
log.Printf("[startup] warning: could not fetch playlist from URL: %v", err)
}
} else if useLocal {
log.Printf("[startup] PLAYLIST_USE_LOCAL=true, copying %s -> %s", localSrcPath, playlistPath)
if err := copyFile(localSrcPath, playlistPath); err != nil {
log.Printf("[startup] warning: could not copy local playlist: %v", err)
}
}
// Single shared upstream fan-out hub.
hub := stream.NewHub()
// Build shared app state
app := &api.App{
PlaylistPath: playlistPath,
Hub: hub,
}
// Only expose the URL for reloads when we're in URL mode
if !useLocal && playlistURL != "" {
app.PlaylistURL = playlistURL
}
// Load playlist (warn but don't crash if missing)
channels, err := m3u.ParseFile(playlistPath)
if err != nil {
log.Printf("[startup] warning: could not load playlist %s: %v", playlistPath, err)
} else {
app.Channels = channels
app.PlaylistVersion = 1
app.Status.PlaylistVersion = app.PlaylistVersion
log.Printf("[startup] loaded %d channels from %s", len(channels), playlistPath)
}
if playlistRefreshInterval > 0 {
log.Printf("[startup] playlist auto-refresh enabled: %s", playlistRefreshInterval)
go func() {
ticker := time.NewTicker(playlistRefreshInterval)
defer ticker.Stop()
for range ticker.C {
count, err := app.ReloadPlaylist()
if err != nil {
log.Printf("[playlist] auto-refresh failed: %v", err)
continue
}
log.Printf("[playlist] auto-refreshed: %d channels", count)
}
}()
} else {
log.Printf("[startup] playlist auto-refresh disabled")
}
// Register routes
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/config", app.HandleConfig)
mux.HandleFunc("/api/status", app.HandleStatus)
mux.HandleFunc("/api/admin/channels", app.HandleChannels)
mux.HandleFunc("/api/admin/groups", app.HandleGroups)
mux.HandleFunc("/api/admin/channel", app.HandleSetChannel)
mux.HandleFunc("/api/admin/playlist/reload", app.HandleReloadPlaylist)
// Raw MPEG-TS fan-out endpoint: every viewer streams the shared channel here.
mux.HandleFunc("/ts", app.HandleTS)
// Serve frontend static files (falls back to index.html for SPA routing)
frontendDir := getEnv("FRONTEND_DIR", "frontend/dist")
if info, err := os.Stat(frontendDir); err == nil && info.IsDir() {
fs := http.FileServer(http.Dir(frontendDir))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to serve the file; if it doesn't exist, serve index.html (SPA fallback)
path := frontendDir + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) && r.URL.Path != "/" {
http.ServeFile(w, r, frontendDir+"/index.html")
return
}
fs.ServeHTTP(w, r)
}))
log.Printf("[startup] serving frontend from %s", frontendDir)
} else {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("Tuner backend running. Frontend not found."))
})
log.Printf("[startup] frontend dir %s not found, serving placeholder", frontendDir)
}
// Wrap with middleware
handler := api.Logging(api.CORS(mux))
server := &http.Server{
Addr: ":" + port,
Handler: handler,
}
// Graceful shutdown
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("[shutdown] received %v, shutting down...", sig)
hub.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("[shutdown] http server shutdown error: %v", err)
}
}()
log.Printf("[startup] listening on :%s", port)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("[fatal] server error: %v", err)
}
log.Println("[shutdown] complete")
}