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

65
apps/streamd/README.md Normal file
View file

@ -0,0 +1,65 @@
# streamd
Control plane: REST API, SQLite, dashboard and media supervisor. Hosted by the
single binary as `drm serve`.
```text
apps/streamd/
servecmd/ the command body, imported by apps/cli
internal/api HTTP handlers
internal/db SQLite store
internal/ui dashboard (index.html, app.js, app.css)
internal/supervisor downloader + ffmpeg supervision
internal/ws agent heartbeat
```
## Run
```bash
./bin/drm serve --bind 127.0.0.1:8083 --data .cache/streamd --token SECRET
```
Port 8083 by default, to stay clear of anything already on 8080/8081. Media tools
resolve from flags, then env (`NRE_PATH` / `FFMPEG_PATH` / `MP4DECRYPT_PATH`), then
`bin/`, then `PATH` — no machine-specific paths are baked in.
Open `http://HOST:8083/` and put the token in the header box for mutating actions.
## API
| Method | Path | Notes |
|---|---|---|
| GET | `/api/health` | liveness + uptime |
| GET | `/api/apps` | compiled-in app modules, channels, registered rewriters |
| GET | `/api/streams` | list + runtime (the agent polls this) |
| POST | `/api/streams` | create |
| GET/PATCH/DELETE | `/api/streams/:id` | detail / edit / delete |
| POST | `/api/streams/:id/start\|stop\|restart` | desired state |
| POST | `/api/streams/:id/credentials` | `{mpd,key,pssh?,auth?,pid?}`, restarts if enabled |
| POST | `/api/streams/:id/claim` | agent lease (`agent_id`, TTL ~3m) |
| POST | `/api/streams/:id/claim/release` | release lease |
Mutating routes need `Authorization: Bearer TOKEN` (or `X-Streamd-Token`, or
`?token=`). HLS output is served from `--data/www` at `/hls/<name>/index.m3u8`.
## No provider knowledge
The schema has no provider defaults, and the dashboard's app/channel pickers come
from `/api/apps`. Per-stream downloader settings resolve at start time:
```text
stored row -> the stream's app module (app.StreamDefaults) -> neutral defaults
```
The manifest rewriter is a registry lookup on the stream's `rewriter` column, and
HLS is detected from the manifest shape — not from an app or stream name.
## Status
| Area | State |
|---|---|
| serve + SQLite + CRUD UI + claims + credentials | done |
| `/api/apps` + module-driven defaults | done |
| Real downloader/ffmpeg workers + playlist health | partial — least exercised path |
**Full guide: [docs/streamd.md](../../docs/streamd.md)**

23
apps/streamd/go.mod Normal file
View file

@ -0,0 +1,23 @@
module drmdecryption/apps/streamd
go 1.25.0
require (
drmdecryption v0.0.0
github.com/gorilla/websocket v1.5.3
modernc.org/sqlite v1.34.5
)
replace drmdecryption => ../pkg
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)

45
apps/streamd/go.sum Normal file
View file

@ -0,0 +1,45 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -0,0 +1,326 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"drmdecryption/apps/streamd/internal/db"
)
type Supervisor interface {
Start(id int64) error
Stop(id int64) error
Restart(id int64) error
}
type Handler struct {
Store *db.Store
Token string
Supervisor Supervisor
StartedAt time.Time
}
func (h *Handler) Mount(mux *http.ServeMux) {
mux.HandleFunc("/api/health", h.health)
mux.HandleFunc("/api/streams", h.streams)
mux.HandleFunc("/api/streams/", h.streamAction)
mux.HandleFunc("/api/apps", h.apps)
}
func (h *Handler) auth(w http.ResponseWriter, r *http.Request) bool {
if h.Token == "" {
return true
}
if r.Method == http.MethodGet {
return true
}
auth := r.Header.Get("Authorization")
tok := strings.TrimPrefix(auth, "Bearer ")
if tok == "" {
tok = r.Header.Get("X-Streamd-Token")
}
if tok == "" {
tok = r.URL.Query().Get("token")
}
if tok != h.Token {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return false
}
return true
}
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"service": "streamd",
"uptime_s": time.Since(h.StartedAt).Seconds(),
"started_at": h.StartedAt.UTC().Format(time.RFC3339),
})
}
func (h *Handler) streams(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
list, err := h.Store.ListStreams()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if list == nil {
list = []db.Stream{}
}
writeJSON(w, http.StatusOK, map[string]any{"streams": list})
case http.MethodPost:
if !h.auth(w, r) {
return
}
var in db.CreateStream
if err := readJSON(r, &in); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
st, err := h.Store.CreateStream(in)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, st)
default:
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (h *Handler) streamAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/streams/")
path = strings.Trim(path, "/")
if path == "" {
h.streams(w, r)
return
}
parts := strings.Split(path, "/")
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
action := ""
if len(parts) > 1 {
action = parts[1]
}
sub := ""
if len(parts) > 2 {
sub = parts[2]
}
switch {
case action == "" && r.Method == http.MethodGet:
st, err := h.Store.GetStream(id)
if err != nil {
writeErr(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, st)
case action == "" && (r.Method == http.MethodPatch || r.Method == http.MethodPut):
if !h.auth(w, r) {
return
}
var in db.PatchStream
if err := readJSON(r, &in); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
before, _ := h.Store.GetStream(id)
st, err := h.Store.PatchStream(id, in)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
// Editing mpd/key/headers must tear down the live media worker and
// bring up a fresh instance with the new credentials.
if h.Supervisor != nil && credentialsChanged(before, st, in) {
if st.Enabled {
_ = h.Supervisor.Restart(id)
} else {
_ = h.Supervisor.Stop(id)
}
st, _ = h.Store.GetStream(id)
}
writeJSON(w, http.StatusOK, st)
case action == "" && r.Method == http.MethodDelete:
if !h.auth(w, r) {
return
}
if h.Supervisor != nil {
_ = h.Supervisor.Stop(id)
}
if err := h.Store.DeleteStream(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
case action == "start" && r.Method == http.MethodPost:
if !h.auth(w, r) {
return
}
st, err := h.Store.SetEnabled(id, true)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if h.Supervisor != nil {
if err := h.Supervisor.Start(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
st, _ = h.Store.GetStream(id)
}
writeJSON(w, http.StatusOK, st)
case action == "stop" && r.Method == http.MethodPost:
if !h.auth(w, r) {
return
}
if h.Supervisor != nil {
_ = h.Supervisor.Stop(id)
}
st, err := h.Store.SetEnabled(id, false)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, st)
case action == "restart" && r.Method == http.MethodPost:
if !h.auth(w, r) {
return
}
st, err := h.Store.SetEnabled(id, true)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
if h.Supervisor != nil {
if err := h.Supervisor.Restart(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
st, _ = h.Store.GetStream(id)
}
writeJSON(w, http.StatusOK, st)
case action == "credentials" && r.Method == http.MethodPost:
if !h.auth(w, r) {
return
}
var cred db.Credentials
if err := readJSON(r, &cred); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
st, err := h.Store.SetCredentials(id, cred)
if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
_ = h.Store.ReleaseClaim(id, "")
// Always kill the old media worker; start a fresh one when enabled.
if h.Supervisor != nil {
if st.Enabled {
_ = h.Supervisor.Restart(id)
} else {
_ = h.Supervisor.Stop(id)
}
st, _ = h.Store.GetStream(id)
}
writeJSON(w, http.StatusOK, st)
case action == "claim" && sub == "" && r.Method == http.MethodPost:
if !h.auth(w, r) {
return
}
var body struct {
AgentID string `json:"agent_id"`
TTLSec int `json:"ttl_sec"`
}
_ = readJSON(r, &body)
ttl := time.Duration(body.TTLSec) * time.Second
st, err := h.Store.Claim(id, body.AgentID, ttl)
if err != nil {
if err.Error() == "claimed" {
writeErr(w, http.StatusConflict, "already claimed")
return
}
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, st)
case action == "claim" && sub == "release" && r.Method == http.MethodPost:
if !h.auth(w, r) {
return
}
var body struct {
AgentID string `json:"agent_id"`
}
_ = readJSON(r, &body)
if err := h.Store.ReleaseClaim(id, body.AgentID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
writeErr(w, http.StatusNotFound, "not found")
}
}
func readJSON(r *http.Request, dst any) error {
defer r.Body.Close()
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return err
}
if len(b) == 0 {
return errors.New("empty body")
}
return json.Unmarshal(b, dst)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]any{"error": msg})
}
// credentialsChanged is true when the patch touched mpd, key, or headers that
// the live NRE/ffmpeg worker is already using.
func credentialsChanged(before, after db.Stream, in db.PatchStream) bool {
if in.MPD != nil && strings.TrimSpace(before.MPD) != strings.TrimSpace(after.MPD) {
return true
}
if in.Key != nil && strings.TrimSpace(before.Key) != strings.TrimSpace(after.Key) {
return true
}
if in.HeadersJSON != nil && strings.TrimSpace(before.HeadersJSON) != strings.TrimSpace(after.HeadersJSON) {
return true
}
return false
}

