Null-DRM-Official/apps/streamd/internal/db/db.go
404errordeveloper 2fa8f2435f 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.
2026-10-06 00:25:35 +02:00

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
}