Files
silk/server-go/internal/service/media.go
T

724 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"silk-server-go/internal/config"
)
// GbPlayResult GB28181 播放结果
type GbPlayResult struct {
DeviceID string
ChannelID string
HLS string
FLV string
WsFlv string
Fmp4 string
Rtsp string
WebRtc string
}
// MediaService WVP/ZLMediaKit 媒体服务代理
type MediaService struct {
cfg *config.Config
httpClient *http.Client
cachedToken string
tokenExpiresAt time.Time
mu sync.Mutex
}
// NewMediaService 根据配置创建媒体服务
func NewMediaService(cfg *config.Config) *MediaService {
return &MediaService{
cfg: cfg,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
// wvpAPIBase 返回去掉末尾斜杠的 WVP API 基地址
func (m *MediaService) wvpAPIBase() string {
return strings.TrimSuffix(m.cfg.WVPAPIBase, "/")
}
// zlmAPIBase 返回去掉末尾斜杠的 ZLMediaKit API 基地址
func (m *MediaService) zlmAPIBase() string {
return strings.TrimSuffix(m.cfg.ZLMAPIBase, "/")
}
// login 登录 WVP API 并缓存 token(密码需 MD5),缓存 55 分钟
func (m *MediaService) login(ctx context.Context) (string, error) {
md5Password := md5.Sum([]byte(m.cfg.WVPPassword))
md5Hex := hex.EncodeToString(md5Password[:])
loginURL := fmt.Sprintf("%s/api/user/login?username=%s&password=%s",
m.wvpAPIBase(),
m.cfg.WVPUsername,
md5Hex,
)
slog.Info("登录 WVP API", "username", m.cfg.WVPUsername)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL, nil)
if err != nil {
return "", fmt.Errorf("创建 WVP 登录请求失败: %w", err)
}
resp, err := m.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("WVP 登录请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("WVP 登录失败: HTTP %d", resp.StatusCode)
}
var body struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
AccessToken string `json:"accessToken"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", fmt.Errorf("解析 WVP 登录响应失败: %w", err)
}
if body.Code != 0 || body.Data.AccessToken == "" {
return "", fmt.Errorf("WVP 登录错误: %s", body.Msg)
}
m.mu.Lock()
m.cachedToken = body.Data.AccessToken
m.tokenExpiresAt = time.Now().Add(55 * time.Minute)
m.mu.Unlock()
slog.Info("WVP 登录成功,token 已缓存")
return body.Data.AccessToken, nil
}
// getAuthToken 获取有效的认证 token,必要时重新登录
func (m *MediaService) getAuthToken(ctx context.Context) (string, error) {
m.mu.Lock()
if m.cachedToken != "" && time.Now().Before(m.tokenExpiresAt) {
token := m.cachedToken
m.mu.Unlock()
return token, nil
}
m.mu.Unlock()
return m.login(ctx)
}
// invalidateToken 使缓存的 token 失效
func (m *MediaService) invalidateToken() {
m.mu.Lock()
m.cachedToken = ""
m.mu.Unlock()
}
// StartPlay 开始播放 GB28181 设备实时流,GET /api/play/start/{deviceId}/{channelId}
func (m *MediaService) StartPlay(deviceId, channelId string) (*GbPlayResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
url := fmt.Sprintf("%s/api/play/start/%s/%s", m.wvpAPIBase(), deviceId, channelId)
slog.Info("调用 WVP play/start", "url", url)
body, err := m.doWVPRequest(ctx, url)
if err != nil {
return nil, err
}
return m.extractPlayResult(body, deviceId, channelId), nil
}
// StopPlay 停止播放 GB28181 设备实时流,GET /api/play/stop/{deviceId}/{channelId}
func (m *MediaService) StopPlay(deviceId, channelId string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/api/play/stop/%s/%s", m.wvpAPIBase(), deviceId, channelId)
token, err := m.getAuthToken(ctx)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("access-token", token)
resp, err := m.httpClient.Do(req)
if err != nil {
slog.Warn("停止播放失败", "deviceId", deviceId, "channelId", channelId, "error", err)
return nil
}
resp.Body.Close()
return nil
}
// StartPlayback 开始回放 GB28181 设备历史流,GET /api/playback/start/{deviceId}/{channelId}?startTime=...&endTime=...
func (m *MediaService) StartPlayback(deviceId, channelId, startTime, endTime string) (*GbPlayResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
url := fmt.Sprintf("%s/api/playback/start/%s/%s?startTime=%s&endTime=%s",
m.wvpAPIBase(), deviceId, channelId,
startTime, endTime,
)
slog.Info("调用 WVP playback/start", "url", url)
body, err := m.doWVPRequest(ctx, url)
if err != nil {
return nil, err
}
return m.extractPlayResult(body, deviceId, channelId), nil
}
// doWVPRequest 执行 WVP API 请求(token 过期时自动重试一次)
func (m *MediaService) doWVPRequest(ctx context.Context, reqURL string) (map[string]interface{}, error) {
token, err := m.getAuthToken(ctx)
if err != nil {
return nil, err
}
body, err := m.sendWVPRequest(ctx, reqURL, token)
if err != nil {
return nil, err
}
// token 过期,重试一次
code, ok := body["code"].(float64)
if ok && int(code) == 401 {
m.invalidateToken()
newToken, err := m.getAuthToken(ctx)
if err != nil {
return nil, err
}
body, err = m.sendWVPRequest(ctx, reqURL, newToken)
if err != nil {
return nil, err
}
}
code, ok = body["code"].(float64)
if !ok || int(code) != 0 {
msg, _ := body["msg"].(string)
if msg == "" {
msg, _ = body["message"].(string)
}
return nil, fmt.Errorf("WVP API 错误: %s", msg)
}
return body, nil
}
// sendWVPRequest 发送带 access-token 的 GET 请求
func (m *MediaService) sendWVPRequest(ctx context.Context, reqURL, token string) (map[string]interface{}, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("access-token", token)
resp, err := m.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("流媒体服务暂时不可用: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("WVP API 返回 HTTP %d", resp.StatusCode)
}
var body map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("解析 WVP 响应失败: %w", err)
}
return body, nil
}
// extractPlayResult 从 WVP 响应中提取播放地址,并将 ZLM 的 :80/ 替换为 :8081/
func (m *MediaService) extractPlayResult(body map[string]interface{}, deviceId, channelId string) *GbPlayResult {
data, _ := body["data"].(map[string]interface{})
if data == nil {
data = map[string]interface{}{}
}
return &GbPlayResult{
DeviceID: deviceId,
ChannelID: channelId,
HLS: fixZlmPort(getString(data, "hls")),
FLV: fixZlmPort(getString(data, "flv")),
WsFlv: fixZlmPort(getString(data, "ws_flv")),
Fmp4: fixZlmPort(getString(data, "fmp4")),
Rtsp: getString(data, "rtsp"),
WebRtc: fixZlmPort(getString(data, "webRtc")),
}
}
// fixZlmPort 将 ZLMediaKit 容器内地址替换为前端可通过 server.cjs 代理访问的相对路径。
// ZLM 返回的 URL 形如 http://100.83.103.1:80/rtp/xxx.live.flv?...
// 改为相对路径 /rtp/xxx.live.flv?... 后,前端浏览器会以同源请求走 server.cjs 代理,
// 避免 CORS / CSP 限制(server.cjs 已内置 /rtp/ 到 127.0.0.1:8081 的代理)。
func fixZlmPort(u string) string {
if u == "" {
return u
}
// 提取 /rtp/ 起始的相对路径(含 query string
if idx := strings.Index(u, "/rtp/"); idx >= 0 {
return u[idx:]
}
// 兜底:替换 Docker 内部主机名为外部可访问 IP
u = strings.Replace(u, "polaris-media:", "100.83.103.1:", 1)
u = strings.Replace(u, ":80/", ":8081/", 1)
if strings.HasSuffix(u, ":80") {
u = u[:len(u)-2] + ":8081"
}
return u
}
// getString 从 map 中安全取字符串
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
// --- ZLMediaKit 录制控制 ---
// StartRecord 开始录制(type=1 for MP4
func (m *MediaService) StartRecord(app, stream string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/index/api/startRecord?type=1&vhost=__defaultVhost__&app=%s&stream=%s&secret=%s",
m.zlmAPIBase(), app, stream, m.cfg.ZLMSecret,
)
slog.Info("ZLMediaKit startRecord", "app", app, "stream", stream)
return m.zlmGet(ctx, url, "startRecord")
}
// StopRecord 停止录制
func (m *MediaService) StopRecord(app, stream string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/index/api/stopRecord?type=1&vhost=__defaultVhost__&app=%s&stream=%s&secret=%s",
m.zlmAPIBase(), app, stream, m.cfg.ZLMSecret,
)
slog.Info("ZLMediaKit stopRecord", "app", app, "stream", stream)
return m.zlmGet(ctx, url, "stopRecord")
}
// IsRecording 检查是否正在录制
func (m *MediaService) IsRecording(app, stream string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/index/api/isRecording?type=1&vhost=__defaultVhost__&app=%s&stream=%s&secret=%s",
m.zlmAPIBase(), app, stream, m.cfg.ZLMSecret,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false
}
resp, err := m.httpClient.Do(req)
if err != nil {
slog.Warn("ZLM isRecording 请求失败", "error", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
var body struct {
Code int `json:"code"`
Data interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return false
}
return body.Code == 0 && body.Data == true
}
// zlmGet 执行 ZLMediaKit GET 请求并检查 code==0
func (m *MediaService) zlmGet(ctx context.Context, url, action string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("创建 %s 请求失败: %w", action, err)
}
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("流媒体录制服务暂时不可用: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ZLM %s 失败: HTTP %d", action, resp.StatusCode)
}
var body struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return fmt.Errorf("解析 ZLM %s 响应失败: %w", action, err)
}
if body.Code != 0 {
return fmt.Errorf("ZLM %s 错误: %s", action, body.Msg)
}
return nil
}
// WvpDeviceInfo WVP 设备同步信息(silk 与 WVP 共有参数)
type WvpDeviceInfo struct {
Name string
Manufacturer string
Transport string
StreamMode string
Password string
OnLine bool
}
// SyncWvpDevices 查询 WVP 设备列表,返回 deviceId -> 设备信息映射(用于 WVP → silk 同步)
func (m *MediaService) SyncWvpDevices() (map[string]*WvpDeviceInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
u := fmt.Sprintf("%s/api/device/query/devices?page=1&count=100", m.wvpAPIBase())
body, err := m.doWVPRequest(ctx, u)
if err != nil {
return nil, err
}
result := make(map[string]*WvpDeviceInfo)
if data, ok := body["data"].(map[string]interface{}); ok {
if devices, ok := data["list"].([]interface{}); ok {
for _, d := range devices {
if dev, ok := d.(map[string]interface{}); ok {
deviceId, _ := dev["deviceId"].(string)
if deviceId == "" {
continue
}
info := &WvpDeviceInfo{
Name: getString(dev, "name"),
Manufacturer: getString(dev, "manufacturer"),
Transport: getString(dev, "transport"),
StreamMode: getString(dev, "streamMode"),
Password: getString(dev, "password"),
}
if onLine, ok := dev["onLine"].(bool); ok {
info.OnLine = onLine
}
result[deviceId] = info
}
}
}
}
return result, nil
}
// GetServerConfig 查询 WVP 服务器配置(含 SIP 参数),供前端展示
func (m *MediaService) GetServerConfig() (map[string]interface{}, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/api/server/config", m.wvpAPIBase())
body, err := m.doWVPRequest(ctx, url)
if err != nil {
return nil, err
}
if data, ok := body["data"].(map[string]interface{}); ok {
return data, nil
}
return nil, fmt.Errorf("WVP 配置响应格式异常")
}
// WvpDevice WVP 设备信息
type WvpDevice struct {
ID int `json:"id"`
DeviceID string `json:"deviceId"`
Name string `json:"name"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
OnLine bool `json:"onLine"`
Transport string `json:"transport"`
StreamMode string `json:"streamMode"`
HostAddress string `json:"hostAddress"`
}
// WvpChannel WVP 通道信息
type WvpChannel struct {
ID int `json:"id"`
DeviceID string `json:"deviceId"`
ChannelID string `json:"channelId"`
Name string `json:"name"`
OnLine bool `json:"onLine"`
}
// ListWvpDevices 查询 WVP 已注册设备列表
func (m *MediaService) ListWvpDevices(query string) ([]WvpDevice, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
u := fmt.Sprintf("%s/api/device/query/devices?page=1&count=100", m.wvpAPIBase())
if query != "" {
u += "&query=" + url.QueryEscape(query)
}
body, err := m.doWVPRequest(ctx, u)
if err != nil {
return nil, err
}
var devices []WvpDevice
if data, ok := body["data"].(map[string]interface{}); ok {
if list, ok := data["list"].([]interface{}); ok {
for _, d := range list {
if dev, ok := d.(map[string]interface{}); ok {
device := WvpDevice{
DeviceID: getString(dev, "deviceId"),
Name: getString(dev, "name"),
Manufacturer: getString(dev, "manufacturer"),
Model: getString(dev, "model"),
Transport: getString(dev, "transport"),
StreamMode: getString(dev, "streamMode"),
HostAddress: getString(dev, "hostAddress"),
}
if onLine, ok := dev["onLine"].(bool); ok {
device.OnLine = onLine
}
if id, ok := dev["id"].(float64); ok {
device.ID = int(id)
}
devices = append(devices, device)
}
}
}
}
return devices, nil
}
// ListWvpChannels 查询 WVP 设备的通道列表
func (m *MediaService) ListWvpChannels(deviceId string) ([]WvpChannel, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
u := fmt.Sprintf("%s/api/device/query/devices/%s/channels?page=1&count=100", m.wvpAPIBase(), url.PathEscape(deviceId))
body, err := m.doWVPRequest(ctx, u)
if err != nil {
return nil, err
}
var channels []WvpChannel
if data, ok := body["data"].(map[string]interface{}); ok {
if list, ok := data["list"].([]interface{}); ok {
for _, c := range list {
if ch, ok := c.(map[string]interface{}); ok {
channel := WvpChannel{
DeviceID: getString(ch, "deviceId"),
ChannelID: getString(ch, "channelId"),
Name: getString(ch, "name"),
}
if onLine, ok := ch["onLine"].(bool); ok {
channel.OnLine = onLine
}
if id, ok := ch["id"].(float64); ok {
channel.ID = int(id)
}
channels = append(channels, channel)
}
}
}
}
return channels, nil
}
// SyncWvpDevice 触发 WVP 设备通道同步
func (m *MediaService) SyncWvpDevice(deviceId string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
u := fmt.Sprintf("%s/api/device/query/devices/%s/sync", m.wvpAPIBase(), url.PathEscape(deviceId))
_, err := m.doWVPRequest(ctx, u)
return err
}
// UpdateWvpDevice 同步设备信息到 WVP(silk → WVP,共有可编辑参数:name、manufacturer、password
func (m *MediaService) UpdateWvpDevice(deviceId string, name, manufacturer, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 先查询设备获取 WVP 内部 ID
queryURL := fmt.Sprintf("%s/api/device/query/devices/%s", m.wvpAPIBase(), url.PathEscape(deviceId))
body, err := m.doWVPRequest(ctx, queryURL)
if err != nil {
return err
}
devData, ok := body["data"].(map[string]interface{})
if !ok {
return fmt.Errorf("设备尚未在 WVP 注册,请在摄像头硬件配置设备ID后等待注册完成")
}
devID, ok := devData["id"].(float64)
if !ok {
return fmt.Errorf("WVP 设备 ID 未找到")
}
// POST 更新设备信息(WVP API 仅接受 name/manufacturer/model 等可编辑字段)
updateURL := fmt.Sprintf("%s/api/device/query/device/update", m.wvpAPIBase())
token, err := m.getAuthToken(ctx)
if err != nil {
return err
}
updates := map[string]interface{}{
"id": int(devID),
"deviceId": deviceId, // WVP API 要求 deviceId 字段必填
}
if name != "" {
updates["name"] = name
}
if manufacturer != "" {
updates["manufacturer"] = manufacturer
}
if password != "" {
updates["password"] = password
}
updateBody, _ := json.Marshal(updates)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, updateURL, strings.NewReader(string(updateBody)))
if err != nil {
return err
}
req.Header.Set("access-token", token)
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("WVP 更新请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("WVP 更新失败: HTTP %d", resp.StatusCode)
}
// 检查 WVP 响应体中的 code 字段
var respBody struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return fmt.Errorf("解析 WVP 更新响应失败: %w", err)
}
if respBody.Code != 0 {
return fmt.Errorf("WVP 更新失败: %s", respBody.Msg)
}
return nil
}
// AddWvpDevice 预先添加设备到 WVP(在摄像头注册前,设置独立密码等参数)
func (m *MediaService) AddWvpDevice(deviceId, name, manufacturer, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
token, err := m.getAuthToken(ctx)
if err != nil {
return err
}
addURL := fmt.Sprintf("%s/api/device/query/device/add", m.wvpAPIBase())
payload := map[string]interface{}{
"deviceId": deviceId,
}
if name != "" {
payload["name"] = name
}
if manufacturer != "" {
payload["manufacturer"] = manufacturer
}
if password != "" {
payload["password"] = password
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, addURL, strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("access-token", token)
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("WVP 添加设备请求失败: %w", err)
}
defer resp.Body.Close()
var respBody struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return fmt.Errorf("解析 WVP 添加设备响应失败: %w", err)
}
if respBody.Code != 0 {
return fmt.Errorf("WVP 添加设备失败: %s", respBody.Msg)
}
return nil
}
// DeleteWvpDevice 从 WVP 删除设备(silk 删除摄像头时同步删除 WVP 中预添加的设备)
func (m *MediaService) DeleteWvpDevice(deviceId string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
token, err := m.getAuthToken(ctx)
if err != nil {
return err
}
u := fmt.Sprintf("%s/api/device/query/devices/%s/delete", m.wvpAPIBase(), url.PathEscape(deviceId))
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
if err != nil {
return err
}
req.Header.Set("access-token", token)
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("WVP 删除设备请求失败: %w", err)
}
defer resp.Body.Close()
var respBody struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return fmt.Errorf("解析 WVP 删除设备响应失败: %w", err)
}
if respBody.Code != 0 {
return fmt.Errorf("WVP 删除设备失败: %s", respBody.Msg)
}
return nil
}