Initial commit: Null DRM Official
Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
This commit is contained in:
commit
2fa8f2435f
121 changed files with 17802 additions and 0 deletions
87
apps/streamd/internal/supervisor/appdefaults.go
Normal file
87
apps/streamd/internal/supervisor/appdefaults.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package supervisor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"drmdecryption/app"
|
||||
"drmdecryption/apps/streamd/internal/db"
|
||||
)
|
||||
|
||||
// streamSettings are the downloader knobs for one stream. They come from the
|
||||
// stream's app module when it declares them, so the supervisor never has to
|
||||
// guess from an app name or a stream name.
|
||||
type streamSettings struct {
|
||||
VideoSelect string
|
||||
AudioSelect string
|
||||
Rewriter string
|
||||
LiveWait int
|
||||
TSReadyBytes int64
|
||||
// IsHLS drives packaging choices that depend on the manifest format.
|
||||
IsHLS bool
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLiveWait = 2
|
||||
defaultTSReadyBytes = 256 * 1024
|
||||
// hlsTSReadyBytes buffers more before probing: an HLS pipe-mux often starts
|
||||
// with incomplete audio config.
|
||||
hlsTSReadyBytes = 2 * 1024 * 1024
|
||||
hlsLiveWait = 6
|
||||
)
|
||||
|
||||
// settingsFor resolves a stream's downloader settings: the app module's
|
||||
// declarations first, then the stream row, then neutral defaults.
|
||||
func settingsFor(st db.Stream) streamSettings {
|
||||
s := streamSettings{
|
||||
VideoSelect: strings.TrimSpace(st.VideoSelect),
|
||||
AudioSelect: strings.TrimSpace(st.AudioSelect),
|
||||
Rewriter: strings.TrimSpace(st.Rewriter),
|
||||
LiveWait: defaultLiveWait,
|
||||
TSReadyBytes: defaultTSReadyBytes,
|
||||
IsHLS: isHLS(st),
|
||||
}
|
||||
if a, err := app.Get(st.App); err == nil {
|
||||
if sd, ok := a.(app.StreamDefaults); ok {
|
||||
if s.VideoSelect == "" {
|
||||
s.VideoSelect = sd.VideoSelect()
|
||||
}
|
||||
if s.AudioSelect == "" {
|
||||
s.AudioSelect = sd.AudioSelect()
|
||||
}
|
||||
if s.Rewriter == "" {
|
||||
s.Rewriter = sd.RewriterName()
|
||||
}
|
||||
if n := sd.LiveWaitSeconds(); n > 0 {
|
||||
s.LiveWait = n
|
||||
}
|
||||
if n := sd.TSReadyBytes(); n > 0 {
|
||||
s.TSReadyBytes = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.IsHLS {
|
||||
if s.LiveWait == defaultLiveWait {
|
||||
s.LiveWait = hlsLiveWait
|
||||
}
|
||||
if s.TSReadyBytes == defaultTSReadyBytes {
|
||||
s.TSReadyBytes = hlsTSReadyBytes
|
||||
}
|
||||
}
|
||||
if s.VideoSelect == "" {
|
||||
s.VideoSelect = "for=best"
|
||||
}
|
||||
if s.AudioSelect == "" {
|
||||
s.AudioSelect = "for=best"
|
||||
}
|
||||
if s.Rewriter == "" {
|
||||
s.Rewriter = "none"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// isHLS reports whether a stream's manifest is an HLS playlist, by URL shape.
|
||||
// No provider host is consulted: a module declares its own needs through
|
||||
// app.StreamDefaults.
|
||||
func isHLS(st db.Stream) bool {
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(st.MPD)), ".m3u8")
|
||||
}
|
||||
129
apps/streamd/internal/supervisor/appdefaults_test.go
Normal file
129
apps/streamd/internal/supervisor/appdefaults_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package supervisor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"drmdecryption/adb"
|
||||
"drmdecryption/app"
|
||||
"drmdecryption/apps/streamd/internal/db"
|
||||
"drmdecryption/capture"
|
||||
"drmdecryption/mpd"
|
||||
)
|
||||
|
||||
// fakeApp stands in for a compiled-in app module: the supervisor must take its
|
||||
// declared settings without knowing anything about the provider.
|
||||
type fakeApp struct {
|
||||
name string
|
||||
rewriter string
|
||||
liveWait int
|
||||
tsBytes int64
|
||||
}
|
||||
|
||||
func (f *fakeApp) Name() string { return f.name }
|
||||
func (f *fakeApp) Package() string { return "com.example." + f.name }
|
||||
func (f *fakeApp) LicenseURL() string { return "" }
|
||||
func (f *fakeApp) Launch(*adb.Client) error { return nil }
|
||||
func (f *fakeApp) AutoPlay(*adb.Client, string) error { return nil }
|
||||
func (f *fakeApp) CaptureHints() capture.Hints { return capture.DefaultHints() }
|
||||
func (f *fakeApp) MPDRewriter() mpd.Rewriter { return mpd.Passthrough{} }
|
||||
func (f *fakeApp) Channels() []app.Channel { return nil }
|
||||
func (f *fakeApp) HasChannel(string) bool { return false }
|
||||
func (f *fakeApp) ProxyBin() string { return "proxy" }
|
||||
func (f *fakeApp) CAHash() string { return "deadbeef" }
|
||||
func (f *fakeApp) KeyMode() string { return "raw" }
|
||||
func (f *fakeApp) VideoSelect() string { return "res=1280x720:for=best" }
|
||||
func (f *fakeApp) AudioSelect() string { return "lang=en:for=best" }
|
||||
func (f *fakeApp) RewriterName() string { return f.rewriter }
|
||||
func (f *fakeApp) LiveWaitSeconds() int { return f.liveWait }
|
||||
func (f *fakeApp) TSReadyBytes() int64 { return f.tsBytes }
|
||||
|
||||
func register(t *testing.T, f *fakeApp) {
|
||||
t.Helper()
|
||||
app.Register(f.name, func() (app.App, error) { return f, nil })
|
||||
}
|
||||
|
||||
func TestSettingsComeFromTheAppModule(t *testing.T) {
|
||||
register(t, &fakeApp{name: "fakedash", rewriter: "fakedash", liveWait: 3, tsBytes: 111})
|
||||
got := settingsFor(db.Stream{App: "fakedash", MPD: "https://origin.test/manifest.mpd"})
|
||||
if got.Rewriter != "fakedash" {
|
||||
t.Errorf("rewriter = %q, want fakedash", got.Rewriter)
|
||||
}
|
||||
if got.VideoSelect != "res=1280x720:for=best" || got.AudioSelect != "lang=en:for=best" {
|
||||
t.Errorf("selectors = %q / %q", got.VideoSelect, got.AudioSelect)
|
||||
}
|
||||
if got.LiveWait != 3 || got.TSReadyBytes != 111 {
|
||||
t.Errorf("liveWait=%d tsReadyBytes=%d", got.LiveWait, got.TSReadyBytes)
|
||||
}
|
||||
if got.IsHLS {
|
||||
t.Error("an .mpd manifest is not HLS")
|
||||
}
|
||||
}
|
||||
|
||||
// The stored row wins over the module, so an operator override sticks.
|
||||
func TestStoredSettingsOverrideTheModule(t *testing.T) {
|
||||
register(t, &fakeApp{name: "fakeoverride", rewriter: "fakeoverride"})
|
||||
got := settingsFor(db.Stream{
|
||||
App: "fakeoverride",
|
||||
MPD: "https://origin.test/manifest.mpd",
|
||||
VideoSelect: "res=1920x1080:for=best",
|
||||
Rewriter: "none",
|
||||
})
|
||||
if got.VideoSelect != "res=1920x1080:for=best" {
|
||||
t.Errorf("video select = %q, want the stored value", got.VideoSelect)
|
||||
}
|
||||
if got.Rewriter != "none" {
|
||||
t.Errorf("rewriter = %q, want the stored none", got.Rewriter)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown app must not inherit any provider's preferences.
|
||||
func TestUnknownAppGetsNeutralDefaults(t *testing.T) {
|
||||
got := settingsFor(db.Stream{App: "nosuchapp", MPD: "https://origin.test/manifest.mpd"})
|
||||
if got.VideoSelect != "for=best" || got.AudioSelect != "for=best" {
|
||||
t.Errorf("selectors = %q / %q, want for=best", got.VideoSelect, got.AudioSelect)
|
||||
}
|
||||
if got.Rewriter != "none" {
|
||||
t.Errorf("rewriter = %q, want none", got.Rewriter)
|
||||
}
|
||||
if got.LiveWait != defaultLiveWait || got.TSReadyBytes != defaultTSReadyBytes {
|
||||
t.Errorf("liveWait=%d tsReadyBytes=%d, want neutral defaults", got.LiveWait, got.TSReadyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// HLS gets more buffering and a longer live wait, by manifest shape and not by
|
||||
// matching a stream name.
|
||||
func TestHLSDetectionByManifestShape(t *testing.T) {
|
||||
got := settingsFor(db.Stream{App: "", MPD: "https://cdn.test/x/playlist-hls-dvr.m3u8"})
|
||||
if !got.IsHLS {
|
||||
t.Fatal("an .m3u8 manifest should be detected as HLS")
|
||||
}
|
||||
if got.LiveWait != hlsLiveWait {
|
||||
t.Errorf("liveWait = %d, want %d", got.LiveWait, hlsLiveWait)
|
||||
}
|
||||
if got.TSReadyBytes != hlsTSReadyBytes {
|
||||
t.Errorf("tsReadyBytes = %d, want %d", got.TSReadyBytes, hlsTSReadyBytes)
|
||||
}
|
||||
// The old code keyed this off stream names like "tg4"/"cula"/"plus".
|
||||
named := settingsFor(db.Stream{Name: "tg4-ioi", MPD: "https://origin.test/manifest.mpd"})
|
||||
if named.TSReadyBytes != defaultTSReadyBytes {
|
||||
t.Error("buffering must not be chosen by stream name any more")
|
||||
}
|
||||
}
|
||||
|
||||
// A module's rewriter is only used if it registered one under that name.
|
||||
func TestRewriterLookupGatesTheRewrite(t *testing.T) {
|
||||
register(t, &fakeApp{name: "fakeunregistered", rewriter: "fakeunregistered"})
|
||||
cfg := settingsFor(db.Stream{App: "fakeunregistered", MPD: "https://origin.test/manifest.mpd"})
|
||||
if _, needed := mpd.Lookup(cfg.Rewriter); needed {
|
||||
t.Error("an unregistered rewriter name must not trigger a rewrite")
|
||||
}
|
||||
mpd.Register("fakeunregistered", func() mpd.Rewriter { return stubRewriter{} })
|
||||
if _, needed := mpd.Lookup(cfg.Rewriter); !needed {
|
||||
t.Error("a registered rewriter must trigger a rewrite")
|
||||
}
|
||||
}
|
||||
|
||||
type stubRewriter struct{}
|
||||
|
||||
func (stubRewriter) Name() string { return "fakeunregistered" }
|
||||
func (stubRewriter) Rewrite(in []byte, _ string) ([]byte, error) { return in, nil }
|
||||
779
apps/streamd/internal/supervisor/media.go
Normal file
779
apps/streamd/internal/supervisor/media.go
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
package supervisor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"drmdecryption/apps/streamd/internal/db"
|
||||
"drmdecryption/mpd"
|
||||
)
|
||||
|
||||
// Media runs N_m3u8DL-RE + ffmpeg HLS packager per stream into DataDir/www/<name>/.
|
||||
type Media struct {
|
||||
Store *db.Store
|
||||
DataDir string
|
||||
NRE string
|
||||
FFmpeg string
|
||||
MP4Decrypt string
|
||||
Log *log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
procs map[int64]*streamProc
|
||||
stopMon chan struct{}
|
||||
}
|
||||
|
||||
type streamProc struct {
|
||||
id int64
|
||||
name string
|
||||
nre *exec.Cmd
|
||||
ffmpeg *exec.Cmd
|
||||
started time.Time
|
||||
workDir string
|
||||
wwwDir string
|
||||
tsPath string
|
||||
hlsIndex string
|
||||
nreLog *os.File
|
||||
ffOutLog *os.File
|
||||
ffErrLog *os.File
|
||||
stopping bool
|
||||
mpdProxy *mpd.LocalServer
|
||||
nreDead bool
|
||||
ffDead bool
|
||||
// tsReadyBytes is how much muxed output to buffer before probing, from the
|
||||
// stream's app module.
|
||||
tsReadyBytes int64
|
||||
}
|
||||
|
||||
// Options for constructing the media supervisor.
|
||||
type Options struct {
|
||||
Store *db.Store
|
||||
DataDir string
|
||||
NRE string
|
||||
FFmpeg string
|
||||
MP4Decrypt string
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
func NewMedia(opt Options) *Media {
|
||||
lg := opt.Log
|
||||
if lg == nil {
|
||||
lg = log.Default()
|
||||
}
|
||||
m := &Media{
|
||||
Store: opt.Store,
|
||||
DataDir: opt.DataDir,
|
||||
NRE: opt.NRE,
|
||||
FFmpeg: opt.FFmpeg,
|
||||
MP4Decrypt: opt.MP4Decrypt,
|
||||
Log: lg,
|
||||
procs: map[int64]*streamProc{},
|
||||
stopMon: make(chan struct{}),
|
||||
}
|
||||
go m.monitorLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Media) Close() {
|
||||
close(m.stopMon)
|
||||
m.mu.Lock()
|
||||
ids := make([]int64, 0, len(m.procs))
|
||||
for id := range m.procs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
_ = m.Stop(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Media) Start(id int64) error {
|
||||
st, err := m.Store.GetStream(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(st.MPD) == "" || strings.TrimSpace(st.Key) == "" {
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "down",
|
||||
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||
LastError: "missing mpd/key - capture required",
|
||||
})
|
||||
return fmt.Errorf("missing mpd/key")
|
||||
}
|
||||
mpdURL := strings.TrimSpace(st.MPD)
|
||||
if !strings.HasPrefix(strings.ToLower(mpdURL), "http://") && !strings.HasPrefix(strings.ToLower(mpdURL), "https://") {
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "down",
|
||||
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||
LastError: "mpd must be an http(s) URL",
|
||||
})
|
||||
return fmt.Errorf("invalid mpd url")
|
||||
}
|
||||
if m.NRE == "" || m.FFmpeg == "" {
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "down",
|
||||
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||
LastError: "NRE or ffmpeg binary not configured",
|
||||
})
|
||||
return fmt.Errorf("NRE or ffmpeg not configured")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if _, exists := m.procs[id]; exists {
|
||||
m.mu.Unlock()
|
||||
return m.Restart(id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "starting",
|
||||
PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||
})
|
||||
|
||||
workDir := filepath.Join(m.DataDir, "work", st.Name)
|
||||
wwwDir := filepath.Join(m.DataDir, "www", st.Name)
|
||||
// Fresh dirs each start — leftover decrypted segments make NRE File.Move fail.
|
||||
_ = os.RemoveAll(wwwDir)
|
||||
_ = os.RemoveAll(workDir)
|
||||
for _, d := range []string{workDir, wwwDir, filepath.Join(m.DataDir, "logs")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
keysFile := filepath.Join(workDir, "keys.txt")
|
||||
keysBody := normalizeKeysFile(st.Key)
|
||||
if err := os.WriteFile(keysFile, []byte(keysBody), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
saveName := st.Name
|
||||
tsPath := filepath.Join(workDir, saveName+".ts")
|
||||
_ = os.Remove(tsPath)
|
||||
|
||||
headers := headerMap(st.HeadersJSON)
|
||||
manifestURL := st.MPD
|
||||
cfg := settingsFor(st)
|
||||
var mpdProxy *mpd.LocalServer
|
||||
// The rewriter is named by the stream row or its app module and looked up in
|
||||
// the mpd registry, so streamd needs no knowledge of any provider.
|
||||
if rw, needed := mpd.Lookup(cfg.Rewriter); needed && strings.TrimSpace(st.MPD) != "" && !cfg.IsHLS {
|
||||
srv, err := mpd.StartLocal(st.MPD, st.Key, headers, rw)
|
||||
if err != nil {
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "down", PlayPath: "/hls/" + st.Name + "/index.m3u8",
|
||||
LastError: "mpd rewrite server: " + err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
mpdProxy = srv
|
||||
manifestURL = srv.URL
|
||||
m.logf("stream %s: rewritten MPD %s", st.Name, manifestURL)
|
||||
}
|
||||
|
||||
// Ad-stitched manifest rewrite + live pipe mux.
|
||||
nreTmp := filepath.Join(workDir, "nre")
|
||||
_ = os.MkdirAll(nreTmp, 0o755)
|
||||
tsPath = filepath.Join(nreTmp, saveName+".ts")
|
||||
_ = os.Remove(tsPath)
|
||||
|
||||
liveWait := strconv.Itoa(cfg.LiveWait)
|
||||
nreArgs := []string{
|
||||
manifestURL,
|
||||
// Multi-KID SAMPLE-AES streams rainbow-decrypt with a single --key, so
|
||||
// always feed every KID:KEY via --key-text-file.
|
||||
"--key-text-file", keysFile,
|
||||
"--live-real-time-merge",
|
||||
"--live-pipe-mux",
|
||||
"--mp4-real-time-decryption",
|
||||
"--live-wait-time", liveWait,
|
||||
"--ffmpeg-binary-path", m.FFmpeg,
|
||||
"--save-name", saveName,
|
||||
"--save-dir", nreTmp,
|
||||
"--tmp-dir", nreTmp,
|
||||
"--no-ansi-color",
|
||||
"--log-level", "ERROR",
|
||||
"-ss", "0",
|
||||
}
|
||||
// A synthetic manifest publishes exactly one video + audio pair, and an HLS
|
||||
// master's variants rarely match a resolution/language filter, so in both
|
||||
// cases take the best rendition instead of the configured selectors.
|
||||
if mpdProxy != nil || cfg.IsHLS {
|
||||
nreArgs = append(nreArgs, "-sv", "for=best", "-sa", "for=best")
|
||||
} else {
|
||||
nreArgs = append(nreArgs, "-sv", cfg.VideoSelect, "-sa", cfg.AudioSelect)
|
||||
}
|
||||
if m.MP4Decrypt != "" {
|
||||
nreArgs = append(nreArgs,
|
||||
"--decryption-engine", "MP4DECRYPT",
|
||||
"--decryption-binary-path", m.MP4Decrypt,
|
||||
)
|
||||
}
|
||||
// Headers only needed when hitting upstream DAI directly (non-rewritten).
|
||||
if mpdProxy == nil {
|
||||
for _, h := range headerFlags(st.HeadersJSON) {
|
||||
nreArgs = append(nreArgs, "-H", h)
|
||||
}
|
||||
}
|
||||
_ = keysFile // kept on disk for debugging
|
||||
|
||||
nreLogPath := filepath.Join(m.DataDir, "logs", st.Name+"-nre.log")
|
||||
nreLog, err := os.OpenFile(nreLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nreCmd := exec.Command(m.NRE, nreArgs...)
|
||||
nreCmd.Stdout = nreLog
|
||||
nreCmd.Stderr = nreLog
|
||||
hideWindow(nreCmd)
|
||||
// NRE (v0.6) resolves mp4decrypt via PATH / sibling of its .exe, and often
|
||||
// ignores --decryption-binary-path for MP4DECRYPT. Match legacy: prepend
|
||||
// dirs and best-effort copy beside NRE.
|
||||
nreEnv := os.Environ()
|
||||
pathPrepend := []string{}
|
||||
if m.MP4Decrypt != "" {
|
||||
pathPrepend = append(pathPrepend, filepath.Dir(m.MP4Decrypt))
|
||||
sibling := filepath.Join(filepath.Dir(m.NRE), filepath.Base(m.MP4Decrypt))
|
||||
if _, err := os.Stat(sibling); err != nil {
|
||||
_ = copyFile(m.MP4Decrypt, sibling)
|
||||
}
|
||||
}
|
||||
if m.NRE != "" {
|
||||
pathPrepend = append(pathPrepend, filepath.Dir(m.NRE))
|
||||
}
|
||||
if len(pathPrepend) > 0 {
|
||||
sep := string(os.PathListSeparator)
|
||||
nreEnv = prependPathEnv(nreEnv, strings.Join(pathPrepend, sep))
|
||||
}
|
||||
nreCmd.Env = nreEnv
|
||||
if err := nreCmd.Start(); err != nil {
|
||||
_ = nreLog.Close()
|
||||
if mpdProxy != nil {
|
||||
mpdProxy.Close()
|
||||
}
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{Health: "down", LastError: err.Error(), PlayPath: "/hls/" + st.Name + "/index.m3u8"})
|
||||
return err
|
||||
}
|
||||
m.logf("stream %s: NRE pid=%d", st.Name, nreCmd.Process.Pid)
|
||||
|
||||
sp := &streamProc{
|
||||
id: id,
|
||||
name: st.Name,
|
||||
nre: nreCmd,
|
||||
started: time.Now(),
|
||||
workDir: workDir,
|
||||
wwwDir: wwwDir,
|
||||
tsPath: tsPath,
|
||||
hlsIndex: filepath.Join(wwwDir, "index.m3u8"),
|
||||
nreLog: nreLog,
|
||||
mpdProxy: mpdProxy,
|
||||
|
||||
tsReadyBytes: cfg.TSReadyBytes,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.procs[id] = sp
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
_ = nreCmd.Wait()
|
||||
m.mu.Lock()
|
||||
if cur, ok := m.procs[id]; ok && cur.nre == nreCmd {
|
||||
cur.nreDead = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
go m.bootstrapHLS(sp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Media) bootstrapHLS(sp *streamProc) {
|
||||
deadline := time.Now().Add(3 * time.Minute)
|
||||
nreDir := filepath.Dir(sp.tsPath)
|
||||
minTS := sp.tsReadyBytes
|
||||
for time.Now().Before(deadline) {
|
||||
m.mu.Lock()
|
||||
cur := m.procs[sp.id]
|
||||
stopping := cur == nil || cur.stopping
|
||||
nreDead := sp.nreDead
|
||||
m.mu.Unlock()
|
||||
if stopping {
|
||||
return
|
||||
}
|
||||
if found := findGrowingTSMin(nreDir, sp.name, minTS); found != "" {
|
||||
sp.tsPath = found
|
||||
m.mu.Lock()
|
||||
if cur, ok := m.procs[sp.id]; ok {
|
||||
cur.tsPath = found
|
||||
}
|
||||
m.mu.Unlock()
|
||||
break
|
||||
}
|
||||
if nreDead || (sp.nre.ProcessState != nil && sp.nre.ProcessState.Exited()) {
|
||||
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||
Health: "down",
|
||||
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||
LastError: "NRE exited before TS was ready — see logs/" + sp.name + "-nre.log",
|
||||
})
|
||||
m.cleanupProc(sp.id)
|
||||
return
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
if st, err := os.Stat(sp.tsPath); err != nil || st.Size() < minTS {
|
||||
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||
Health: "down",
|
||||
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||
LastError: "timed out waiting for growing TS",
|
||||
})
|
||||
_ = m.Stop(sp.id)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.startFFmpegHLS(sp); err != nil {
|
||||
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||
Health: "down", LastError: err.Error(), PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||
})
|
||||
return
|
||||
}
|
||||
pid := int64(0)
|
||||
if sp.nre != nil && sp.nre.Process != nil {
|
||||
pid = int64(sp.nre.Process.Pid)
|
||||
}
|
||||
_ = m.Store.SetRuntime(sp.id, db.RuntimePatch{
|
||||
Health: "starting",
|
||||
PID: pid,
|
||||
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Media) startFFmpegHLS(sp *streamProc) error {
|
||||
ffOut := filepath.Join(m.DataDir, "logs", sp.name+"-ffmpeg.out.log")
|
||||
ffErr := filepath.Join(m.DataDir, "logs", sp.name+"-ffmpeg.err.log")
|
||||
outF, err := os.OpenFile(ffOut, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
errF, err := os.OpenFile(ffErr, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
_ = outF.Close()
|
||||
return err
|
||||
}
|
||||
ffArgs := []string{
|
||||
"-hide_banner", "-loglevel", "warning", "-y",
|
||||
"-follow", "1",
|
||||
// Large probe: early pipe-mux bytes often lack clean audio config.
|
||||
"-analyzeduration", "20M",
|
||||
"-probesize", "10M",
|
||||
"-fflags", "+genpts",
|
||||
"-i", sp.tsPath,
|
||||
"-map", "0",
|
||||
"-c", "copy",
|
||||
"-f", "hls",
|
||||
"-hls_time", "4",
|
||||
"-hls_list_size", "15",
|
||||
"-hls_flags", "delete_segments+append_list+omit_endlist+independent_segments",
|
||||
"-hls_segment_type", "mpegts",
|
||||
"-hls_segment_filename", filepath.Join(sp.wwwDir, "seg_%05d.ts"),
|
||||
sp.hlsIndex,
|
||||
}
|
||||
ffCmd := exec.Command(m.FFmpeg, ffArgs...)
|
||||
ffCmd.Stdout = outF
|
||||
ffCmd.Stderr = errF
|
||||
hideWindow(ffCmd)
|
||||
if err := ffCmd.Start(); err != nil {
|
||||
_ = outF.Close()
|
||||
_ = errF.Close()
|
||||
return err
|
||||
}
|
||||
m.logf("stream %s: ffmpeg HLS pid=%d → %s", sp.name, ffCmd.Process.Pid, sp.hlsIndex)
|
||||
|
||||
m.mu.Lock()
|
||||
if cur, ok := m.procs[sp.id]; ok && !cur.stopping {
|
||||
if cur.ffOutLog != nil {
|
||||
_ = cur.ffOutLog.Close()
|
||||
}
|
||||
if cur.ffErrLog != nil {
|
||||
_ = cur.ffErrLog.Close()
|
||||
}
|
||||
cur.ffmpeg = ffCmd
|
||||
cur.ffOutLog = outF
|
||||
cur.ffErrLog = errF
|
||||
cur.ffDead = false
|
||||
} else {
|
||||
m.mu.Unlock()
|
||||
_ = killProcess(ffCmd)
|
||||
_ = outF.Close()
|
||||
_ = errF.Close()
|
||||
return fmt.Errorf("stream stopped before ffmpeg attach")
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
_ = ffCmd.Wait()
|
||||
m.mu.Lock()
|
||||
if cur, ok := m.procs[sp.id]; ok && cur.ffmpeg == ffCmd {
|
||||
cur.ffDead = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Media) Stop(id int64) error {
|
||||
m.mu.Lock()
|
||||
sp, ok := m.procs[id]
|
||||
if ok {
|
||||
sp.stopping = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
st, _ := m.Store.GetStream(id)
|
||||
name := fmt.Sprintf("%d", id)
|
||||
if st.Name != "" {
|
||||
name = st.Name
|
||||
}
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{Health: "stopped", PlayPath: "/hls/" + name + "/index.m3u8"})
|
||||
return nil
|
||||
}
|
||||
nrePID, ffPID := 0, 0
|
||||
if sp.ffmpeg != nil && sp.ffmpeg.Process != nil {
|
||||
ffPID = sp.ffmpeg.Process.Pid
|
||||
_ = killProcess(sp.ffmpeg)
|
||||
}
|
||||
if sp.nre != nil && sp.nre.Process != nil {
|
||||
nrePID = sp.nre.Process.Pid
|
||||
_ = killProcess(sp.nre)
|
||||
}
|
||||
if sp.mpdProxy != nil {
|
||||
sp.mpdProxy.Close()
|
||||
sp.mpdProxy = nil
|
||||
}
|
||||
waitPIDsGone([]int{nrePID, ffPID}, 5*time.Second)
|
||||
m.cleanupProc(id)
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "stopped",
|
||||
PlayPath: "/hls/" + sp.name + "/index.m3u8",
|
||||
})
|
||||
m.logf("stream %s: stopped", sp.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Media) Restart(id int64) error {
|
||||
m.logf("stream id=%d: hard restart (kill worker, fresh start)", id)
|
||||
_ = m.Stop(id)
|
||||
// Give Windows time to release file locks on work/www before Start wipes them.
|
||||
time.Sleep(1 * time.Second)
|
||||
return m.Start(id)
|
||||
}
|
||||
|
||||
func (m *Media) cleanupProc(id int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
sp, ok := m.procs[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if sp.nreLog != nil {
|
||||
_ = sp.nreLog.Close()
|
||||
}
|
||||
if sp.ffOutLog != nil {
|
||||
_ = sp.ffOutLog.Close()
|
||||
}
|
||||
if sp.ffErrLog != nil {
|
||||
_ = sp.ffErrLog.Close()
|
||||
}
|
||||
delete(m.procs, id)
|
||||
}
|
||||
|
||||
func (m *Media) monitorLoop() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.stopMon:
|
||||
return
|
||||
case <-t.C:
|
||||
m.mu.Lock()
|
||||
ids := make([]int64, 0, len(m.procs))
|
||||
for id := range m.procs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
m.refreshHealth(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Media) refreshHealth(id int64) {
|
||||
m.mu.Lock()
|
||||
sp, ok := m.procs[id]
|
||||
m.mu.Unlock()
|
||||
if !ok || sp.stopping {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
nreDead := sp.nreDead
|
||||
ffDead := sp.ffDead
|
||||
m.mu.Unlock()
|
||||
|
||||
playPath := "/hls/" + sp.name + "/index.m3u8"
|
||||
uptime := time.Since(sp.started).Seconds()
|
||||
pid := int64(0)
|
||||
if sp.nre != nil && sp.nre.Process != nil {
|
||||
pid = int64(sp.nre.Process.Pid)
|
||||
}
|
||||
|
||||
if nreDead {
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "down", PID: 0, PlayPath: playPath, UptimeS: uptime,
|
||||
LastError: "NRE process exited — see logs/" + sp.name + "-nre.log",
|
||||
})
|
||||
m.cleanupProc(id)
|
||||
return
|
||||
}
|
||||
|
||||
fi, err := os.Stat(sp.hlsIndex)
|
||||
if err != nil {
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: "starting", PID: pid, PlayPath: playPath, UptimeS: uptime,
|
||||
LastError: "waiting for HLS playlist",
|
||||
})
|
||||
if ffDead {
|
||||
// restart ffmpeg packager if TS still growing
|
||||
go m.restartFFmpeg(sp)
|
||||
}
|
||||
return
|
||||
}
|
||||
age := time.Since(fi.ModTime()).Seconds()
|
||||
health := "ok"
|
||||
lastErr := ""
|
||||
if age > 45 {
|
||||
health = "down"
|
||||
lastErr = fmt.Sprintf("playlist stale (%.0fs)", age)
|
||||
}
|
||||
_ = m.Store.SetRuntime(id, db.RuntimePatch{
|
||||
Health: health, PID: pid, PlayPath: playPath, UptimeS: uptime,
|
||||
PlaylistAgeS: age, LastError: lastErr,
|
||||
})
|
||||
if ffDead && health != "down" {
|
||||
go m.restartFFmpeg(sp)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Media) restartFFmpeg(sp *streamProc) {
|
||||
m.mu.Lock()
|
||||
cur, ok := m.procs[sp.id]
|
||||
if !ok || cur.stopping || !cur.ffDead {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Claim the restart slot so monitorLoop does not spawn duplicates.
|
||||
cur.ffDead = false
|
||||
if cur.ffmpeg != nil {
|
||||
_ = killProcess(cur.ffmpeg)
|
||||
}
|
||||
tsPath := cur.tsPath
|
||||
m.mu.Unlock()
|
||||
|
||||
minTS := sp.tsReadyBytes
|
||||
if st, err := os.Stat(tsPath); err != nil || st.Size() < minTS {
|
||||
m.mu.Lock()
|
||||
if cur, ok := m.procs[sp.id]; ok {
|
||||
cur.ffDead = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.logf("stream %s: restarting ffmpeg HLS packager", sp.name)
|
||||
if err := m.startFFmpegHLS(sp); err != nil {
|
||||
m.logf("stream %s: ffmpeg restart failed: %v", sp.name, err)
|
||||
m.mu.Lock()
|
||||
if cur, ok := m.procs[sp.id]; ok {
|
||||
cur.ffDead = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Media) logf(format string, args ...any) {
|
||||
m.Log.Printf("supervisor: "+format, args...)
|
||||
}
|
||||
|
||||
func headerMap(headersJSON string) map[string]string {
|
||||
headersJSON = strings.TrimSpace(headersJSON)
|
||||
if headersJSON == "" || headersJSON == "{}" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(headersJSON), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range m {
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
if k != "" && v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func headerFlags(headersJSON string) []string {
|
||||
m := headerMap(headersJSON)
|
||||
out := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
out = append(out, k+": "+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeKeysFile turns a DB key field (one or many KID:KEY lines, or
|
||||
// comma/semicolon separated) into an NRE --key-text-file body.
|
||||
func normalizeKeysFile(raw string) string {
|
||||
raw = strings.ReplaceAll(raw, ",", "\n")
|
||||
raw = strings.ReplaceAll(raw, ";", "\n")
|
||||
var lines []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.Count(line, ":") != 1 {
|
||||
continue
|
||||
}
|
||||
if seen[line] {
|
||||
continue
|
||||
}
|
||||
seen[line] = true
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return strings.TrimSpace(raw) + "\n"
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func killProcess(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if runtime.GOOS == "windows" {
|
||||
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
||||
return nil
|
||||
}
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func prependPathEnv(env []string, prefix string) []string {
|
||||
if prefix == "" {
|
||||
return env
|
||||
}
|
||||
out := make([]string, 0, len(env)+1)
|
||||
found := false
|
||||
for _, e := range env {
|
||||
if len(e) >= 5 && strings.EqualFold(e[:5], "PATH=") {
|
||||
out = append(out, "PATH="+prefix+string(os.PathListSeparator)+e[5:])
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if !found {
|
||||
out = append(out, "PATH="+prefix)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// findGrowingTS locates NRE's live-pipe-mux output. Depending on version/flags
|
||||
// it may be saveName.ts or saveName.<lang>.ts (e.g. test.en.ts).
|
||||
func waitPIDsGone(pids []int, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
alive := false
|
||||
for _, pid := range pids {
|
||||
if pid <= 0 {
|
||||
continue
|
||||
}
|
||||
if !processDead(pid) {
|
||||
alive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !alive {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func findGrowingTS(dir, saveName string) string {
|
||||
return findGrowingTSMin(dir, saveName, 256*1024)
|
||||
}
|
||||
|
||||
func findGrowingTSMin(dir, saveName string, minSize int64) string {
|
||||
if minSize <= 0 {
|
||||
minSize = 256 * 1024
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(dir, saveName+".ts"),
|
||||
}
|
||||
if ents, err := os.ReadDir(dir); err == nil {
|
||||
prefix := saveName + "."
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".ts") {
|
||||
continue
|
||||
}
|
||||
if name == saveName+".ts" || strings.HasPrefix(name, prefix) {
|
||||
candidates = append(candidates, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
var best string
|
||||
var bestSize int64
|
||||
for _, p := range candidates {
|
||||
st, err := os.Stat(p)
|
||||
if err != nil || st.Size() < minSize {
|
||||
continue
|
||||
}
|
||||
if st.Size() > bestSize {
|
||||
bestSize = st.Size()
|
||||
best = p
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
20
apps/streamd/internal/supervisor/proc_unix.go
Normal file
20
apps/streamd/internal/supervisor/proc_unix.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build !windows
|
||||
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func hideWindow(cmd *exec.Cmd) {}
|
||||
|
||||
func processDead(pid int) bool {
|
||||
p, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
err = p.Signal(syscall.Signal(0))
|
||||
return err != nil
|
||||
}
|
||||
35
apps/streamd/internal/supervisor/proc_windows.go
Normal file
35
apps/streamd/internal/supervisor/proc_windows.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//go:build windows
|
||||
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
|
||||
}
|
||||
}
|
||||
|
||||
func processDead(pid int) bool {
|
||||
p, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
// On Windows, FindProcess always succeeds; OpenProcess is needed.
|
||||
// tasklist-style: try duplicate handle via Signal is unsupported.
|
||||
// Use Windows API indirectly: if Wait with timeout 0 isn't available,
|
||||
// check via tasklist is heavy — use syscall OpenProcess.
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
h, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
_ = p
|
||||
syscall.CloseHandle(h)
|
||||
return false
|
||||
}
|
||||
71
apps/streamd/internal/supervisor/stub.go
Normal file
71
apps/streamd/internal/supervisor/stub.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package supervisor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"drmdecryption/apps/streamd/internal/db"
|
||||
)
|
||||
|
||||
// Stub marks streams as starting/stopped without spawning media workers.
|
||||
// Replaced by a real NRE/ffmpeg supervisor in Phase 2.
|
||||
type Stub struct {
|
||||
Store *db.Store
|
||||
mu sync.Mutex
|
||||
pids map[int64]int
|
||||
}
|
||||
|
||||
func NewStub(store *db.Store) *Stub {
|
||||
return &Stub{Store: store, pids: map[int64]int{}}
|
||||
}
|
||||
|
||||
func (s *Stub) Start(id int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st, err := s.Store.GetStream(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.MPD == "" || st.Key == "" {
|
||||
_, err = s.Store.DB.Exec(`
|
||||
INSERT INTO stream_runtime(stream_id,health,play_path,last_error,updated_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(stream_id) DO UPDATE SET health=excluded.health, last_error=excluded.last_error, updated_at=excluded.updated_at`,
|
||||
id, "down", "/hls/"+st.Name+"/index.m3u8", "missing mpd/key - capture required", time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
pid := 10000 + int(id)
|
||||
s.pids[id] = pid
|
||||
_, err = s.Store.DB.Exec(`
|
||||
INSERT INTO stream_runtime(stream_id,health,pid,play_path,uptime_s,playlist_age_s,last_error,updated_at)
|
||||
VALUES(?,?,?,?,0,1,'',?)
|
||||
ON CONFLICT(stream_id) DO UPDATE SET
|
||||
health=excluded.health, pid=excluded.pid, play_path=excluded.play_path,
|
||||
playlist_age_s=excluded.playlist_age_s, last_error='', updated_at=excluded.updated_at`,
|
||||
id, "ok", pid, "/hls/"+st.Name+"/index.m3u8", time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Stub) Stop(id int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.pids, id)
|
||||
st, err := s.Store.GetStream(id)
|
||||
name := fmt.Sprintf("%d", id)
|
||||
if err == nil {
|
||||
name = st.Name
|
||||
}
|
||||
_, err = s.Store.DB.Exec(`
|
||||
INSERT INTO stream_runtime(stream_id,health,pid,play_path,uptime_s,bitrate_mbps,playlist_age_s,last_error,updated_at)
|
||||
VALUES(?,?,0,?,0,0,0,'',?)
|
||||
ON CONFLICT(stream_id) DO UPDATE SET
|
||||
health='stopped', pid=0, uptime_s=0, bitrate_mbps=0, playlist_age_s=0, last_error='', updated_at=excluded.updated_at`,
|
||||
id, "stopped", "/hls/"+name+"/index.m3u8", time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Stub) Restart(id int64) error {
|
||||
_ = s.Stop(id)
|
||||
return s.Start(id)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue