Compare commits
6 Commits
b8bfcefee8
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 03296e7572 | |||
| 723ae1f1ac | |||
| 88a189de34 | |||
| a41c3ee56c | |||
| a2df93097a | |||
| 6bd80a0b3a |
15
Dockerfile
15
Dockerfile
@@ -12,24 +12,19 @@ RUN bun install && bun run build
|
|||||||
|
|
||||||
# Stage 3: Runtime
|
# Stage 3: Runtime
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache ffmpeg ca-certificates wget tar \
|
RUN apk add --no-cache ca-certificates
|
||||||
&& wget -qO /tmp/mediamtx.tar.gz \
|
|
||||||
https://github.com/bluenviron/mediamtx/releases/download/v1.12.2/mediamtx_v1.12.2_linux_amd64.tar.gz \
|
|
||||||
&& tar -xzf /tmp/mediamtx.tar.gz -C /usr/local/bin mediamtx \
|
|
||||||
&& rm /tmp/mediamtx.tar.gz \
|
|
||||||
&& apk del wget tar
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend /out/tuner /app/tuner
|
COPY --from=backend /out/tuner /app/tuner
|
||||||
COPY --from=frontend /src/dist /app/frontend/dist
|
COPY --from=frontend /src/dist /app/frontend/dist
|
||||||
COPY mediamtx.yml /app/mediamtx.yml
|
|
||||||
|
|
||||||
ENV RESTREAMER_PORT=8080
|
ENV RESTREAMER_PORT=8080
|
||||||
ENV PLAYLIST_PATH=/data/playlist.m3u
|
ENV PLAYLIST_PATH=/data/playlist.m3u
|
||||||
ENV MEDIAMTX_PATH=/usr/local/bin/mediamtx
|
ENV PLAYLIST_URL=https://pia.cx/m3u/630xhAmY/smp424GL
|
||||||
ENV MEDIAMTX_CONFIG=/app/mediamtx.yml
|
ENV PLAYLIST_USE_LOCAL=false
|
||||||
|
ENV PLAYLIST_AUTO_REFRESH_INTERVAL=1h
|
||||||
ENV FRONTEND_DIR=/app/frontend/dist
|
ENV FRONTEND_DIR=/app/frontend/dist
|
||||||
|
|
||||||
EXPOSE 1935 8080 8888
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["/app/tuner"]
|
ENTRYPOINT ["/app/tuner"]
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"tuner/m3u"
|
"tuner/m3u"
|
||||||
"tuner/models"
|
"tuner/models"
|
||||||
"tuner/process"
|
"tuner/stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App holds shared application state for all handlers.
|
// App holds shared application state for all handlers.
|
||||||
@@ -17,8 +18,23 @@ type App struct {
|
|||||||
Channels []models.Channel
|
Channels []models.Channel
|
||||||
Status models.StreamStatus
|
Status models.StreamStatus
|
||||||
PlaylistPath string
|
PlaylistPath string
|
||||||
MediaMTX *process.MediaMTXManager
|
PlaylistURL string // if set, reload fetches from this URL before parsing
|
||||||
FFmpeg *process.FFmpegManager
|
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.
|
// HandleChannels lists, searches, and filters channels.
|
||||||
@@ -56,48 +72,7 @@ func (a *App) HandleGroups(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(groups)
|
json.NewEncoder(w).Encode(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSetSource sets the active source (obs or iptv).
|
// HandleSetChannel selects an IPTV channel and points the hub at its upstream.
|
||||||
// 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": "..."}
|
// POST /api/admin/channel {"channel_id": "..."}
|
||||||
func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -129,16 +104,16 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start FFmpeg with the channel's stream URL
|
// Point the shared fan-out hub at this channel's upstream URL.
|
||||||
if err := a.FFmpeg.Start(found.StreamURL); err != nil {
|
a.Hub.SetChannel(found.StreamURL)
|
||||||
http.Error(w, "failed to start stream: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.Status.Source = "iptv"
|
|
||||||
a.Status.ChannelName = found.Name
|
a.Status.ChannelName = found.Name
|
||||||
a.Status.Live = true
|
a.Status.Live = true
|
||||||
|
a.Status.Transitioning = true
|
||||||
|
a.Status.UpstreamDown = false
|
||||||
|
a.SwitchStartedAt = time.Now()
|
||||||
|
a.CurrentStreamURL = found.StreamURL
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
log.Printf("[admin] channel set to %s (%s)", found.Name, found.ID)
|
log.Printf("[admin] channel set to %s (%s)", found.Name, found.ID)
|
||||||
@@ -147,7 +122,42 @@ func (a *App) HandleSetChannel(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "channel": found.Name})
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "channel": found.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleReloadPlaylist re-parses the M3U playlist from disk.
|
// 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
|
// POST /api/admin/playlist/reload
|
||||||
func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
|
func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -155,35 +165,14 @@ func (a *App) HandleReloadPlaylist(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
channels, err := m3u.ParseFile(a.PlaylistPath)
|
count, err := a.ReloadPlaylist()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "failed to reload playlist: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "failed to reload playlist: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mu.Lock()
|
log.Printf("[admin] playlist reloaded: %d channels", count)
|
||||||
a.Channels = channels
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
log.Printf("[admin] playlist reloaded: %d channels", len(channels))
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "channels": len(channels)})
|
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
35
backend/api/playlist.go
Normal file
35
backend/api/playlist.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchPlaylist downloads a playlist from url and writes it to destPath.
|
||||||
|
func FetchPlaylist(url, destPath string) error {
|
||||||
|
log.Printf("[playlist] fetching from %s", url)
|
||||||
|
resp, err := http.Get(url) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch playlist: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("fetch playlist: server returned %s", resp.Status)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("/data", 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir /data: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create playlist file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("write playlist file: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[playlist] saved to %s", destPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const upstreamDownAfter = 10 * time.Second
|
||||||
|
|
||||||
// HandleStatus returns the current stream status as JSON.
|
// HandleStatus returns the current stream status as JSON.
|
||||||
// GET /api/status
|
// GET /api/status
|
||||||
func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -17,6 +20,24 @@ func (a *App) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
status := a.Status
|
status := a.Status
|
||||||
a.mu.RUnlock()
|
a.mu.RUnlock()
|
||||||
|
|
||||||
|
// Detect upstream health from the hub. Once the hub receives data, the
|
||||||
|
// channel is considered live; if no data arrives within the threshold
|
||||||
|
// after a switch, flag upstream_down so all watchers see the warning.
|
||||||
|
gotData, since := a.Hub.GotData()
|
||||||
|
if status.Transitioning && gotData {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.Status.Transitioning = false
|
||||||
|
a.Status.UpstreamDown = false
|
||||||
|
a.mu.Unlock()
|
||||||
|
status.Transitioning = false
|
||||||
|
status.UpstreamDown = false
|
||||||
|
} else if status.Transitioning && !gotData && since >= upstreamDownAfter {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.Status.UpstreamDown = true
|
||||||
|
a.mu.Unlock()
|
||||||
|
status.UpstreamDown = true
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(status)
|
json.NewEncoder(w).Encode(status)
|
||||||
}
|
}
|
||||||
|
|||||||
48
backend/api/ts.go
Normal file
48
backend/api/ts.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleTS streams the shared channel's raw MPEG-TS bytes to a viewer.
|
||||||
|
// Every connected browser subscribes to the same upstream via the hub, so the
|
||||||
|
// server holds only one connection to the IPTV source regardless of how many
|
||||||
|
// clients are watching.
|
||||||
|
// GET /ts
|
||||||
|
func (a *App) HandleTS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "video/mp2t")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
sub, unsub := a.Hub.Subscribe()
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-sub.Done():
|
||||||
|
return
|
||||||
|
case <-sub.Overflow():
|
||||||
|
// Client fell too far behind; close so it reconnects and resyncs.
|
||||||
|
return
|
||||||
|
case chunk, ok := <-sub.Chan():
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := w.Write(chunk); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,13 +105,17 @@ func generateID(name, url string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FilterChannels returns channels matching the given search term and/or group.
|
// FilterChannels returns channels matching the given search term and/or group.
|
||||||
|
// Always returns a non-nil slice so it encodes as [] rather than null in JSON.
|
||||||
func FilterChannels(channels []models.Channel, search string, group string) []models.Channel {
|
func FilterChannels(channels []models.Channel, search string, group string) []models.Channel {
|
||||||
if search == "" && group == "" {
|
if search == "" && group == "" {
|
||||||
|
if channels == nil {
|
||||||
|
return []models.Channel{}
|
||||||
|
}
|
||||||
return channels
|
return channels
|
||||||
}
|
}
|
||||||
|
|
||||||
searchLower := strings.ToLower(search)
|
searchLower := strings.ToLower(search)
|
||||||
var result []models.Channel
|
result := []models.Channel{}
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
if group != "" && !strings.EqualFold(ch.Group, group) {
|
if group != "" && !strings.EqualFold(ch.Group, group) {
|
||||||
@@ -127,9 +131,10 @@ func FilterChannels(channels []models.Channel, search string, group string) []mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetGroups returns a deduplicated, sorted list of group names from the channel list.
|
// GetGroups returns a deduplicated, sorted list of group names from the channel list.
|
||||||
|
// Always returns a non-nil slice so it encodes as [] rather than null in JSON.
|
||||||
func GetGroups(channels []models.Channel) []string {
|
func GetGroups(channels []models.Channel) []string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var groups []string
|
groups := []string{}
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
if ch.Group != "" && !seen[ch.Group] {
|
if ch.Group != "" && !seen[ch.Group] {
|
||||||
|
|||||||
105
backend/main.go
105
backend/main.go
@@ -2,16 +2,19 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tuner/api"
|
"tuner/api"
|
||||||
"tuner/m3u"
|
"tuner/m3u"
|
||||||
"tuner/process"
|
"tuner/stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
@@ -21,21 +24,72 @@ func getEnv(key, fallback string) string {
|
|||||||
return fallback
|
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() {
|
func main() {
|
||||||
playlistPath := getEnv("PLAYLIST_PATH", "/data/playlist.m3u")
|
playlistPath := getEnv("PLAYLIST_PATH", "/data/playlist.m3u")
|
||||||
mediamtxBin := getEnv("MEDIAMTX_PATH", "mediamtx")
|
|
||||||
mediamtxCfg := getEnv("MEDIAMTX_CONFIG", "mediamtx.yml")
|
|
||||||
port := getEnv("RESTREAMER_PORT", "8080")
|
port := getEnv("RESTREAMER_PORT", "8080")
|
||||||
|
playlistURL := getEnv("PLAYLIST_URL", "")
|
||||||
|
useLocal := getEnv("PLAYLIST_USE_LOCAL", "false") == "true"
|
||||||
|
playlistRefreshInterval := parseRefreshInterval(getEnv("PLAYLIST_AUTO_REFRESH_INTERVAL", "1h"))
|
||||||
|
|
||||||
// Initialize process managers
|
localSrcPath := getEnv("PLAYLIST_LOCAL_SRC", "/data/playlist.m3u.local")
|
||||||
mtxManager := process.NewMediaMTXManager(mediamtxBin, mediamtxCfg)
|
|
||||||
ffmpegManager := process.NewFFmpegManager()
|
// 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
|
// Build shared app state
|
||||||
app := &api.App{
|
app := &api.App{
|
||||||
PlaylistPath: playlistPath,
|
PlaylistPath: playlistPath,
|
||||||
MediaMTX: mtxManager,
|
Hub: hub,
|
||||||
FFmpeg: ffmpegManager,
|
}
|
||||||
|
// 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)
|
// Load playlist (warn but don't crash if missing)
|
||||||
@@ -44,25 +98,42 @@ func main() {
|
|||||||
log.Printf("[startup] warning: could not load playlist %s: %v", playlistPath, err)
|
log.Printf("[startup] warning: could not load playlist %s: %v", playlistPath, err)
|
||||||
} else {
|
} else {
|
||||||
app.Channels = channels
|
app.Channels = channels
|
||||||
|
app.PlaylistVersion = 1
|
||||||
|
app.Status.PlaylistVersion = app.PlaylistVersion
|
||||||
log.Printf("[startup] loaded %d channels from %s", len(channels), playlistPath)
|
log.Printf("[startup] loaded %d channels from %s", len(channels), playlistPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start MediaMTX (warn but don't crash if binary not found)
|
if playlistRefreshInterval > 0 {
|
||||||
if err := mtxManager.Start(); err != nil {
|
log.Printf("[startup] playlist auto-refresh enabled: %s", playlistRefreshInterval)
|
||||||
log.Printf("[startup] warning: could not start mediamtx: %v", err)
|
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
|
// Register routes
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
mux.HandleFunc("/api/config", app.HandleConfig)
|
||||||
mux.HandleFunc("/api/status", app.HandleStatus)
|
mux.HandleFunc("/api/status", app.HandleStatus)
|
||||||
mux.HandleFunc("/api/admin/channels", app.HandleChannels)
|
mux.HandleFunc("/api/admin/channels", app.HandleChannels)
|
||||||
mux.HandleFunc("/api/admin/groups", app.HandleGroups)
|
mux.HandleFunc("/api/admin/groups", app.HandleGroups)
|
||||||
mux.HandleFunc("/api/admin/source", app.HandleSetSource)
|
|
||||||
mux.HandleFunc("/api/admin/channel", app.HandleSetChannel)
|
mux.HandleFunc("/api/admin/channel", app.HandleSetChannel)
|
||||||
mux.HandleFunc("/api/admin/playlist/reload", app.HandleReloadPlaylist)
|
mux.HandleFunc("/api/admin/playlist/reload", app.HandleReloadPlaylist)
|
||||||
mux.HandleFunc("/api/admin/process/status", app.HandleProcessStatus)
|
|
||||||
|
// 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)
|
// Serve frontend static files (falls back to index.html for SPA routing)
|
||||||
frontendDir := getEnv("FRONTEND_DIR", "frontend/dist")
|
frontendDir := getEnv("FRONTEND_DIR", "frontend/dist")
|
||||||
@@ -101,13 +172,7 @@ func main() {
|
|||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
log.Printf("[shutdown] received %v, shutting down...", sig)
|
log.Printf("[shutdown] received %v, shutting down...", sig)
|
||||||
|
|
||||||
// Stop child processes
|
hub.Stop()
|
||||||
if err := ffmpegManager.Stop(); err != nil {
|
|
||||||
log.Printf("[shutdown] error stopping ffmpeg: %v", err)
|
|
||||||
}
|
|
||||||
if err := mtxManager.Stop(); err != nil {
|
|
||||||
log.Printf("[shutdown] error stopping mediamtx: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -11,21 +11,9 @@ type Channel struct {
|
|||||||
|
|
||||||
// StreamStatus represents the current streaming state.
|
// StreamStatus represents the current streaming state.
|
||||||
type StreamStatus struct {
|
type StreamStatus struct {
|
||||||
Source string `json:"source"` // "obs" or "iptv"
|
ChannelName string `json:"channel_name"` // current IPTV channel name
|
||||||
ChannelName string `json:"channel_name"` // current IPTV channel name (empty if OBS)
|
Live bool `json:"live"` // true when a channel is selected
|
||||||
Live bool `json:"live"`
|
Transitioning bool `json:"transitioning"` // true while switching to a new channel
|
||||||
}
|
UpstreamDown bool `json:"upstream_down"` // true when no data is detected from the selected upstream
|
||||||
|
PlaylistVersion int64 `json:"playlist_version"` // increments when playlist reloads
|
||||||
// ProcessInfo represents the status of a managed child process.
|
|
||||||
type ProcessInfo struct {
|
|
||||||
Running bool `json:"running"`
|
|
||||||
PID int `json:"pid"`
|
|
||||||
Uptime string `json:"uptime"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemStatus holds status for all managed processes.
|
|
||||||
type SystemStatus struct {
|
|
||||||
MediaMTX ProcessInfo `json:"mediamtx"`
|
|
||||||
FFmpeg ProcessInfo `json:"ffmpeg"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package process
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os/exec"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tuner/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FFmpegManager manages an FFmpeg child process for IPTV relay.
|
|
||||||
type FFmpegManager struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
cmd *exec.Cmd
|
|
||||||
startTime time.Time
|
|
||||||
lastError string
|
|
||||||
streamURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFFmpegManager creates a new FFmpeg process manager.
|
|
||||||
func NewFFmpegManager() *FFmpegManager {
|
|
||||||
return &FFmpegManager{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start spawns an FFmpeg process to pull from streamURL and push RTMP to MediaMTX.
|
|
||||||
// It kills any existing FFmpeg process first.
|
|
||||||
func (f *FFmpegManager) Start(streamURL string) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
|
|
||||||
// Kill existing process if running
|
|
||||||
f.stopLocked()
|
|
||||||
|
|
||||||
f.cmd = exec.Command("ffmpeg",
|
|
||||||
"-re",
|
|
||||||
"-i", streamURL,
|
|
||||||
"-c", "copy",
|
|
||||||
"-f", "flv",
|
|
||||||
"rtmp://localhost:1935/live/stream",
|
|
||||||
)
|
|
||||||
f.streamURL = streamURL
|
|
||||||
f.lastError = ""
|
|
||||||
|
|
||||||
if err := f.cmd.Start(); err != nil {
|
|
||||||
f.lastError = err.Error()
|
|
||||||
return fmt.Errorf("start ffmpeg: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.startTime = time.Now()
|
|
||||||
log.Printf("[ffmpeg] started with pid %d, source: %s", f.cmd.Process.Pid, streamURL)
|
|
||||||
|
|
||||||
// Monitor in background
|
|
||||||
go f.monitor()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FFmpegManager) monitor() {
|
|
||||||
err := f.cmd.Wait()
|
|
||||||
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
f.lastError = err.Error()
|
|
||||||
log.Printf("[ffmpeg] exited: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("[ffmpeg] exited (status 0)")
|
|
||||||
}
|
|
||||||
|
|
||||||
f.cmd = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop kills the FFmpeg process.
|
|
||||||
func (f *FFmpegManager) Stop() error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
return f.stopLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FFmpegManager) stopLocked() error {
|
|
||||||
if f.cmd == nil || f.cmd.Process == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := f.cmd.Process.Kill(); err != nil {
|
|
||||||
return fmt.Errorf("kill ffmpeg: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the process to fully exit to avoid zombies
|
|
||||||
_ = f.cmd.Wait()
|
|
||||||
f.cmd = nil
|
|
||||||
log.Println("[ffmpeg] killed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status returns the current FFmpeg process status.
|
|
||||||
func (f *FFmpegManager) Status() models.ProcessInfo {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
|
|
||||||
info := models.ProcessInfo{
|
|
||||||
Error: f.lastError,
|
|
||||||
}
|
|
||||||
|
|
||||||
if f.cmd != nil && f.cmd.Process != nil {
|
|
||||||
info.Running = true
|
|
||||||
info.PID = f.cmd.Process.Pid
|
|
||||||
info.Uptime = time.Since(f.startTime).Truncate(time.Second).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package process
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os/exec"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tuner/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MediaMTXManager manages the MediaMTX child process.
|
|
||||||
type MediaMTXManager struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
cmd *exec.Cmd
|
|
||||||
binaryPath string
|
|
||||||
configPath string
|
|
||||||
startTime time.Time
|
|
||||||
lastError string
|
|
||||||
stopCh chan struct{}
|
|
||||||
stopped bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMediaMTXManager creates a new manager for the MediaMTX process.
|
|
||||||
func NewMediaMTXManager(binaryPath, configPath string) *MediaMTXManager {
|
|
||||||
return &MediaMTXManager{
|
|
||||||
binaryPath: binaryPath,
|
|
||||||
configPath: configPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start launches the MediaMTX process. It auto-restarts on unexpected exit.
|
|
||||||
func (m *MediaMTXManager) Start() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.cmd != nil && m.cmd.Process != nil {
|
|
||||||
return fmt.Errorf("mediamtx already running (pid %d)", m.cmd.Process.Pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.startLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MediaMTXManager) startLocked() error {
|
|
||||||
_, err := exec.LookPath(m.binaryPath)
|
|
||||||
if err != nil {
|
|
||||||
m.lastError = fmt.Sprintf("binary not found: %s", m.binaryPath)
|
|
||||||
return fmt.Errorf("mediamtx binary not found: %s", m.binaryPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cmd = exec.Command(m.binaryPath, m.configPath)
|
|
||||||
m.lastError = ""
|
|
||||||
m.stopped = false
|
|
||||||
m.stopCh = make(chan struct{})
|
|
||||||
|
|
||||||
if err := m.cmd.Start(); err != nil {
|
|
||||||
m.lastError = err.Error()
|
|
||||||
return fmt.Errorf("start mediamtx: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.startTime = time.Now()
|
|
||||||
log.Printf("[mediamtx] started with pid %d", m.cmd.Process.Pid)
|
|
||||||
|
|
||||||
// Monitor in background and auto-restart on unexpected exit
|
|
||||||
go m.monitor()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MediaMTXManager) monitor() {
|
|
||||||
err := m.cmd.Wait()
|
|
||||||
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
// Check if this was a deliberate stop
|
|
||||||
select {
|
|
||||||
case <-m.stopCh:
|
|
||||||
log.Println("[mediamtx] stopped")
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
m.lastError = err.Error()
|
|
||||||
log.Printf("[mediamtx] exited unexpectedly: %v — restarting in 2s", err)
|
|
||||||
} else {
|
|
||||||
log.Println("[mediamtx] exited unexpectedly (status 0) — restarting in 2s")
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cmd = nil
|
|
||||||
|
|
||||||
// Auto-restart after a brief delay (without lock held)
|
|
||||||
go func() {
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.stopped {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if m.cmd != nil {
|
|
||||||
return // already restarted
|
|
||||||
}
|
|
||||||
if err := m.startLocked(); err != nil {
|
|
||||||
log.Printf("[mediamtx] auto-restart failed: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop kills the MediaMTX process.
|
|
||||||
func (m *MediaMTXManager) Stop() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
m.stopped = true
|
|
||||||
|
|
||||||
if m.cmd == nil || m.cmd.Process == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.stopCh != nil {
|
|
||||||
close(m.stopCh)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.cmd.Process.Kill(); err != nil {
|
|
||||||
return fmt.Errorf("kill mediamtx: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cmd = nil
|
|
||||||
log.Println("[mediamtx] killed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart stops and restarts the MediaMTX process.
|
|
||||||
func (m *MediaMTXManager) Restart() error {
|
|
||||||
if err := m.Stop(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
return m.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status returns the current process status.
|
|
||||||
func (m *MediaMTXManager) Status() models.ProcessInfo {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
info := models.ProcessInfo{
|
|
||||||
Error: m.lastError,
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.cmd != nil && m.cmd.Process != nil {
|
|
||||||
info.Running = true
|
|
||||||
info.PID = m.cmd.Process.Pid
|
|
||||||
info.Uptime = time.Since(m.startTime).Truncate(time.Second).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
258
backend/stream/hub.go
Normal file
258
backend/stream/hub.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// Package stream implements a single-upstream, many-client fan-out hub for
|
||||||
|
// raw MPEG-TS streams. Only one connection is ever opened to the upstream IPTV
|
||||||
|
// source at a time; all connected browsers receive a copy of the same bytes.
|
||||||
|
package stream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chunkSize is the read buffer size for pulling from the upstream source.
|
||||||
|
const chunkSize = 64 * 1024
|
||||||
|
|
||||||
|
// subscriberBuffer is how many chunks may queue per client before it is
|
||||||
|
// considered too slow. At ~64KB/chunk this is a large cushion so transient
|
||||||
|
// MSE backpressure never overflows for a normal viewer.
|
||||||
|
const subscriberBuffer = 2048
|
||||||
|
|
||||||
|
// Subscriber receives copies of upstream TS chunks.
|
||||||
|
type subscriber struct {
|
||||||
|
ch chan []byte
|
||||||
|
closed chan struct{}
|
||||||
|
overflow chan struct{} // closed by the hub when the client falls too far behind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub manages a single shared upstream connection and fans its bytes out to
|
||||||
|
// any number of subscribers. Switching channels tears down the current
|
||||||
|
// upstream and starts a new one.
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
streamURL string
|
||||||
|
cancel context.CancelFunc
|
||||||
|
subscribers map[*subscriber]struct{}
|
||||||
|
running bool
|
||||||
|
|
||||||
|
// gotData is closed once the first upstream bytes arrive after a switch;
|
||||||
|
// used to detect upstream-down conditions.
|
||||||
|
gotData bool
|
||||||
|
startedAt time.Time
|
||||||
|
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates an empty hub.
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
subscribers: make(map[*subscriber]struct{}),
|
||||||
|
client: &http.Client{
|
||||||
|
// No overall timeout: this is a long-lived stream. Per-attempt
|
||||||
|
// dial/response-header timeouts are set on the transport.
|
||||||
|
Transport: &http.Transport{
|
||||||
|
ResponseHeaderTimeout: 15 * time.Second,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannel switches the shared upstream to streamURL. If the same URL is
|
||||||
|
// already active, it is a no-op. An empty URL stops streaming.
|
||||||
|
func (h *Hub) SetChannel(streamURL string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
if streamURL == h.streamURL && h.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear down existing upstream
|
||||||
|
h.stopLocked()
|
||||||
|
|
||||||
|
h.streamURL = streamURL
|
||||||
|
if streamURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
h.cancel = cancel
|
||||||
|
h.running = true
|
||||||
|
h.gotData = false
|
||||||
|
h.startedAt = time.Now()
|
||||||
|
|
||||||
|
go h.run(ctx, streamURL)
|
||||||
|
log.Printf("[hub] switched upstream to %s", streamURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the upstream connection and clears the active channel.
|
||||||
|
func (h *Hub) Stop() {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.streamURL = ""
|
||||||
|
h.stopLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) stopLocked() {
|
||||||
|
if h.cancel != nil {
|
||||||
|
h.cancel()
|
||||||
|
h.cancel = nil
|
||||||
|
}
|
||||||
|
h.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotData reports whether upstream bytes have been received since the last
|
||||||
|
// channel switch, plus how long ago the switch occurred.
|
||||||
|
func (h *Hub) GotData() (bool, time.Duration) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if !h.running {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
return h.gotData, time.Since(h.startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a new client. Returns a subscriber and an unsubscribe
|
||||||
|
// function the caller must invoke when done.
|
||||||
|
func (h *Hub) Subscribe() (*subscriber, func()) {
|
||||||
|
sub := &subscriber{
|
||||||
|
ch: make(chan []byte, subscriberBuffer),
|
||||||
|
closed: make(chan struct{}),
|
||||||
|
overflow: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.subscribers[sub] = struct{}{}
|
||||||
|
count := len(h.subscribers)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[hub] client subscribed (%d total)", count)
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
unsub := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.subscribers, sub)
|
||||||
|
remaining := len(h.subscribers)
|
||||||
|
h.mu.Unlock()
|
||||||
|
close(sub.closed)
|
||||||
|
log.Printf("[hub] client unsubscribed (%d remaining)", remaining)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sub, unsub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chan exposes the subscriber's chunk channel.
|
||||||
|
func (s *subscriber) Chan() <-chan []byte { return s.ch }
|
||||||
|
|
||||||
|
// Closed signals the subscriber has been removed by the hub.
|
||||||
|
func (s *subscriber) Done() <-chan struct{} { return s.closed }
|
||||||
|
|
||||||
|
// Overflow signals the client fell too far behind and must reconnect. We never
|
||||||
|
// drop bytes mid-stream (that corrupts TS packet alignment); instead we close
|
||||||
|
// the connection so the client reconnects and resyncs from a packet boundary.
|
||||||
|
func (s *subscriber) Overflow() <-chan struct{} { return s.overflow }
|
||||||
|
|
||||||
|
// broadcast sends a chunk to all subscribers. A subscriber whose buffer is full
|
||||||
|
// is flagged for disconnect rather than having bytes silently dropped.
|
||||||
|
func (h *Hub) broadcast(chunk []byte) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if !h.gotData {
|
||||||
|
h.gotData = true
|
||||||
|
}
|
||||||
|
subs := make([]*subscriber, 0, len(h.subscribers))
|
||||||
|
for s := range h.subscribers {
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, s := range subs {
|
||||||
|
select {
|
||||||
|
case s.ch <- chunk:
|
||||||
|
default:
|
||||||
|
// Buffer full: signal overflow once so the handler closes the
|
||||||
|
// connection cleanly. Never drop bytes — that breaks TS alignment.
|
||||||
|
select {
|
||||||
|
case <-s.overflow:
|
||||||
|
// already flagged
|
||||||
|
default:
|
||||||
|
close(s.overflow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run maintains the upstream connection, reconnecting on failure until the
|
||||||
|
// context is cancelled (channel switch/stop).
|
||||||
|
func (h *Hub) run(ctx context.Context, streamURL string) {
|
||||||
|
backoff := time.Second
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.pull(ctx, streamURL)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[hub] upstream error (%s), retrying in %s: %v", streamURL, backoff, err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < 10*time.Second {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull opens a single upstream connection and copies bytes to subscribers
|
||||||
|
// until the stream ends or the context is cancelled.
|
||||||
|
func (h *Hub) pull(ctx context.Context, streamURL string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "VLC/3.0.20 LibVLC/3.0.20")
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &httpError{status: resp.StatusCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, chunkSize)
|
||||||
|
for {
|
||||||
|
n, err := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
copy(chunk, buf[:n])
|
||||||
|
h.broadcast(chunk)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpError struct{ status int }
|
||||||
|
|
||||||
|
func (e *httpError) Error() string {
|
||||||
|
return "upstream returned HTTP status " + http.StatusText(e.status)
|
||||||
|
}
|
||||||
6264
data/playlist.m3u
6264
data/playlist.m3u
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,18 @@ services:
|
|||||||
tuner:
|
tuner:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "8888:8888"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/playlist.m3u:/data/playlist.m3u:ro
|
# real1.m3u is mounted read-only as the local source.
|
||||||
|
# It is only used when PLAYLIST_USE_LOCAL=true.
|
||||||
|
- ./real1.m3u:/data/playlist.m3u.local:ro
|
||||||
environment:
|
environment:
|
||||||
RESTREAMER_PORT: "8080"
|
RESTREAMER_PORT: "8080"
|
||||||
PLAYLIST_PATH: "/data/playlist.m3u"
|
PLAYLIST_PATH: "/data/playlist.m3u"
|
||||||
|
# Default: fetch playlist from URL on every boot.
|
||||||
|
# Set PLAYLIST_USE_LOCAL=true to use real1.m3u instead.
|
||||||
|
PLAYLIST_URL: "https://pia.cx/m3u/630xhAmY/smp424GL"
|
||||||
|
PLAYLIST_USE_LOCAL: "false"
|
||||||
|
# Set to "0" or "off" to disable automatic playlist refresh.
|
||||||
|
PLAYLIST_AUTO_REFRESH_INTERVAL: "1h"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hls.js": "^1.6.15",
|
"mpegts.js": "^1.8.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
},
|
},
|
||||||
@@ -286,6 +286,8 @@
|
|||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||||
|
|
||||||
|
"es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
@@ -342,8 +344,6 @@
|
|||||||
|
|
||||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||||
|
|
||||||
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
@@ -382,6 +382,8 @@
|
|||||||
|
|
||||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
|
"mpegts.js": ["mpegts.js@1.8.0", "", { "dependencies": { "es6-promise": "^4.2.5", "webworkify-webpack": "github:xqq/webworkify-webpack" } }, "sha512-ZtujqtmTjWgcDDkoOnLvrOKUTO/MKgLHM432zGDI8oPaJ0S+ebPxg1nEpDpLw6I7KmV/GZgUIrfbWi3qqEircg=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
@@ -454,6 +456,8 @@
|
|||||||
|
|
||||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
|
"webworkify-webpack": ["webworkify-webpack@github:xqq/webworkify-webpack#24d1e71", {}, "xqq-webworkify-webpack-24d1e71", "sha512-G1LdOhY/Ute4RrohjqCH/KsME92eCS5jE9MuiXO2e6oKg1EkVbHf6YUvFsp2C/EQfLJc88oafJOqdZZVhNkd5A=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hls.js": "^1.6.15",
|
"mpegts.js": "^1.8.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,25 +4,44 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html, body, #root {
|
||||||
background: #0a0a0a;
|
height: 100%;
|
||||||
color: #e0e0e0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #000;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App shell: video fills everything, UI floats on top */
|
||||||
.app {
|
.app {
|
||||||
max-width: 960px;
|
position: fixed;
|
||||||
margin: 0 auto;
|
inset: 0;
|
||||||
padding: 16px;
|
background: #000;
|
||||||
|
/* height reserved for the fixed bottom status bar */
|
||||||
|
--status-bar-height: 49px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen video stage: sits above the bottom status bar so the
|
||||||
|
native video controls (play button) are never hidden behind it */
|
||||||
|
.stage {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--status-bar-height);
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Player */
|
/* Player */
|
||||||
.player-container {
|
.player-container {
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
height: 100%;
|
||||||
background: #111;
|
background: #000;
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,39 +49,116 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-video:not([src]),
|
/* Offline overlay is controlled by React state, not CSS src tricks */
|
||||||
.player-video[src=""] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-video:not([src]) ~ .player-offline,
|
|
||||||
.player-video[src=""] ~ .player-offline {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-offline {
|
.player-offline {
|
||||||
display: none;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.25rem;
|
font-size: 1.5rem;
|
||||||
color: #666;
|
color: #555;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* StatusBar */
|
/* Sidebar toggle button (far right, vertically centered) */
|
||||||
|
.sidebar-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 30;
|
||||||
|
width: 32px;
|
||||||
|
height: 64px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
background: rgba(20, 20, 20, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
/* hidden by default; shown on interaction */
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: right 0.25s ease, background 0.15s, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:hover {
|
||||||
|
background: rgba(40, 40, 40, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle.open {
|
||||||
|
right: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 340px;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(15, 15, 15, 0.82);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
/* leave room for the bottom status bar */
|
||||||
|
padding-bottom: 64px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* StatusBar (fixed to bottom) */
|
||||||
.status-bar {
|
.status-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 25;
|
||||||
|
height: var(--status-bar-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 16px;
|
padding: 0 16px;
|
||||||
margin-top: 8px;
|
background: rgba(15, 15, 15, 0.85);
|
||||||
background: #1a1a1a;
|
backdrop-filter: blur(8px);
|
||||||
border-radius: 6px;
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -95,21 +191,98 @@ body {
|
|||||||
|
|
||||||
.status-channel {
|
.status-channel {
|
||||||
color: #67e8f9;
|
color: #67e8f9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls layout */
|
.status-channel.channel-transitioning {
|
||||||
.controls {
|
animation: channel-pulse 1.2s ease-in-out infinite;
|
||||||
display: grid;
|
}
|
||||||
grid-template-columns: 240px 1fr;
|
|
||||||
gap: 12px;
|
.channel-switching {
|
||||||
margin-top: 12px;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fbbf24;
|
||||||
|
background: #713f12;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: switching-blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upstream-down-banner {
|
||||||
|
color: #fecaca;
|
||||||
|
background: #7f1d1d;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes channel-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes switching-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-health {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: "SF Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-indicator {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-good {
|
||||||
|
background: #14532d;
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-warn {
|
||||||
|
background: #713f12;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-bad {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-unknown {
|
||||||
|
background: #333;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-stats {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-drops {
|
||||||
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AdminPanel */
|
/* AdminPanel */
|
||||||
.admin-panel {
|
.admin-panel {
|
||||||
background: #1a1a1a;
|
background: rgba(26, 26, 26, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-title {
|
.admin-title {
|
||||||
@@ -167,12 +340,14 @@ body {
|
|||||||
|
|
||||||
/* ChannelList */
|
/* ChannelList */
|
||||||
.channel-list {
|
.channel-list {
|
||||||
background: #1a1a1a;
|
background: rgba(26, 26, 26, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: 500px;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-list-title {
|
.channel-list-title {
|
||||||
@@ -186,6 +361,7 @@ body {
|
|||||||
|
|
||||||
.channel-filters {
|
.channel-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -217,7 +393,7 @@ body {
|
|||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
min-width: 140px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-items {
|
.channel-items {
|
||||||
@@ -286,8 +462,11 @@ body {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 480px) {
|
||||||
.controls {
|
.sidebar {
|
||||||
grid-template-columns: 1fr;
|
width: 100%;
|
||||||
|
}
|
||||||
|
.sidebar-toggle.open {
|
||||||
|
right: calc(100% - 32px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { StreamStatus, Channel } from "./types";
|
import type { StreamStatus, Channel } from "./types";
|
||||||
import * as api from "./api/client";
|
import * as api from "./api/client";
|
||||||
import Player from "./components/Player";
|
import Player from "./components/Player";
|
||||||
@@ -7,9 +7,18 @@ import AdminPanel from "./components/AdminPanel";
|
|||||||
import ChannelList from "./components/ChannelList";
|
import ChannelList from "./components/ChannelList";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
|
// Detect touch-primary devices (phones/tablets have no hover pointer).
|
||||||
|
const isTouchPrimary = () =>
|
||||||
|
window.matchMedia("(pointer: coarse)").matches || navigator.maxTouchPoints > 0;
|
||||||
|
|
||||||
|
const HIDE_DELAY = 3000; // ms of inactivity before toggle hides again
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [status, setStatus] = useState<StreamStatus | null>(null);
|
const [status, setStatus] = useState<StreamStatus | null>(null);
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [toggleVisible, setToggleVisible] = useState(false);
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,13 +41,53 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchChannels();
|
fetchChannels();
|
||||||
const interval = setInterval(fetchStatus, 5000);
|
// Poll faster while live so passive viewers detect channel switches promptly.
|
||||||
|
// Transitioning gets the fastest rate so the UI clears quickly after a switch.
|
||||||
|
const rate = status?.transitioning ? 1000 : status?.live ? 2000 : 5000;
|
||||||
|
const interval = setInterval(fetchStatus, rate);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchStatus, fetchChannels]);
|
}, [fetchStatus, fetchChannels, status?.live, status?.transitioning]);
|
||||||
|
|
||||||
const handleSourceChanged = () => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
if (status?.playlist_version) {
|
||||||
|
fetchChannels();
|
||||||
|
}
|
||||||
|
}, [fetchChannels, status?.playlist_version]);
|
||||||
|
|
||||||
|
// Show the toggle button on interaction, then auto-hide after HIDE_DELAY.
|
||||||
|
// When the sidebar is open we keep it visible so the user can close it.
|
||||||
|
const showToggle = useCallback(() => {
|
||||||
|
setToggleVisible(true);
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
if (!sidebarOpen) {
|
||||||
|
hideTimer.current = setTimeout(() => setToggleVisible(false), HIDE_DELAY);
|
||||||
|
}
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
// Keep toggle visible while sidebar is open; restart timer when it closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarOpen) {
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
setToggleVisible(true);
|
||||||
|
} else {
|
||||||
|
hideTimer.current = setTimeout(() => setToggleVisible(false), HIDE_DELAY);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
};
|
};
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
// Wire up interaction listeners on the document.
|
||||||
|
useEffect(() => {
|
||||||
|
const touch = isTouchPrimary();
|
||||||
|
if (touch) {
|
||||||
|
document.addEventListener("touchstart", showToggle, { passive: true });
|
||||||
|
return () => document.removeEventListener("touchstart", showToggle);
|
||||||
|
} else {
|
||||||
|
document.addEventListener("mousemove", showToggle);
|
||||||
|
return () => document.removeEventListener("mousemove", showToggle);
|
||||||
|
}
|
||||||
|
}, [showToggle]);
|
||||||
|
|
||||||
const handlePlaylistReloaded = () => {
|
const handlePlaylistReloaded = () => {
|
||||||
fetchChannels();
|
fetchChannels();
|
||||||
@@ -50,20 +99,38 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Player />
|
{/* Fullscreen video background */}
|
||||||
<StatusBar status={status} />
|
<div className="stage">
|
||||||
<div className="controls">
|
<Player
|
||||||
<AdminPanel
|
live={status?.live ?? false}
|
||||||
status={status}
|
streamKey={status?.channel_name ?? "offline"}
|
||||||
onSourceChanged={handleSourceChanged}
|
|
||||||
onPlaylistReloaded={handlePlaylistReloaded}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible right sidebar */}
|
||||||
|
<button
|
||||||
|
className={`sidebar-toggle ${sidebarOpen ? "open" : ""} ${toggleVisible ? "visible" : ""}`}
|
||||||
|
onClick={() => setSidebarOpen((v) => !v)}
|
||||||
|
aria-label={sidebarOpen ? "Collapse panel" : "Open panel"}
|
||||||
|
title={sidebarOpen ? "Collapse panel" : "Open panel"}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? "›" : "‹"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<aside className={`sidebar ${sidebarOpen ? "open" : "collapsed"}`}>
|
||||||
|
<div className="sidebar-inner">
|
||||||
|
<AdminPanel onPlaylistReloaded={handlePlaylistReloaded} />
|
||||||
<ChannelList
|
<ChannelList
|
||||||
channels={channels}
|
channels={channels}
|
||||||
activeChannel={status?.channelName ?? ""}
|
activeChannel={status?.channel_name ?? ""}
|
||||||
|
playlistVersion={status?.playlist_version ?? 0}
|
||||||
onRefresh={handleChannelRefresh}
|
onRefresh={handleChannelRefresh}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Status banner fixed to the bottom */}
|
||||||
|
<StatusBar status={status} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Channel, StreamStatus, SystemStatus } from "../types";
|
import type { Channel, StreamStatus } from "../types";
|
||||||
|
|
||||||
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(url, init);
|
const res = await fetch(url, init);
|
||||||
@@ -31,14 +31,6 @@ export function getGroups(): Promise<string[]> {
|
|||||||
return fetchJSON<string[]>("/api/admin/groups");
|
return fetchJSON<string[]>("/api/admin/groups");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSource(source: string): Promise<void> {
|
|
||||||
return fetchVoid("/api/admin/source", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ source }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setChannel(channelId: string): Promise<void> {
|
export function setChannel(channelId: string): Promise<void> {
|
||||||
return fetchVoid("/api/admin/channel", {
|
return fetchVoid("/api/admin/channel", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -50,7 +42,3 @@ export function setChannel(channelId: string): Promise<void> {
|
|||||||
export function reloadPlaylist(): Promise<void> {
|
export function reloadPlaylist(): Promise<void> {
|
||||||
return fetchVoid("/api/admin/playlist/reload", { method: "POST" });
|
return fetchVoid("/api/admin/playlist/reload", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProcessStatus(): Promise<SystemStatus> {
|
|
||||||
return fetchJSON<SystemStatus>("/api/admin/process/status");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
import type { StreamStatus } from "../types";
|
|
||||||
import * as api from "../api/client";
|
import * as api from "../api/client";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: StreamStatus | null;
|
|
||||||
onSourceChanged: () => void;
|
|
||||||
onPlaylistReloaded: () => void;
|
onPlaylistReloaded: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPanel({ status, onSourceChanged, onPlaylistReloaded }: Props) {
|
export default function AdminPanel({ onPlaylistReloaded }: Props) {
|
||||||
const currentSource = status?.source ?? "obs";
|
|
||||||
|
|
||||||
const handleSource = async (source: "obs" | "iptv") => {
|
|
||||||
try {
|
|
||||||
await api.setSource(source);
|
|
||||||
onSourceChanged();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to set source:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReload = async () => {
|
const handleReload = async () => {
|
||||||
try {
|
try {
|
||||||
await api.reloadPlaylist();
|
await api.reloadPlaylist();
|
||||||
@@ -30,23 +16,8 @@ export default function AdminPanel({ status, onSourceChanged, onPlaylistReloaded
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
<h2 className="admin-title">Source</h2>
|
|
||||||
<div className="source-buttons">
|
|
||||||
<button
|
|
||||||
className={`source-btn ${currentSource === "obs" ? "active" : ""}`}
|
|
||||||
onClick={() => handleSource("obs")}
|
|
||||||
>
|
|
||||||
OBS
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`source-btn ${currentSource === "iptv" ? "active" : ""}`}
|
|
||||||
onClick={() => handleSource("iptv")}
|
|
||||||
>
|
|
||||||
IPTV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button className="reload-btn" onClick={handleReload}>
|
<button className="reload-btn" onClick={handleReload}>
|
||||||
Reload Playlist
|
Refresh Channels / Playlist
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,24 +5,25 @@ import * as api from "../api/client";
|
|||||||
interface Props {
|
interface Props {
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
activeChannel: string;
|
activeChannel: string;
|
||||||
|
playlistVersion: number;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChannelList({ channels, activeChannel, onRefresh }: Props) {
|
export default function ChannelList({ channels, activeChannel, playlistVersion, onRefresh }: Props) {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [group, setGroup] = useState("");
|
const [group, setGroup] = useState("");
|
||||||
const [groups, setGroups] = useState<string[]>([]);
|
const [groups, setGroups] = useState<string[]>([]);
|
||||||
const [filtered, setFiltered] = useState<Channel[]>(channels);
|
const [filtered, setFiltered] = useState<Channel[]>(channels);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getGroups().then(setGroups).catch(() => {});
|
api.getGroups().then((g) => setGroups(g ?? [])).catch(() => {});
|
||||||
}, []);
|
}, [playlistVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFiltered = async () => {
|
const fetchFiltered = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.getChannels(search || undefined, group || undefined);
|
const result = await api.getChannels(search || undefined, group || undefined);
|
||||||
setFiltered(result);
|
setFiltered(result ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
setFiltered(channels);
|
setFiltered(channels);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,148 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Hls from "hls.js";
|
import mpegts from "mpegts.js";
|
||||||
|
|
||||||
export default function Player() {
|
interface Props {
|
||||||
|
live: boolean;
|
||||||
|
streamKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 8;
|
||||||
|
|
||||||
|
export default function Player({ live, streamKey }: Props) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const playerRef = useRef<mpegts.Player | null>(null);
|
||||||
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const stallTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
|
const lastProgressRef = useRef(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const src = `http://${window.location.hostname}:8888/live/stream/`;
|
const clearTimers = () => {
|
||||||
|
if (retryTimerRef.current) {
|
||||||
if (Hls.isSupported()) {
|
clearTimeout(retryTimerRef.current);
|
||||||
const hls = new Hls({
|
retryTimerRef.current = null;
|
||||||
enableWorker: true,
|
|
||||||
lowLatencyMode: true,
|
|
||||||
});
|
|
||||||
hlsRef.current = hls;
|
|
||||||
hls.loadSource(src);
|
|
||||||
hls.attachMedia(video);
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
||||||
video.play().catch(() => {});
|
|
||||||
});
|
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
||||||
if (data.fatal) {
|
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
||||||
setTimeout(() => hls.loadSource(src), 5000);
|
|
||||||
} else {
|
|
||||||
hls.destroy();
|
|
||||||
}
|
}
|
||||||
}
|
if (stallTimerRef.current) {
|
||||||
});
|
clearInterval(stallTimerRef.current);
|
||||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
stallTimerRef.current = null;
|
||||||
video.src = src;
|
|
||||||
video.addEventListener("loadedmetadata", () => {
|
|
||||||
video.play().catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (hlsRef.current) {
|
|
||||||
hlsRef.current.destroy();
|
|
||||||
hlsRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
const destroyPlayer = () => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
try { playerRef.current.pause(); } catch { /* noop */ }
|
||||||
|
try { playerRef.current.unload(); } catch { /* noop */ }
|
||||||
|
try { playerRef.current.detachMediaElement(); } catch { /* noop */ }
|
||||||
|
try { playerRef.current.destroy(); } catch { /* noop */ }
|
||||||
|
playerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tear down when offline
|
||||||
|
if (!live) {
|
||||||
|
clearTimers();
|
||||||
|
destroyPlayer();
|
||||||
|
try { video.removeAttribute("src"); video.load(); } catch { /* noop */ }
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mpegts.isSupported()) {
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = `${window.location.origin}/ts?v=${encodeURIComponent(streamKey)}`;
|
||||||
|
|
||||||
|
const scheduleRetry = () => {
|
||||||
|
if (retryCountRef.current >= MAX_RETRIES) {
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retryCountRef.current++;
|
||||||
|
const delay = Math.min(2000 * retryCountRef.current, 10000);
|
||||||
|
setPlaying(false);
|
||||||
|
retryTimerRef.current = setTimeout(start, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startStallWatchdog = () => {
|
||||||
|
if (stallTimerRef.current) clearInterval(stallTimerRef.current);
|
||||||
|
lastProgressRef.current = Date.now();
|
||||||
|
const bump = () => { lastProgressRef.current = Date.now(); };
|
||||||
|
video.addEventListener("timeupdate", bump);
|
||||||
|
video.addEventListener("progress", bump);
|
||||||
|
stallTimerRef.current = setInterval(() => {
|
||||||
|
if (Date.now() - lastProgressRef.current > 30000) {
|
||||||
|
lastProgressRef.current = Date.now();
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
destroyPlayer();
|
||||||
|
// Config mirrors webplayer.online (proven on this exact bursty upstream):
|
||||||
|
// a deep 30-90s live buffer absorbs the upstream's multi-second delivery
|
||||||
|
// gaps, while gentle 1.1x catch-up keeps latency bounded.
|
||||||
|
const player = mpegts.createPlayer(
|
||||||
|
{ type: "mpegts", isLive: true, url: src },
|
||||||
|
{
|
||||||
|
enableWorker: true,
|
||||||
|
enableStashBuffer: true,
|
||||||
|
stashInitialSize: 8 * 1024 * 1024,
|
||||||
|
liveSync: true,
|
||||||
|
liveSyncMaxLatency: 60,
|
||||||
|
liveSyncTargetLatency: 45,
|
||||||
|
liveSyncPlaybackRate: 1.1,
|
||||||
|
liveBufferLatencyChasing: true,
|
||||||
|
liveBufferLatencyMaxLatency: 90,
|
||||||
|
liveBufferLatencyMinRemain: 30,
|
||||||
|
autoCleanupSourceBuffer: true,
|
||||||
|
autoCleanupMaxBackwardDuration: 120,
|
||||||
|
autoCleanupMinBackwardDuration: 90,
|
||||||
|
fixAudioTimestampGap: true,
|
||||||
|
lazyLoad: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
playerRef.current = player;
|
||||||
|
player.attachMediaElement(video!);
|
||||||
|
|
||||||
|
player.on(mpegts.Events.ERROR, () => {
|
||||||
|
scheduleRetry();
|
||||||
|
});
|
||||||
|
player.on(mpegts.Events.LOADING_COMPLETE, () => {
|
||||||
|
scheduleRetry();
|
||||||
|
});
|
||||||
|
player.on(mpegts.Events.MEDIA_INFO, () => {
|
||||||
|
setPlaying(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
player.load();
|
||||||
|
video!.play().catch(() => { /* autoplay handled via muted attribute */ });
|
||||||
|
startStallWatchdog();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryCountRef.current = 0;
|
||||||
|
start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimers();
|
||||||
|
destroyPlayer();
|
||||||
|
setPlaying(false);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [live, streamKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player-container">
|
<div className="player-container">
|
||||||
<video ref={videoRef} className="player-video" controls playsInline />
|
<video ref={videoRef} className="player-video" controls playsInline autoPlay muted />
|
||||||
<div className="player-offline">Stream Offline</div>
|
{!playing && <div className="player-offline">Stream Offline</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,21 @@ export default function StatusBar({ status }: Props) {
|
|||||||
<span className="status-text">
|
<span className="status-text">
|
||||||
{status.live ? "LIVE" : "Offline"}
|
{status.live ? "LIVE" : "Offline"}
|
||||||
</span>
|
</span>
|
||||||
<span className="status-separator">|</span>
|
{status.channel_name && (
|
||||||
<span className="status-source">
|
|
||||||
Source: {status.source === "obs" ? "OBS" : "IPTV"}
|
|
||||||
</span>
|
|
||||||
{status.source === "iptv" && status.channelName && (
|
|
||||||
<>
|
<>
|
||||||
<span className="status-separator">|</span>
|
<span className="status-separator">|</span>
|
||||||
<span className="status-channel">{status.channelName}</span>
|
<span className={`status-channel ${status.transitioning ? "channel-transitioning" : ""}`}>
|
||||||
|
{status.channel_name}
|
||||||
|
{status.transitioning && (
|
||||||
|
<span className="channel-switching">Switching...</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status.upstream_down && (
|
||||||
|
<>
|
||||||
|
<span className="status-separator">|</span>
|
||||||
|
<span className="upstream-down-banner">Upstream down?</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,19 +7,9 @@ export interface Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamStatus {
|
export interface StreamStatus {
|
||||||
source: "obs" | "iptv";
|
channel_name: string;
|
||||||
channelName: string;
|
|
||||||
live: boolean;
|
live: boolean;
|
||||||
}
|
transitioning: boolean;
|
||||||
|
upstream_down: boolean;
|
||||||
export interface ProcessInfo {
|
playlist_version: number;
|
||||||
running: boolean;
|
|
||||||
pid: number;
|
|
||||||
uptime: string;
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemStatus {
|
|
||||||
mediamtx: ProcessInfo;
|
|
||||||
ffmpeg: ProcessInfo;
|
|
||||||
}
|
}
|
||||||
|
|||||||
17
mediamtx.yml
17
mediamtx.yml
@@ -6,20 +6,31 @@
|
|||||||
logLevel: info
|
logLevel: info
|
||||||
logDestinations: [stdout]
|
logDestinations: [stdout]
|
||||||
|
|
||||||
|
# REST API (for diagnostics)
|
||||||
|
api: yes
|
||||||
|
apiAddress: :9997
|
||||||
|
|
||||||
# RTMP server (ingest from OBS / FFmpeg)
|
# RTMP server (ingest from OBS / FFmpeg)
|
||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :1935
|
rtmpAddress: :1935
|
||||||
|
|
||||||
# HLS server (output to viewers)
|
# HLS server (output to viewers)
|
||||||
|
# Note: hlsVariant / segment / part durations are overridden at runtime by the
|
||||||
|
# backend via MTX_HLS* environment variables, based on PLAYBACK_MODE
|
||||||
|
# (standard = mpegts, fmp4 = fragmented MP4, llhls = lowLatency).
|
||||||
hls: yes
|
hls: yes
|
||||||
hlsAddress: :8888
|
hlsAddress: :8888
|
||||||
hlsAlwaysRemux: yes
|
hlsAlwaysRemux: yes
|
||||||
hlsSegmentCount: 3
|
hlsVariant: mpegts
|
||||||
hlsSegmentDuration: 1s
|
hlsSegmentCount: 7
|
||||||
|
hlsSegmentDuration: 2s
|
||||||
hlsAllowOrigin: '*'
|
hlsAllowOrigin: '*'
|
||||||
|
|
||||||
|
# RTSP server (used internally for FFmpeg → MediaMTX push, supports HEVC)
|
||||||
|
rtsp: yes
|
||||||
|
rtspAddress: :8554
|
||||||
|
|
||||||
# Disable unused protocols
|
# Disable unused protocols
|
||||||
rtsp: no
|
|
||||||
webrtc: no
|
webrtc: no
|
||||||
srt: no
|
srt: no
|
||||||
record: no
|
record: no
|
||||||
|
|||||||
1
update_url.txt
Normal file
1
update_url.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
https://pia.cx/m3u/630xhAmY/smp424GL
|
||||||
Reference in New Issue
Block a user