Checkpoint: v4.0 media service, compose deploy, and verified docs

这个提交包含在:
cryptocommuniums-afk
2026-03-14 21:45:31 +08:00
父节点 27083d5af9
当前提交 d5431aee0e
修改 41 个文件,包含 4056 行新增883 行删除

17
media/Dockerfile 普通文件
查看文件

@@ -0,0 +1,17 @@
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/media-service ./main.go
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/media-service /usr/local/bin/media-service
ENV MEDIA_ADDR=:8081
ENV MEDIA_DATA_DIR=/data/media
EXPOSE 8081
CMD ["media-service"]

28
media/go.mod 普通文件
查看文件

@@ -0,0 +1,28 @@
module tennis-training-hub/media
go 1.23.0
require github.com/pion/webrtc/v4 v4.1.2
require (
github.com/google/uuid v1.6.0 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)

50
media/go.sum 普通文件
查看文件

@@ -0,0 +1,50 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

861
media/main.go 普通文件
查看文件

@@ -0,0 +1,861 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/pion/webrtc/v4"
)
type SessionStatus string
const (
StatusCreated SessionStatus = "created"
StatusRecording SessionStatus = "recording"
StatusStreaming SessionStatus = "streaming"
StatusReconnecting SessionStatus = "reconnecting"
StatusFinalizing SessionStatus = "finalizing"
StatusArchived SessionStatus = "archived"
StatusFailed SessionStatus = "failed"
)
type ArchiveStatus string
const (
ArchiveIdle ArchiveStatus = "idle"
ArchiveQueued ArchiveStatus = "queued"
ArchiveProcessing ArchiveStatus = "processing"
ArchiveCompleted ArchiveStatus = "completed"
ArchiveFailed ArchiveStatus = "failed"
)
type PlaybackInfo struct {
WebMURL string `json:"webmUrl,omitempty"`
MP4URL string `json:"mp4Url,omitempty"`
WebMSize int64 `json:"webmSize,omitempty"`
MP4Size int64 `json:"mp4Size,omitempty"`
Ready bool `json:"ready"`
PreviewURL string `json:"previewUrl,omitempty"`
}
type SegmentMeta struct {
Sequence int `json:"sequence"`
Filename string `json:"filename"`
DurationMS int64 `json:"durationMs"`
SizeBytes int64 `json:"sizeBytes"`
UploadedAt string `json:"uploadedAt"`
ContentType string `json:"contentType"`
}
type Marker struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
Timestamp int64 `json:"timestampMs"`
Confidence float64 `json:"confidence,omitempty"`
CreatedAt string `json:"createdAt"`
}
type Session struct {
ID string `json:"id"`
UserID string `json:"userId"`
Title string `json:"title"`
Status SessionStatus `json:"status"`
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
Format string `json:"format"`
MimeType string `json:"mimeType"`
QualityPreset string `json:"qualityPreset"`
FacingMode string `json:"facingMode"`
DeviceKind string `json:"deviceKind"`
ReconnectCount int `json:"reconnectCount"`
UploadedSegments int `json:"uploadedSegments"`
UploadedBytes int64 `json:"uploadedBytes"`
DurationMS int64 `json:"durationMs"`
LastError string `json:"lastError,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
FinalizedAt string `json:"finalizedAt,omitempty"`
StreamConnected bool `json:"streamConnected"`
LastStreamAt string `json:"lastStreamAt,omitempty"`
Playback PlaybackInfo `json:"playback"`
Segments []SegmentMeta `json:"segments"`
Markers []Marker `json:"markers"`
}
func (s *Session) recomputeAggregates() {
s.UploadedSegments = len(s.Segments)
var totalBytes int64
var totalDuration int64
for _, segment := range s.Segments {
totalBytes += segment.SizeBytes
totalDuration += segment.DurationMS
}
s.UploadedBytes = totalBytes
if totalDuration > 0 {
s.DurationMS = totalDuration
}
}
type CreateSessionRequest struct {
UserID string `json:"userId"`
Title string `json:"title"`
Format string `json:"format"`
MimeType string `json:"mimeType"`
QualityPreset string `json:"qualityPreset"`
FacingMode string `json:"facingMode"`
DeviceKind string `json:"deviceKind"`
}
type SignalRequest struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
type MarkerRequest struct {
Type string `json:"type"`
Label string `json:"label"`
Timestamp int64 `json:"timestampMs"`
Confidence float64 `json:"confidence,omitempty"`
}
type FinalizeRequest struct {
Title string `json:"title"`
DurationMS int64 `json:"durationMs"`
}
type sessionStore struct {
rootDir string
public string
mu sync.RWMutex
sessions map[string]*Session
peers map[string]*webrtc.PeerConnection
}
func newSessionStore(rootDir string) (*sessionStore, error) {
store := &sessionStore{
rootDir: rootDir,
public: filepath.Join(rootDir, "public"),
sessions: map[string]*Session{},
peers: map[string]*webrtc.PeerConnection{},
}
if err := os.MkdirAll(filepath.Join(rootDir, "sessions"), 0o755); err != nil {
return nil, err
}
if err := os.MkdirAll(store.public, 0o755); err != nil {
return nil, err
}
if err := store.load(); err != nil {
return nil, err
}
for _, session := range store.sessions {
session.recomputeAggregates()
}
return store, nil
}
func (s *sessionStore) load() error {
pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json")
files, err := filepath.Glob(pattern)
if err != nil {
return err
}
for _, file := range files {
body, readErr := os.ReadFile(file)
if readErr != nil {
continue
}
var session Session
if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil {
continue
}
s.sessions[session.ID] = &session
}
return nil
}
func (s *sessionStore) sessionDir(id string) string {
return filepath.Join(s.rootDir, "sessions", id)
}
func (s *sessionStore) segmentsDir(id string) string {
return filepath.Join(s.sessionDir(id), "segments")
}
func (s *sessionStore) publicDir(id string) string {
return filepath.Join(s.public, "sessions", id)
}
func (s *sessionStore) saveSession(session *Session) error {
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
dir := s.sessionDir(session.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
body, err := json.MarshalIndent(session, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "session.json"), body, 0o644)
}
func cloneSession(session *Session) *Session {
body, _ := json.Marshal(session)
var copy Session
_ = json.Unmarshal(body, &copy)
return &copy
}
func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, error) {
now := time.Now().UTC().Format(time.RFC3339)
session := &Session{
ID: randomID(),
UserID: strings.TrimSpace(input.UserID),
Title: strings.TrimSpace(input.Title),
Status: StatusCreated,
ArchiveStatus: ArchiveIdle,
Format: defaultString(input.Format, "webm"),
MimeType: defaultString(input.MimeType, "video/webm"),
QualityPreset: defaultString(input.QualityPreset, "balanced"),
FacingMode: defaultString(input.FacingMode, "environment"),
DeviceKind: defaultString(input.DeviceKind, "desktop"),
CreatedAt: now,
UpdatedAt: now,
Segments: []SegmentMeta{},
Markers: []Marker{},
}
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[session.ID] = session
if err := os.MkdirAll(s.segmentsDir(session.ID), 0o755); err != nil {
return nil, err
}
if err := s.saveSession(session); err != nil {
return nil, err
}
return cloneSession(session), nil
}
func (s *sessionStore) getSession(id string) (*Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[id]
if !ok {
return nil, errors.New("session not found")
}
return cloneSession(session), nil
}
func (s *sessionStore) replacePeer(id string, peer *webrtc.PeerConnection) {
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.peers[id]; ok {
_ = existing.Close()
}
s.peers[id] = peer
}
func (s *sessionStore) closePeer(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.peers[id]; ok {
_ = existing.Close()
delete(s.peers, id)
}
}
func (s *sessionStore) updateSession(id string, update func(*Session) error) (*Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return nil, errors.New("session not found")
}
if err := update(session); err != nil {
return nil, err
}
session.recomputeAggregates()
if err := s.saveSession(session); err != nil {
return nil, err
}
return cloneSession(session), nil
}
func (s *sessionStore) listFinalizingSessions() []*Session {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]*Session, 0, len(s.sessions))
for _, session := range s.sessions {
if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing {
items = append(items, cloneSession(session))
}
}
return items
}
type mediaServer struct {
store *sessionStore
}
func newMediaServer(store *sessionStore) *mediaServer {
return &mediaServer{store: store}
}
func (m *mediaServer) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/media/health", m.handleHealth)
mux.HandleFunc("/media/sessions", m.handleSessions)
mux.HandleFunc("/media/sessions/", m.handleSession)
fileServer := http.FileServer(http.Dir(m.store.public))
mux.Handle("/media/assets/", http.StripPrefix("/media/assets/", cacheControl(fileServer)))
return withCORS(mux)
}
func (m *mediaServer) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
func (m *mediaServer) handleSessions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var input CreateSessionRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
session, err := m.store.createSession(input)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"session": session})
}
func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/media/sessions/")
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 0 || parts[0] == "" {
http.NotFound(w, r)
return
}
sessionID := parts[0]
if len(parts) == 1 && r.Method == http.MethodGet {
session, err := m.store.getSession(sessionID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"session": session})
return
}
if len(parts) < 2 {
http.NotFound(w, r)
return
}
switch parts[1] {
case "signal":
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
m.handleSignal(sessionID, w, r)
case "segments":
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
m.handleSegmentUpload(sessionID, w, r)
case "markers":
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
m.handleMarker(sessionID, w, r)
case "finalize":
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
m.handleFinalize(sessionID, w, r)
case "playback":
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
session, err := m.store.getSession(sessionID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"playback": session.Playback, "session": session})
default:
http.NotFound(w, r)
}
}
func (m *mediaServer) handleSignal(sessionID string, w http.ResponseWriter, r *http.Request) {
var input SignalRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
session, err := m.store.getSession(sessionID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
}
peer, err := webrtc.NewPeerConnection(config)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create peer connection")
return
}
m.store.replacePeer(sessionID, peer)
_, _ = peer.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionRecvonly,
})
_, _ = peer.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionRecvonly,
})
peer.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
_, _ = m.store.updateSession(sessionID, func(session *Session) error {
session.StreamConnected = state == webrtc.PeerConnectionStateConnected
session.LastStreamAt = time.Now().UTC().Format(time.RFC3339)
switch state {
case webrtc.PeerConnectionStateConnected:
session.Status = StatusStreaming
session.LastError = ""
case webrtc.PeerConnectionStateDisconnected:
session.Status = StatusReconnecting
session.ReconnectCount++
case webrtc.PeerConnectionStateFailed:
session.Status = StatusFailed
session.LastError = "webrtc peer connection failed"
case webrtc.PeerConnectionStateClosed:
if session.Status != StatusArchived && session.Status != StatusFinalizing {
session.StreamConnected = false
}
}
return nil
})
})
peer.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
_ = receiver
go func() {
buffer := make([]byte, 1600)
for {
if _, _, readErr := track.Read(buffer); readErr != nil {
return
}
_, _ = m.store.updateSession(sessionID, func(session *Session) error {
session.StreamConnected = true
session.Status = StatusStreaming
session.LastStreamAt = time.Now().UTC().Format(time.RFC3339)
return nil
})
}
}()
})
offer := webrtc.SessionDescription{
Type: parseSDPType(input.Type),
SDP: input.SDP,
}
if err := peer.SetRemoteDescription(offer); err != nil {
writeError(w, http.StatusBadRequest, "failed to set remote description")
return
}
answer, err := peer.CreateAnswer(nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create answer")
return
}
gatherComplete := webrtc.GatheringCompletePromise(peer)
if err := peer.SetLocalDescription(answer); err != nil {
writeError(w, http.StatusInternalServerError, "failed to set local description")
return
}
<-gatherComplete
_, _ = m.store.updateSession(session.ID, func(current *Session) error {
current.Status = StatusRecording
current.StreamConnected = true
current.LastStreamAt = time.Now().UTC().Format(time.RFC3339)
return nil
})
writeJSON(w, http.StatusOK, map[string]any{
"type": strings.ToLower(peer.LocalDescription().Type.String()),
"sdp": peer.LocalDescription().SDP,
})
}
func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWriter, r *http.Request) {
sequence, err := strconv.Atoi(r.URL.Query().Get("sequence"))
if err != nil || sequence < 0 {
writeError(w, http.StatusBadRequest, "invalid sequence")
return
}
durationMS, _ := strconv.ParseInt(r.URL.Query().Get("durationMs"), 10, 64)
contentType := r.Header.Get("Content-Type")
extension := detectExtension(contentType)
filename := fmt.Sprintf("%06d.%s", sequence, extension)
segmentPath := filepath.Join(m.store.segmentsDir(sessionID), filename)
if err := os.MkdirAll(m.store.segmentsDir(sessionID), 0o755); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
file, err := os.Create(segmentPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer file.Close()
size, err := io.Copy(file, r.Body)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
session, err := m.store.updateSession(sessionID, func(session *Session) error {
meta := SegmentMeta{
Sequence: sequence,
Filename: filename,
DurationMS: durationMS,
SizeBytes: size,
UploadedAt: time.Now().UTC().Format(time.RFC3339),
ContentType: defaultString(contentType, "video/webm"),
}
found := false
for index := range session.Segments {
if session.Segments[index].Sequence == sequence {
session.Segments[index] = meta
found = true
break
}
}
if !found {
session.Segments = append(session.Segments, meta)
}
sort.Slice(session.Segments, func(i, j int) bool {
return session.Segments[i].Sequence < session.Segments[j].Sequence
})
session.Status = StatusRecording
session.LastError = ""
return nil
})
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"session": session})
}
func (m *mediaServer) handleMarker(sessionID string, w http.ResponseWriter, r *http.Request) {
var input MarkerRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
session, err := m.store.updateSession(sessionID, func(session *Session) error {
session.Markers = append(session.Markers, Marker{
ID: randomID(),
Type: defaultString(input.Type, "manual"),
Label: defaultString(input.Label, "标记点"),
Timestamp: input.Timestamp,
Confidence: input.Confidence,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
})
sort.Slice(session.Markers, func(i, j int) bool {
return session.Markers[i].Timestamp < session.Markers[j].Timestamp
})
return nil
})
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"session": session})
}
func (m *mediaServer) handleFinalize(sessionID string, w http.ResponseWriter, r *http.Request) {
var input FinalizeRequest
_ = json.NewDecoder(r.Body).Decode(&input)
m.store.closePeer(sessionID)
session, err := m.store.updateSession(sessionID, func(session *Session) error {
session.Status = StatusFinalizing
session.ArchiveStatus = ArchiveQueued
session.FinalizedAt = time.Now().UTC().Format(time.RFC3339)
if strings.TrimSpace(input.Title) != "" {
session.Title = strings.TrimSpace(input.Title)
}
if input.DurationMS > 0 {
session.DurationMS = input.DurationMS
}
session.StreamConnected = false
return nil
})
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"session": session})
}
func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sessions := store.listFinalizingSessions()
for _, session := range sessions {
if err := processSession(store, session.ID); err != nil {
log.Printf("[worker] failed to process session %s: %v", session.ID, err)
}
}
}
}
}
func processSession(store *sessionStore, sessionID string) error {
session, err := store.updateSession(sessionID, func(session *Session) error {
if session.ArchiveStatus == ArchiveProcessing {
return errors.New("already processing")
}
session.ArchiveStatus = ArchiveProcessing
session.Status = StatusFinalizing
session.LastError = ""
return nil
})
if err != nil {
if strings.Contains(err.Error(), "already processing") {
return nil
}
return err
}
if len(session.Segments) == 0 {
_, _ = store.updateSession(sessionID, func(session *Session) error {
session.ArchiveStatus = ArchiveFailed
session.Status = StatusFailed
session.LastError = "no uploaded segments found"
return nil
})
return errors.New("no uploaded segments found")
}
publicDir := store.publicDir(sessionID)
if err := os.MkdirAll(publicDir, 0o755); err != nil {
return err
}
outputWebM := filepath.Join(publicDir, "recording.webm")
outputMP4 := filepath.Join(publicDir, "recording.mp4")
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
inputs := make([]string, 0, len(session.Segments))
sort.Slice(session.Segments, func(i, j int) bool {
return session.Segments[i].Sequence < session.Segments[j].Sequence
})
for _, segment := range session.Segments {
inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename))
}
if err := writeConcatList(listFile, inputs); err != nil {
return markArchiveError(store, sessionID, err)
}
if len(inputs) == 1 {
body, copyErr := os.ReadFile(inputs[0])
if copyErr != nil {
return markArchiveError(store, sessionID, copyErr)
}
if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil {
return markArchiveError(store, sessionID, writeErr)
}
} else {
copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM)
if copyErr != nil {
reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM)
if reencodeErr != nil {
return markArchiveError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr))
}
}
}
mp4Err := runFFmpeg("-y", "-i", outputWebM, "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-c:a", "aac", "-movflags", "+faststart", outputMP4)
if mp4Err != nil {
log.Printf("[worker] mp4 archive generation failed for %s: %v", sessionID, mp4Err)
}
webmInfo, webmStatErr := os.Stat(outputWebM)
if webmStatErr != nil {
return markArchiveError(store, sessionID, webmStatErr)
}
var mp4Size int64
var mp4URL string
if info, statErr := os.Stat(outputMP4); statErr == nil {
mp4Size = info.Size()
mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID)
}
_, err = store.updateSession(sessionID, func(session *Session) error {
session.ArchiveStatus = ArchiveCompleted
session.Status = StatusArchived
session.Playback = PlaybackInfo{
WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
MP4URL: mp4URL,
WebMSize: webmInfo.Size(),
MP4Size: mp4Size,
Ready: true,
PreviewURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
}
session.LastError = ""
return nil
})
return err
}
func markArchiveError(store *sessionStore, sessionID string, err error) error {
_, _ = store.updateSession(sessionID, func(session *Session) error {
session.ArchiveStatus = ArchiveFailed
session.Status = StatusFailed
session.LastError = err.Error()
return nil
})
return err
}
func writeConcatList(path string, inputs []string) error {
lines := make([]string, 0, len(inputs))
for _, input := range inputs {
lines = append(lines, fmt.Sprintf("file '%s'", strings.ReplaceAll(input, "'", "'\\''")))
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}
func runFFmpeg(args ...string) error {
cmd := exec.Command("ffmpeg", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
func parseSDPType(value string) webrtc.SDPType {
switch strings.ToLower(value) {
case "offer":
return webrtc.SDPTypeOffer
case "pranswer":
return webrtc.SDPTypePranswer
case "rollback":
return webrtc.SDPTypeRollback
default:
return webrtc.SDPTypeOffer
}
}
func detectExtension(contentType string) string {
switch {
case strings.Contains(contentType, "mp4"):
return "mp4"
case strings.Contains(contentType, "ogg"):
return "ogg"
default:
return "webm"
}
}
func defaultString(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func randomID() string {
buffer := make([]byte, 12)
if _, err := rand.Read(buffer); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(buffer)
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,X-User-Id")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func cacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func main() {
mode := defaultString(os.Getenv("MEDIA_MODE"), "serve")
dataDir := defaultString(os.Getenv("MEDIA_DATA_DIR"), "./data/media")
addr := defaultString(os.Getenv("MEDIA_ADDR"), ":8081")
workerInterval := 3 * time.Second
store, err := newSessionStore(dataDir)
if err != nil {
log.Fatalf("failed to create store: %v", err)
}
switch mode {
case "worker":
log.Printf("media worker running with data dir %s", dataDir)
runWorkerLoop(context.Background(), store, workerInterval)
default:
server := newMediaServer(store)
if os.Getenv("MEDIA_EMBEDDED_WORKER") != "0" {
go runWorkerLoop(context.Background(), store, workerInterval)
}
log.Printf("media service listening on %s with data dir %s", addr, dataDir)
if err := http.ListenAndServe(addr, server.routes()); err != nil {
log.Fatal(err)
}
}
}

130
media/main_test.go 普通文件
查看文件

@@ -0,0 +1,130 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMediaHealthAndSessionLifecycle(t *testing.T) {
store, err := newSessionStore(t.TempDir())
if err != nil {
t.Fatalf("newSessionStore: %v", err)
}
server := newMediaServer(store)
healthReq := httptest.NewRequest(http.MethodGet, "/media/health", nil)
healthRes := httptest.NewRecorder()
server.routes().ServeHTTP(healthRes, healthReq)
if healthRes.Code != http.StatusOK {
t.Fatalf("expected health 200, got %d", healthRes.Code)
}
session, err := store.createSession(CreateSessionRequest{
UserID: "1",
Title: "Test Session",
Format: "webm",
MimeType: "video/webm",
QualityPreset: "balanced",
FacingMode: "environment",
DeviceKind: "desktop",
})
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := store.updateSession(session.ID, func(current *Session) error {
current.Segments = append(current.Segments, SegmentMeta{
Sequence: 0,
Filename: "000000.webm",
DurationMS: 60000,
SizeBytes: 7,
ContentType: "video/webm",
})
current.Markers = append(current.Markers, Marker{
ID: "marker-1",
Type: "manual",
Label: "关键片段",
Timestamp: 5000,
CreatedAt: "2026-03-14T00:00:00Z",
})
return nil
}); err != nil {
t.Fatalf("updateSession: %v", err)
}
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
t.Fatalf("write segment: %v", err)
}
current, err := store.getSession(session.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if current.UploadedSegments != 1 {
t.Fatalf("expected uploaded segment count to be recomputed")
}
if current.UploadedBytes != 7 {
t.Fatalf("expected uploaded bytes to be recomputed, got %d", current.UploadedBytes)
}
}
func TestProcessSessionArchivesPlayback(t *testing.T) {
tempDir := t.TempDir()
store, err := newSessionStore(tempDir)
if err != nil {
t.Fatalf("newSessionStore: %v", err)
}
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Archive Session"})
if err != nil {
t.Fatalf("createSession: %v", err)
}
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
t.Fatalf("write segment: %v", err)
}
if _, err := store.updateSession(session.ID, func(current *Session) error {
current.Segments = append(current.Segments, SegmentMeta{
Sequence: 0,
Filename: "000000.webm",
DurationMS: 60000,
SizeBytes: 7,
ContentType: "video/webm",
})
current.ArchiveStatus = ArchiveQueued
return nil
}); err != nil {
t.Fatalf("updateSession: %v", err)
}
fakeFFmpeg := filepath.Join(tempDir, "ffmpeg")
script := "#!/bin/sh\ninput=''\noutput=''\nprev=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-i' ]; then input=\"$arg\"; fi\n prev=\"$arg\"\n output=\"$arg\"\ndone\nif [ -n \"$input\" ] && [ -f \"$input\" ]; then cp \"$input\" \"$output\"; else : > \"$output\"; fi\n"
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
originalPath := os.Getenv("PATH")
t.Setenv("PATH", tempDir+string(os.PathListSeparator)+originalPath)
if err := processSession(store, session.ID); err != nil {
t.Fatalf("processSession: %v", err)
}
archived, err := store.getSession(session.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if archived.ArchiveStatus != ArchiveCompleted {
t.Fatalf("expected archive completed, got %s", archived.ArchiveStatus)
}
if archived.Playback.WebMURL == "" || !strings.HasSuffix(archived.Playback.WebMURL, ".webm") {
t.Fatalf("expected webm playback url, got %#v", archived.Playback)
}
}

二进制
media/media 可执行文件

二进制文件未显示。