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.
504 lines
14 KiB
Go
504 lines
14 KiB
Go
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
|
|
}
|