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