View file

@ -0,0 +1,47 @@
package api
import (
"net/http"
"drmdecryption/app"
"drmdecryption/mpd"
)
// appInfo describes one compiled-in app module to the dashboard, so the UI never
// has to hardcode a provider or channel name.
type appInfo struct {
Name string `json:"name"`
Channels []channelInfo `json:"channels"`
Rewriter string `json:"rewriter"`
HasConfig bool `json:"has_config"`
}
type channelInfo struct {
ID string `json:"id"`
Label string `json:"label"`
}
// apps answers GET /api/apps: which providers this binary was built with.
func (h *Handler) apps(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
out := []appInfo{}
for _, a := range app.All() {
info := appInfo{Name: a.Name(), Channels: []channelInfo{}, Rewriter: "none"}
for _, ch := range a.Channels() {
info.Channels = append(info.Channels, channelInfo{ID: ch.ID, Label: ch.Label})
}
// A module with no local values file has no channels to offer yet.
info.HasConfig = len(info.Channels) > 0
if sd, ok := a.(app.StreamDefaults); ok {
info.Rewriter = sd.RewriterName()
}
out = append(out, info)
}
writeJSON(w, http.StatusOK, map[string]any{
"apps": out,
"rewriters": mpd.Names(),
})
}

View file

@ -0,0 +1,504 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
type Store struct {
DB *sql.DB
}
type Stream struct {
ID int64 `json:"id"`
Name string `json:"name"`
Title string `json:"title"`
App string `json:"app"`
Channel string `json:"channel"`
MPD string `json:"mpd"`
Key string `json:"key"`
HeadersJSON string `json:"headers_json"`
VideoSelect string `json:"video_select"`
AudioSelect string `json:"audio_select"`
Enabled bool `json:"enabled"`
Rewriter string `json:"rewriter"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Health string `json:"health,omitempty"`
PID int64 `json:"pid,omitempty"`
PlayPath string `json:"play_path,omitempty"`
UptimeS float64 `json:"uptime_s,omitempty"`
BitrateMbps float64 `json:"bitrate_mbps,omitempty"`
PlaylistAgeS float64 `json:"playlist_age_s,omitempty"`
LastError string `json:"last_error,omitempty"`
RuntimeAt string `json:"runtime_updated_at,omitempty"`
ClaimedBy string `json:"claimed_by,omitempty"`
ClaimExp string `json:"claim_expires_at,omitempty"`
}
type CreateStream struct {
Name string `json:"name"`
Title string `json:"title"`
App string `json:"app"`
Channel string `json:"channel"`
MPD string `json:"mpd"`
Key string `json:"key"`
HeadersJSON string `json:"headers_json"`
VideoSelect string `json:"video_select"`
AudioSelect string `json:"audio_select"`
Rewriter string `json:"rewriter"`
Enabled *bool `json:"enabled"`
}
type PatchStream struct {
Title *string `json:"title"`
App *string `json:"app"`
Channel *string `json:"channel"`
MPD *string `json:"mpd"`
Key *string `json:"key"`
HeadersJSON *string `json:"headers_json"`
VideoSelect *string `json:"video_select"`
AudioSelect *string `json:"audio_select"`
Rewriter *string `json:"rewriter"`
Enabled *bool `json:"enabled"`
}
type Credentials struct {
MPD string `json:"mpd"`
Key string `json:"key"`
PSSH string `json:"pssh,omitempty"`
Auth string `json:"auth,omitempty"`
PID string `json:"pid,omitempty"`
}
func Open(dataDir string) (*Store, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dataDir, "streamd.db")
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", filepath.ToSlash(path))
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(1)
s := &Store{DB: sqlDB}
if err := s.migrate(); err != nil {
_ = sqlDB.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error {
return s.DB.Close()
}
func (s *Store) migrate() error {
_, err := s.DB.Exec(`
CREATE TABLE IF NOT EXISTS streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
app TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL DEFAULT '',
mpd TEXT NOT NULL DEFAULT '',
key_kid_key TEXT NOT NULL DEFAULT '',
headers_json TEXT NOT NULL DEFAULT '{}',
video_select TEXT NOT NULL DEFAULT '',
audio_select TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
rewriter TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS stream_runtime (
stream_id INTEGER PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
health TEXT NOT NULL DEFAULT 'stopped',
pid INTEGER NOT NULL DEFAULT 0,
play_path TEXT NOT NULL DEFAULT '',
uptime_s REAL NOT NULL DEFAULT 0,
bitrate_mbps REAL NOT NULL DEFAULT 0,
playlist_age_s REAL NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS agent_claims (
stream_id INTEGER PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
agent_id TEXT NOT NULL,
claimed_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
`)
return err
}
func now() string {
return time.Now().UTC().Format(time.RFC3339)
}
func slug(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_' || r == ' ':
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), "-")
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
return out
}
func (s *Store) ListStreams() ([]Stream, error) {
rows, err := s.DB.Query(`
SELECT s.id, s.name, s.title, s.app, s.channel, s.mpd, s.key_kid_key, s.headers_json,
s.video_select, s.audio_select, s.enabled, s.rewriter, s.created_at, s.updated_at,
COALESCE(r.health,''), COALESCE(r.pid,0), COALESCE(r.play_path,''),
COALESCE(r.uptime_s,0), COALESCE(r.bitrate_mbps,0), COALESCE(r.playlist_age_s,0),
COALESCE(r.last_error,''), COALESCE(r.updated_at,''),
COALESCE(c.agent_id,''), COALESCE(c.expires_at,'')
FROM streams s
LEFT JOIN stream_runtime r ON r.stream_id = s.id
LEFT JOIN agent_claims c ON c.stream_id = s.id
ORDER BY s.name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Stream
for rows.Next() {
st, err := scanStream(rows)
if err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
func (s *Store) GetStream(id int64) (Stream, error) {
row := s.DB.QueryRow(`
SELECT s.id, s.name, s.title, s.app, s.channel, s.mpd, s.key_kid_key, s.headers_json,
s.video_select, s.audio_select, s.enabled, s.rewriter, s.created_at, s.updated_at,
COALESCE(r.health,''), COALESCE(r.pid,0), COALESCE(r.play_path,''),
COALESCE(r.uptime_s,0), COALESCE(r.bitrate_mbps,0), COALESCE(r.playlist_age_s,0),
COALESCE(r.last_error,''), COALESCE(r.updated_at,''),
COALESCE(c.agent_id,''), COALESCE(c.expires_at,'')
FROM streams s
LEFT JOIN stream_runtime r ON r.stream_id = s.id
LEFT JOIN agent_claims c ON c.stream_id = s.id
WHERE s.id = ?`, id)
return scanStream(row)
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanStream(row rowScanner) (Stream, error) {
var st Stream
var enabled int
err := row.Scan(
&st.ID, &st.Name, &st.Title, &st.App, &st.Channel, &st.MPD, &st.Key, &st.HeadersJSON,
&st.VideoSelect, &st.AudioSelect, &enabled, &st.Rewriter, &st.CreatedAt, &st.UpdatedAt,
&st.Health, &st.PID, &st.PlayPath, &st.UptimeS, &st.BitrateMbps, &st.PlaylistAgeS,
&st.LastError, &st.RuntimeAt, &st.ClaimedBy, &st.ClaimExp,
)
if err != nil {
return st, err
}
st.Enabled = enabled != 0
if st.Health == "" {
st.Health = "stopped"
}
if st.PlayPath == "" {
st.PlayPath = "/hls/" + st.Name + "/index.m3u8"
}
// Hide expired claims in API responses (ExpireClaims cleans them shortly after).
if st.ClaimExp != "" {
if exp, e := time.Parse(time.RFC3339, st.ClaimExp); e == nil && !exp.After(time.Now().UTC()) {
st.ClaimedBy = ""
st.ClaimExp = ""
}
}
return st, nil
}
func (s *Store) CreateStream(in CreateStream) (Stream, error) {
name := slug(in.Name)
if name == "" {
return Stream{}, fmt.Errorf("name required")
}
if in.HeadersJSON == "" {
in.HeadersJSON = "{}"
}
if !json.Valid([]byte(in.HeadersJSON)) {
return Stream{}, fmt.Errorf("headers_json must be valid JSON")
}
// video_select / audio_select / rewriter are left empty on purpose: the
// supervisor resolves them from the stream's app module at start time, so no
// provider's preferences are baked into the schema.
if in.Title == "" {
in.Title = name
}
enabled := 0
if in.Enabled != nil && *in.Enabled {
enabled = 1
}
ts := now()
res, err := s.DB.Exec(`
INSERT INTO streams(name,title,app,channel,mpd,key_kid_key,headers_json,video_select,audio_select,enabled,rewriter,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
name, in.Title, in.App, in.Channel, in.MPD, in.Key, in.HeadersJSON,
in.VideoSelect, in.AudioSelect, enabled, in.Rewriter, ts, ts)
if err != nil {
return Stream{}, err
}
id, _ := res.LastInsertId()
_, _ = s.DB.Exec(`INSERT OR IGNORE INTO stream_runtime(stream_id,health,play_path,updated_at) VALUES(?,?,?,?)`,
id, "stopped", "/hls/"+name+"/index.m3u8", ts)
return s.GetStream(id)
}
func (s *Store) PatchStream(id int64, in PatchStream) (Stream, error) {
cur, err := s.GetStream(id)
if err != nil {
return Stream{}, err
}
if in.Title != nil {
cur.Title = *in.Title
}
if in.App != nil {
cur.App = *in.App
}
if in.Channel != nil {
cur.Channel = *in.Channel
}
if in.MPD != nil {
cur.MPD = *in.MPD
}
if in.Key != nil {
cur.Key = *in.Key
}
if in.HeadersJSON != nil {
if !json.Valid([]byte(*in.HeadersJSON)) {
return Stream{}, fmt.Errorf("headers_json must be valid JSON")
}
cur.HeadersJSON = *in.HeadersJSON
}
if in.VideoSelect != nil {
cur.VideoSelect = *in.VideoSelect
}
if in.AudioSelect != nil {
cur.AudioSelect = *in.AudioSelect
}
if in.Rewriter != nil {
cur.Rewriter = *in.Rewriter
}
enabled := 0
if cur.Enabled {
enabled = 1
}
if in.Enabled != nil {
if *in.Enabled {
enabled = 1
} else {
enabled = 0
}
}
ts := now()
_, err = s.DB.Exec(`
UPDATE streams SET title=?, app=?, channel=?, mpd=?, key_kid_key=?, headers_json=?,
video_select=?, audio_select=?, enabled=?, rewriter=?, updated_at=? WHERE id=?`,
cur.Title, cur.App, cur.Channel, cur.MPD, cur.Key, cur.HeadersJSON,
cur.VideoSelect, cur.AudioSelect, enabled, cur.Rewriter, ts, id)
if err != nil {
return Stream{}, err
}
return s.GetStream(id)
}
func (s *Store) DeleteStream(id int64) error {
_, err := s.DB.Exec(`DELETE FROM streams WHERE id=?`, id)
return err
}
func (s *Store) SetEnabled(id int64, enabled bool) (Stream, error) {
v := 0
if enabled {
v = 1
}
_, err := s.DB.Exec(`UPDATE streams SET enabled=?, updated_at=? WHERE id=?`, v, now(), id)
if err != nil {
return Stream{}, err
}
health := "stopped"
if enabled {
health = "starting"
}
_, _ = s.DB.Exec(`
INSERT INTO stream_runtime(stream_id,health,play_path,updated_at)
VALUES(?,?, (SELECT '/hls/'||name||'/index.m3u8' FROM streams WHERE id=?), ?)
ON CONFLICT(stream_id) DO UPDATE SET health=excluded.health, play_path=excluded.play_path, updated_at=excluded.updated_at`,
id, health, id, now())
return s.GetStream(id)
}
func (s *Store) SetCredentials(id int64, cred Credentials) (Stream, error) {
if strings.TrimSpace(cred.MPD) == "" || strings.TrimSpace(cred.Key) == "" {
return Stream{}, fmt.Errorf("mpd and key required")
}
_, err := s.DB.Exec(`UPDATE streams SET mpd=?, key_kid_key=?, updated_at=? WHERE id=?`,
strings.TrimSpace(cred.MPD), strings.TrimSpace(cred.Key), now(), id)
if err != nil {
return Stream{}, err
}
return s.GetStream(id)
}
func (s *Store) Claim(id int64, agentID string, ttl time.Duration) (Stream, error) {
if agentID == "" {
agentID = "agent"
}
if ttl <= 0 {
ttl = 90 * time.Second
}
// Drop expired rows first so a dead agent cannot block forever.
_, _ = s.ExpireClaims()
st, err := s.GetStream(id)
if err != nil {
return Stream{}, err
}
nowT := time.Now().UTC()
if st.ClaimedBy != "" && st.ClaimExp != "" {
if exp, e := time.Parse(time.RFC3339, st.ClaimExp); e == nil && exp.After(nowT) && st.ClaimedBy != agentID {
return Stream{}, fmt.Errorf("claimed")
}
}
claimedAt := nowT.Format(time.RFC3339)
expires := nowT.Add(ttl).Format(time.RFC3339)
_, err = s.DB.Exec(`
INSERT INTO agent_claims(stream_id,agent_id,claimed_at,expires_at) VALUES(?,?,?,?)
ON CONFLICT(stream_id) DO UPDATE SET agent_id=excluded.agent_id, claimed_at=excluded.claimed_at, expires_at=excluded.expires_at`,
id, agentID, claimedAt, expires)
if err != nil {
return Stream{}, err
}
return s.GetStream(id)
}
func (s *Store) ReleaseClaim(id int64, agentID string) error {
if agentID == "" {
_, err := s.DB.Exec(`DELETE FROM agent_claims WHERE stream_id=?`, id)
return err
}
_, err := s.DB.Exec(`DELETE FROM agent_claims WHERE stream_id=? AND agent_id=?`, id, agentID)
return err
}
// ExpireClaims deletes claims whose expires_at is in the past. Returns rows removed.
func (s *Store) ExpireClaims() (int64, error) {
res, err := s.DB.Exec(`DELETE FROM agent_claims WHERE expires_at <> '' AND expires_at < ?`, now())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// RenewAgentClaims extends expires_at for every claim held by agentID.
func (s *Store) RenewAgentClaims(agentID string, ttl time.Duration) (int64, error) {
if agentID == "" {
return 0, nil
}
if ttl <= 0 {
ttl = 90 * time.Second
}
exp := time.Now().UTC().Add(ttl).Format(time.RFC3339)
res, err := s.DB.Exec(`UPDATE agent_claims SET expires_at=? WHERE agent_id=?`, exp, agentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ReleaseAgentClaims drops every claim for agentID (used on WS disconnect).
func (s *Store) ReleaseAgentClaims(agentID string) (int64, error) {
if agentID == "" {
return 0, nil
}
res, err := s.DB.Exec(`DELETE FROM agent_claims WHERE agent_id=?`, agentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// RuntimePatch updates live worker fields for a stream.
type RuntimePatch struct {
Health string
PID int64
PlayPath string
UptimeS float64
BitrateMbps float64
PlaylistAgeS float64
LastError string
}
func (s *Store) SetRuntime(id int64, p RuntimePatch) error {
play := p.PlayPath
if play == "" {
st, err := s.GetStream(id)
if err == nil {
play = "/hls/" + st.Name + "/index.m3u8"
}
}
_, err := s.DB.Exec(`
INSERT INTO stream_runtime(stream_id,health,pid,play_path,uptime_s,bitrate_mbps,playlist_age_s,last_error,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(stream_id) DO UPDATE SET
health=excluded.health,
pid=excluded.pid,
play_path=excluded.play_path,
uptime_s=excluded.uptime_s,
bitrate_mbps=excluded.bitrate_mbps,
playlist_age_s=excluded.playlist_age_s,
last_error=excluded.last_error,
updated_at=excluded.updated_at`,
id, p.Health, p.PID, play, p.UptimeS, p.BitrateMbps, p.PlaylistAgeS, p.LastError, now())
return err
}
// NeedsCapture reports whether an enabled stream looks down / missing creds.
func (st Stream) NeedsCapture() bool {
if !st.Enabled {
return false
}
if strings.TrimSpace(st.MPD) == "" || strings.TrimSpace(st.Key) == "" {
return true
}
switch st.Health {
case "down", "stopped", "starting", "":
return true
}
if st.PlaylistAgeS > 30 {
return true
}
return false
}

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

View file

@ -0,0 +1,106 @@
:root {
--bg: #0f1115;
--card: #171a21;
--border: #2a303c;
--text: #e8eaed;
--muted: #9aa0a6;
--accent: #7aa2ff;
--ok: #3dd68c;
--degraded: #f5a524;
--down: #f07178;
--stopped: #6b7280;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.45 system-ui, Segoe UI, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
h1 { margin: 0; font-size: 18px; font-weight: 600; }
h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
.header-right { display: flex; gap: 16px; align-items: center; }
.muted { color: var(--muted); }
.card {
margin: 16px 20px;
padding: 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
}
.span2 { grid-column: span 2; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
input, select, button, textarea {
font: inherit;
color: var(--text);
background: #0c0e12;
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
}
textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.4;
resize: vertical;
min-height: 4.5em;
white-space: pre;
}
button {
background: #243044;
border-color: #334155;
cursor: pointer;
}
button:hover { border-color: var(--accent); }
button.ghost { background: transparent; }
button.danger { border-color: #7f1d1d; color: #fecaca; }
.actions { display: flex; gap: 8px; align-items: end; }
.token-box input { width: 180px; }
.row-between { display: flex; justify-content: space-between; align-items: center; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { color: var(--muted); font-weight: 500; font-size: 12px; }
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
background: #222;
}
.pill.ok { color: var(--ok); }
.pill.degraded { color: var(--degraded); }
.pill.down, .pill.starting { color: var(--down); }
.pill.stopped { color: var(--stopped); }
.pill.claim { color: #c4b5fd; background: #1f1635; }
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.row-actions button { padding: 4px 8px; font-size: 12px; }
.msg { min-height: 1.2em; margin: 8px 0 0; }
.msg.err { color: var(--down); }
a { color: var(--accent); }
dialog {
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
border-radius: 8px;
padding: 16px;
min-width: min(560px, 92vw);
}
dialog::backdrop { background: rgba(0,0,0,.55); }
@media (max-width: 720px) {
.grid { grid-template-columns: 1fr; }
.span2 { grid-column: span 1; }
header { flex-direction: column; align-items: flex-start; gap: 8px; }
}

View file

@ -0,0 +1,286 @@
(() => {
const tokenEl = document.getElementById("token");
const rowsEl = document.getElementById("rows");
const listMsg = document.getElementById("list-msg");
const formMsg = document.getElementById("form-msg");
const uptimeEl = document.getElementById("uptime");
const agentsEl = document.getElementById("agents-status");
const dlg = document.getElementById("edit-dlg");
const editForm = document.getElementById("edit-form");
tokenEl.value = localStorage.getItem("streamd_token") || "";
tokenEl.addEventListener("change", () => {
localStorage.setItem("streamd_token", tokenEl.value.trim());
connectUIWS();
});
function token() {
return tokenEl.value.trim();
}
function headers(json) {
const h = {};
if (json) h["Content-Type"] = "application/json";
const t = token();
if (t) h["Authorization"] = "Bearer " + t;
return h;
}
async function api(path, opts = {}) {
const res = await fetch(path, opts);
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
if (!res.ok) {
const msg = (data && data.error) || res.statusText || "request failed";
throw new Error(msg);
}
return data;
}
function age(s) {
if (s == null || s === 0) return "—";
return Number(s).toFixed(1) + "s";
}
function claimLabel(s) {
if (!s.claimed_by) return '<span class="muted">—</span>';
let exp = "";
if (s.claim_expires_at) {
const ms = Date.parse(s.claim_expires_at) - Date.now();
if (!Number.isNaN(ms)) {
exp = ms > 0 ? ` (${Math.ceil(ms / 1000)}s)` : " (expired)";
}
}
return `<span class="pill claim">${esc(s.claimed_by)}${esc(exp)}</span>`;
}
function render(streams) {
if (!streams.length) {
rowsEl.innerHTML = "";
listMsg.textContent = "No streams yet. Add one above.";
return;
}
listMsg.textContent = streams.length + " stream(s)";
rowsEl.innerHTML = streams.map((s) => {
const need = !s.mpd || !s.key ? " needs capture" : "";
return `<tr>
<td><strong>${esc(s.name)}</strong><div class="muted">${esc(s.title || "")}${need}</div></td>
<td>${esc(s.app)} / ${esc(s.channel)}</td>
<td><span class="pill ${esc(s.health || "stopped")}">${esc(s.health || "stopped")}</span></td>
<td>${claimLabel(s)}</td>
<td>${age(s.playlist_age_s)}</td>
<td><a href="${esc(s.play_path)}" target="_blank" rel="noopener">hls</a></td>
<td class="row-actions">
<button data-act="start" data-id="${s.id}">Start</button>
<button data-act="stop" data-id="${s.id}" class="ghost">Stop</button>
<button data-act="restart" data-id="${s.id}" class="ghost">Restart</button>
<button data-act="edit" data-id="${s.id}" class="ghost">Edit</button>
${s.claimed_by ? `<button data-act="unclaim" data-id="${s.id}" class="ghost">Release</button>` : ""}
<button data-act="delete" data-id="${s.id}" class="danger">Delete</button>
</td>
</tr>`;
}).join("");
}
function esc(v) {
return String(v ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[c]));
}
function renderAgents(list) {
if (!agentsEl) return;
if (!list || !list.length) {
agentsEl.textContent = "agents: none connected";
return;
}
agentsEl.textContent = "agents: " + list.map((a) => a.agent_id).join(", ");
}
async function refresh() {
try {
const [health, list, agents] = await Promise.all([
api("/api/health"),
api("/api/streams"),
api("/api/agents").catch(() => ({ agents: [] })),
]);
uptimeEl.textContent = "up " + Math.floor(health.uptime_s || 0) + "s";
render(list.streams || []);
renderAgents(agents.agents || []);
listMsg.classList.remove("err");
} catch (e) {
listMsg.textContent = e.message;
listMsg.classList.add("err");
}
}
// App/channel pickers come from the binary's compiled-in modules, so the UI
// never hardcodes a provider.
const appSel = document.getElementById("app-select");
const chanSel = document.getElementById("channel-select");
let apps = [];
function fillChannels() {
const a = apps.find((x) => x.name === appSel.value);
chanSel.innerHTML = "";
if (!a || !a.channels.length) {
const o = document.createElement("option");
o.value = "";
o.textContent = a ? "(no module.yaml — no channels)" : "(no apps)";
chanSel.appendChild(o);
return;
}
for (const ch of a.channels) {
const o = document.createElement("option");
o.value = ch.id;
o.textContent = ch.label && ch.label !== ch.id ? ch.id + " — " + ch.label : ch.id;
chanSel.appendChild(o);
}
}
async function loadApps() {
try {
const data = await api("/api/apps");
apps = data.apps || [];
} catch (e) {
apps = [];
}
appSel.innerHTML = "";
if (!apps.length) {
const o = document.createElement("option");
o.value = "";
o.textContent = "(no app modules compiled in)";
appSel.appendChild(o);
}
for (const a of apps) {
const o = document.createElement("option");
o.value = a.name;
o.textContent = a.name;
appSel.appendChild(o);
}
fillChannels();
}
appSel.addEventListener("change", fillChannels);
loadApps();
document.getElementById("refresh").addEventListener("click", refresh);
document.getElementById("create-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
formMsg.textContent = "";
formMsg.classList.remove("err");
const fd = new FormData(ev.target);
const body = Object.fromEntries(fd.entries());
try {
await api("/api/streams", {
method: "POST",
headers: headers(true),
body: JSON.stringify(body),
});
ev.target.reset();
ev.target.headers_json.value = "{}";
fillChannels();
formMsg.textContent = "Created.";
refresh();
} catch (e) {
formMsg.textContent = e.message;
formMsg.classList.add("err");
}
});
rowsEl.addEventListener("click", async (ev) => {
const btn = ev.target.closest("button[data-act]");
if (!btn) return;
const id = btn.dataset.id;
const act = btn.dataset.act;
try {
if (act === "delete") {
if (!confirm("Delete stream #" + id + "?")) return;
await api("/api/streams/" + id, { method: "DELETE", headers: headers(false) });
} else if (act === "unclaim") {
await api("/api/streams/" + id + "/claim/release", {
method: "POST",
headers: headers(true),
body: JSON.stringify({ agent_id: "" }),
});
} else if (act === "edit") {
const st = await api("/api/streams/" + id);
editForm.id.value = st.id;
editForm.title.value = st.title || "";
editForm.channel.value = st.channel || "";
editForm.mpd.value = st.mpd || "";
editForm.key.value = st.key || "";
editForm.headers_json.value = st.headers_json || "{}";
dlg.showModal();
return;
} else {
await api("/api/streams/" + id + "/" + act, {
method: "POST",
headers: headers(false),
});
}
refresh();
} catch (e) {
listMsg.textContent = e.message;
listMsg.classList.add("err");
}
});
document.getElementById("edit-cancel").addEventListener("click", () => dlg.close());
editForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const id = editForm.id.value;
const body = {
title: editForm.title.value,
channel: editForm.channel.value,
mpd: editForm.mpd.value,
key: editForm.key.value,
headers_json: editForm.headers_json.value,
};
try {
await api("/api/streams/" + id, {
method: "PATCH",
headers: headers(true),
body: JSON.stringify(body),
});
dlg.close();
refresh();
} catch (e) {
alert(e.message);
}
});
let uiWS = null;
let uiWSTimer = null;
function connectUIWS() {
if (uiWS) {
try { uiWS.close(); } catch {}
uiWS = null;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(proto + "//" + location.host + "/ws/ui");
uiWS = ws;
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "agents" || msg.type === "agent_online" || msg.type === "agent_offline") {
renderAgents(msg.agents || []);
}
if (msg.type === "claims_changed" || msg.type === "agent_offline") {
refresh();
}
} catch {}
};
ws.onclose = () => {
clearTimeout(uiWSTimer);
uiWSTimer = setTimeout(connectUIWS, 3000);
};
}
refresh();
connectUIWS();
setInterval(refresh, 5000);
})();

View file

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>streamd</title>
<link rel="stylesheet" href="/static/app.css" />
</head>
<body>
<header>
<h1>streamd</h1>
<div class="header-right">
<span id="uptime" class="muted"></span>
<label class="token-box">token <input id="token" type="password" placeholder="STREAMD_TOKEN" autocomplete="off" /></label>
</div>
</header>
<section class="card">
<h2>Add stream</h2>
<form id="create-form" class="grid">
<label>name <input name="name" required placeholder="my-channel" /></label>
<label>title <input name="title" placeholder="My Channel" /></label>
<label>app
<select name="app" id="app-select"></select>
</label>
<label>channel
<select name="channel" id="channel-select"></select>
</label>
<label class="span2">mpd <input name="mpd" placeholder="https://…/manifest.mpd (optional until capture)" /></label>
<label class="span2">key <textarea name="key" rows="4" placeholder="KID:KEY (one per line; multi-key streams need every key)" spellcheck="false"></textarea></label>
<label class="span2">headers JSON <input name="headers_json" value="{}" /></label>
<div class="span2 actions">
<button type="submit">Create</button>
</div>
</form>
<p id="form-msg" class="msg"></p>
</section>
<section class="card">
<div class="row-between">
<h2>Streams</h2>
<div class="row-actions">
<span id="agents-status" class="muted">agents: —</span>
<button type="button" id="refresh" class="ghost">Refresh</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>name</th>
<th>app / channel</th>
<th>health</th>
<th>claim</th>
<th>age</th>
<th>play</th>
<th>actions</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<p id="list-msg" class="msg muted">Loading…</p>
</section>
<dialog id="edit-dlg">
<form method="dialog" id="edit-form" class="grid">
<h2 class="span2">Edit stream</h2>
<input type="hidden" name="id" />
<label>title <input name="title" /></label>
<label>channel <input name="channel" /></label>
<label class="span2">mpd <input name="mpd" /></label>
<label class="span2">key <textarea name="key" rows="4" placeholder="KID:KEY (one per line)" spellcheck="false"></textarea></label>
<label class="span2">headers JSON <input name="headers_json" /></label>
<div class="span2 actions">
<button type="submit" value="save">Save</button>
<button type="button" id="edit-cancel" class="ghost">Cancel</button>
</div>
</form>
</dialog>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,34 @@
package ui
import (
"embed"
"io/fs"
"net/http"
)
//go:embed index.html app.css app.js
var files embed.FS
func Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
http.NotFound(w, r)
return
}
b, err := files.ReadFile("index.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
})
}
func Static() http.Handler {
sub, err := fs.Sub(files, ".")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(sub))
}

View file

@ -0,0 +1,350 @@
package ws
import (
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"drmdecryption/apps/streamd/internal/db"
)
const (
agentHeartbeatEvery = 20 * time.Second
agentStaleAfter = 45 * time.Second
claimRenewTTL = 90 * time.Second
writeWait = 10 * time.Second
pongWait = 60 * time.Second
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Hub tracks agent WebSocket sessions and UI listeners.
type Hub struct {
Store *db.Store
Token string
Log *log.Logger
mu sync.Mutex
agents map[string]*agentConn // agent_id -> conn
uis map[*websocket.Conn]struct{}
}
type agentConn struct {
conn *websocket.Conn
agentID string
lastSeen time.Time
send chan []byte
}
type inbound struct {
Type string `json:"type"`
AgentID string `json:"agent_id,omitempty"`
}
type outbound struct {
Type string `json:"type"`
AgentID string `json:"agent_id,omitempty"`
OK bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Agents []any `json:"agents,omitempty"`
Message string `json:"message,omitempty"`
}
func NewHub(store *db.Store, token string, lg *log.Logger) *Hub {
if lg == nil {
lg = log.Default()
}
return &Hub{
Store: store,
Token: token,
Log: lg,
agents: map[string]*agentConn{},
uis: map[*websocket.Conn]struct{}{},
}
}
func (h *Hub) Mount(mux *http.ServeMux) {
mux.HandleFunc("/ws/agent", h.handleAgent)
mux.HandleFunc("/ws/ui", h.handleUI)
mux.HandleFunc("/api/agents", h.handleAgentsAPI)
}
// RunExpireLoop periodically drops expired claims and dead agent sessions.
func (h *Hub) RunExpireLoop(stop <-chan struct{}) {
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
h.tick()
}
}
}
func (h *Hub) tick() {
n, err := h.Store.ExpireClaims()
if err != nil {
h.Log.Printf("ws: expire claims: %v", err)
} else if n > 0 {
h.Log.Printf("ws: expired %d stale claim(s)", n)
h.broadcastUI(outbound{Type: "claims_changed", Message: "expired"})
}
now := time.Now().UTC()
var stale []string
h.mu.Lock()
for id, a := range h.agents {
if now.Sub(a.lastSeen) > agentStaleAfter {
stale = append(stale, id)
}
}
h.mu.Unlock()
for _, id := range stale {
h.Log.Printf("ws: agent %s heartbeat stale — releasing claims", id)
h.dropAgent(id, true)
}
}
func (h *Hub) authToken(r *http.Request) bool {
if h.Token == "" {
return true
}
tok := r.URL.Query().Get("token")
if tok == "" {
auth := r.Header.Get("Authorization")
tok = strings.TrimPrefix(auth, "Bearer ")
}
if tok == "" {
tok = r.Header.Get("X-Streamd-Token")
}
return tok == h.Token
}
func (h *Hub) handleAgentsAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
writeJSON(w, map[string]any{"agents": h.snapshotAgents()})
}
func (h *Hub) handleUI(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
h.mu.Lock()
h.uis[conn] = struct{}{}
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.uis, conn)
h.mu.Unlock()
_ = conn.Close()
}()
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// Push initial agent list.
h.sendJSON(conn, outbound{Type: "agents", Agents: h.snapshotAgents()})
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}
func (h *Hub) handleAgent(w http.ResponseWriter, r *http.Request) {
if !h.authToken(r) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if agentID == "" {
http.Error(w, `{"error":"agent_id required"}`, http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
ac := &agentConn{
conn: conn,
agentID: agentID,
lastSeen: time.Now().UTC(),
send: make(chan []byte, 8),
}
h.mu.Lock()
if old, ok := h.agents[agentID]; ok {
close(old.send)
_ = old.conn.Close()
}
h.agents[agentID] = ac
h.mu.Unlock()
h.Log.Printf("ws: agent connected id=%s", agentID)
h.broadcastUI(outbound{Type: "agent_online", AgentID: agentID, Agents: h.snapshotAgents()})
go h.agentWriter(ac)
defer h.dropAgent(agentID, true)
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// Greeting + first renew.
h.renew(agentID)
h.queue(ac, outbound{Type: "hello", AgentID: agentID, OK: true})
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
var msg inbound
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch strings.ToLower(msg.Type) {
case "heartbeat", "ping", "hello":
h.mu.Lock()
if cur, ok := h.agents[agentID]; ok {
cur.lastSeen = time.Now().UTC()
}
h.mu.Unlock()
n, err := h.renew(agentID)
if err != nil {
h.queue(ac, outbound{Type: "heartbeat_ack", OK: false, Error: err.Error()})
continue
}
h.queue(ac, outbound{Type: "heartbeat_ack", OK: true, Message: "renewed", AgentID: agentID})
if n > 0 {
h.broadcastUI(outbound{Type: "claims_changed", AgentID: agentID, Message: "renewed"})
}
case "release_all":
_, _ = h.Store.ReleaseAgentClaims(agentID)
h.broadcastUI(outbound{Type: "claims_changed", AgentID: agentID, Message: "released"})
h.queue(ac, outbound{Type: "release_ack", OK: true})
}
}
}
func (h *Hub) renew(agentID string) (int64, error) {
return h.Store.RenewAgentClaims(agentID, claimRenewTTL)
}
func (h *Hub) dropAgent(agentID string, releaseClaims bool) {
h.mu.Lock()
ac, ok := h.agents[agentID]
if ok {
delete(h.agents, agentID)
}
h.mu.Unlock()
if !ok {
return
}
close(ac.send)
_ = ac.conn.Close()
if releaseClaims {
n, err := h.Store.ReleaseAgentClaims(agentID)
if err != nil {
h.Log.Printf("ws: release claims for %s: %v", agentID, err)
} else if n > 0 {
h.Log.Printf("ws: released %d claim(s) for disconnected agent %s", n, agentID)
}
}
h.Log.Printf("ws: agent disconnected id=%s", agentID)
h.broadcastUI(outbound{Type: "agent_offline", AgentID: agentID, Agents: h.snapshotAgents()})
}
func (h *Hub) agentWriter(ac *agentConn) {
ticker := time.NewTicker(agentHeartbeatEvery)
defer ticker.Stop()
for {
select {
case msg, ok := <-ac.send:
if !ok {
return
}
_ = ac.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := ac.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
_ = ac.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := ac.conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(writeWait)); err != nil {
return
}
}
}
}
func (h *Hub) queue(ac *agentConn, msg outbound) {
b, err := json.Marshal(msg)
if err != nil {
return
}
select {
case ac.send <- b:
default:
}
}
func (h *Hub) sendJSON(conn *websocket.Conn, msg outbound) {
b, _ := json.Marshal(msg)
_ = conn.SetWriteDeadline(time.Now().Add(writeWait))
_ = conn.WriteMessage(websocket.TextMessage, b)
}
func (h *Hub) broadcastUI(msg outbound) {
b, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
for conn := range h.uis {
_ = conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
_ = conn.Close()
delete(h.uis, conn)
}
}
}
func (h *Hub) snapshotAgents() []any {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]any, 0, len(h.agents))
now := time.Now().UTC()
for id, a := range h.agents {
out = append(out, map[string]any{
"agent_id": id,
"last_seen_s": now.Sub(a.lastSeen).Seconds(),
"connected": true,
})
}
return out
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}

View file

@ -0,0 +1,245 @@
// Package servecmd is the streamd control plane, exposed as a library so the
// single drm binary can host it as a subcommand.
package servecmd
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"drmdecryption/apps/streamd/internal/api"
"drmdecryption/apps/streamd/internal/db"
"drmdecryption/apps/streamd/internal/supervisor"
"drmdecryption/apps/streamd/internal/ui"
"drmdecryption/apps/streamd/internal/ws"
)
// Run dispatches a streamd subcommand. args[0] is the subcommand name.
func Run(args []string) error {
if len(args) < 1 {
Usage()
return fmt.Errorf("streamd: subcommand required")
}
sub, rest := args[0], args[1:]
switch sub {
case "serve":
serveCmd(rest)
case "worker":
return fmt.Errorf("worker: media is supervised in-process by `serve` now")
case "help", "-h", "--help":
Usage()
default:
Usage()
return fmt.Errorf("streamd: unknown subcommand %q", sub)
}
return nil
}
// Usage prints the streamd subcommand help.
func Usage() {
fmt.Fprintf(os.Stderr, `streamd — Go control plane for DRM restreams
Usage:
streamd serve [flags]
serve flags:
--bind listen address (default 0.0.0.0:8083)
--data data dir for sqlite + www + work (default .cache/streamd)
--token shared bearer token for mutating API (or STREAMD_TOKEN)
--nre path to N_m3u8DL-RE (else NRE_PATH, bin/, PATH)
--ffmpeg path to ffmpeg (else FFMPEG_PATH, bin/, PATH)
--mp4decrypt path to mp4decrypt (else MP4DECRYPT_PATH, bin/, PATH)
`)
}
func serveCmd(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
bind := fs.String("bind", "0.0.0.0:8083", "listen address")
data := fs.String("data", "", "data directory (sqlite, www, work, logs)")
token := fs.String("token", "", "API bearer token (default: STREAMD_TOKEN env)")
nre := fs.String("nre", "", "N_m3u8DL-RE binary")
ffmpeg := fs.String("ffmpeg", "", "ffmpeg binary")
mp4decrypt := fs.String("mp4decrypt", "", "mp4decrypt binary")
_ = fs.Parse(args)
dataDir := *data
if dataDir == "" {
dataDir = filepath.Join(".", ".cache", "streamd")
}
tok := *token
if tok == "" {
tok = os.Getenv("STREAMD_TOKEN")
}
for _, sub := range []string{"www", "work", "logs"} {
if err := os.MkdirAll(filepath.Join(dataDir, sub), 0o755); err != nil {
log.Fatal(err)
}
}
store, err := db.Open(dataDir)
if err != nil {
log.Fatalf("db: %v", err)
}
defer store.Close()
nrePath := firstExisting(*nre,
os.Getenv("NRE_PATH"),
os.Getenv("N_M3U8DL_RE"),
filepath.Join("bin", "N_m3u8DL-RE.exe"),
"N_m3u8DL-RE",
)
ffPath := firstExisting(*ffmpeg,
os.Getenv("FFMPEG_PATH"),
os.Getenv("FFMPEG"),
filepath.Join("bin", "ffmpeg.exe"),
"ffmpeg",
)
decPath := firstExisting(*mp4decrypt,
os.Getenv("MP4DECRYPT_PATH"),
os.Getenv("MP4DECRYPT"),
filepath.Join("bin", "mp4decrypt.exe"),
"mp4decrypt",
)
sup := supervisor.NewMedia(supervisor.Options{
Store: store,
DataDir: dataDir,
NRE: nrePath,
FFmpeg: ffPath,
MP4Decrypt: decPath,
Log: log.Default(),
})
defer sup.Close()
apiH := &api.Handler{
Store: store,
Token: tok,
Supervisor: sup,
StartedAt: time.Now(),
}
hub := ws.NewHub(store, tok, log.Default())
mux := http.NewServeMux()
apiH.Mount(mux)
hub.Mount(mux)
mux.Handle("/", ui.Handler())
mux.Handle("/static/", http.StripPrefix("/static/", ui.Static()))
mux.Handle("/hls/", http.StripPrefix("/hls/", http.FileServer(http.Dir(filepath.Join(dataDir, "www")))))
srv := &http.Server{
Addr: *bind,
Handler: withCORS(mux),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("streamd listening on http://%s data=%s", *bind, dataDir)
log.Printf("media: nre=%s", nrePath)
log.Printf("media: ffmpeg=%s", ffPath)
log.Printf("media: mp4decrypt=%s", decPath)
log.Printf("ws: agents=/ws/agent ui=/ws/ui")
if tok == "" {
log.Printf("warning: no --token / STREAMD_TOKEN set; mutating API is open")
}
stopBG := make(chan struct{})
go hub.RunExpireLoop(stopBG)
// Resume enabled streams that already have credentials (staggered).
go func() {
time.Sleep(500 * time.Millisecond)
list, err := store.ListStreams()
if err != nil {
return
}
for _, st := range list {
if !st.Enabled || strings.TrimSpace(st.MPD) == "" || strings.TrimSpace(st.Key) == "" {
continue
}
mpd := strings.ToLower(strings.TrimSpace(st.MPD))
if !strings.HasPrefix(mpd, "http://") && !strings.HasPrefix(mpd, "https://") {
log.Printf("auto-start skip %s: mpd is not an http(s) URL", st.Name)
continue
}
log.Printf("auto-start enabled stream %s", st.Name)
func(id int64, name string) {
defer func() {
if r := recover(); r != nil {
log.Printf("auto-start %s panic: %v", name, r)
}
}()
if err := sup.Start(id); err != nil {
log.Printf("auto-start %s: %v", name, err)
}
}(st.ID, st.Name)
time.Sleep(1500 * time.Millisecond)
}
}()
go func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
<-ch
log.Printf("shutting down…")
close(stopBG)
sup.Close()
_ = srv.Close()
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Streamd-Token")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func firstExisting(explicit string, candidates ...string) string {
if explicit != "" {
if st, err := os.Stat(explicit); err == nil && !st.IsDir() {
return explicit
}
// still return explicit so exec fails clearly if user set a bad path
if filepath.IsAbs(explicit) || containsSep(explicit) {
return explicit
}
}
for _, c := range candidates {
if c == "" {
continue
}
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
if p, err := execLookPath(c); err == nil {
return p
}
}
return explicit
}
func containsSep(s string) bool {
return filepath.Base(s) != s
}
func execLookPath(file string) (string, error) {
return exec.LookPath(file)
}