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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue