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:
404errordeveloper 2026-10-06 00:25:35 +02:00
commit 2fa8f2435f
121 changed files with 17802 additions and 0 deletions

View 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")
}

View 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 }

View 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
}

View 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
}

View 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
}

View 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)
}