feat: 重建 MediaMTX 生命周期与状态对账 (#67)

This commit is contained in:
QiuSW
2026-08-14 17:42:26 +08:00
parent 24cf4bbd65
commit 19f9bfa1a0
26 changed files with 1560 additions and 0 deletions
@@ -0,0 +1,73 @@
package media_test
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
)
func freeAddress(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
if err = listener.Close(); err != nil {
t.Fatal(err)
}
return address
}
func TestRealMediaMTXControlLifecycle(t *testing.T) {
binary := os.Getenv("SENSE_MEDIAMTX_TEST_BINARY")
if binary == "" {
t.Skip("set SENSE_MEDIAMTX_TEST_BINARY to run the real MediaMTX integration")
}
apiAddress, rtspAddress := freeAddress(t), freeAddress(t)
configPath := filepath.Join(t.TempDir(), "mediamtx.yml")
config := fmt.Sprintf("logLevel: warn\napi: true\napiAddress: %s\nrtspAddress: %s\nrtmp: false\nhls: false\nwebrtc: false\nsrt: false\nplayback: false\npaths: {}\n", apiAddress, rtspAddress)
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
t.Fatal(err)
}
controller, err := media.NewHTTPController("http://" + apiAddress)
if err != nil {
t.Fatal(err)
}
supervisor := media.NewSupervisor(binary, configPath)
if err = supervisor.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = supervisor.Stop(ctx)
})
deadline := time.Now().Add(8 * time.Second)
for {
err = controller.Health(context.Background())
if err == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf("MediaMTX did not become ready: %v", err)
}
time.Sleep(100 * time.Millisecond)
}
source := media.Source{Path: "sense_integration", URI: "rtsp://127.0.0.1:65530/test", Username: "synthetic-user", Password: "synthetic-password"}
if err = controller.Apply(context.Background(), source); err != nil {
t.Fatal(err)
}
if err = controller.Apply(context.Background(), source); err != nil {
t.Fatalf("replace must be idempotent: %v", err)
}
if err = controller.Delete(context.Background(), source.Path); err != nil {
t.Fatal(err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package media_test
import (
"context"
"crypto/rand"
"encoding/base64"
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
)
type readyController struct{}
func (readyController) Health(context.Context) error { return nil }
func (readyController) Apply(context.Context, media.Source) error { return nil }
func (readyController) Delete(context.Context, string) error { return nil }
func (readyController) Status(context.Context, string) (media.PathStatus, error) {
return media.PathStatus{Exists: true, Ready: true}, nil
}
func TestPostgresColdStartRestoresDesiredRoute(t *testing.T) {
dsn := os.Getenv("SENSE_MEDIA_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_MEDIA_TEST_DATABASE_URL to run PostgreSQL media recovery")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&media.Route{}, &admission.Profile{}, &credential.DeviceCredential{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sense_admission_profiles, sense_device_credentials")
})
key := make([]byte, 32)
if _, err = rand.Read(key); err != nil {
t.Fatal(err)
}
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
vault, _ := credential.NewVault(key)
ciphertext, err := vault.Encrypt("device-pg", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
if err != nil {
t.Fatal(err)
}
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-pg", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&admission.Profile{DeviceID: "device-pg", Token: "main", Name: "Main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready", VerificationDetail: "synthetic"}).Error; err != nil {
t.Fatal(err)
}
first := media.NewService(db, readyController{}, nil, media.RuntimeConfig{})
if err = first.EnsureDevice(context.Background(), "device-pg"); err != nil {
t.Fatal(err)
}
second := media.NewService(db.Session(&gorm.Session{NewDB: true}), readyController{}, nil, media.RuntimeConfig{})
if err = second.ReconcileDue(context.Background()); err != nil {
t.Fatal(err)
}
items, err := second.List(context.Background())
if err != nil || len(items) != 1 || items[0].Actual != "ready" {
t.Fatalf("items=%#v err=%v", items, err)
}
}