Add multi-session auth and changelog tracking
这个提交包含在:
164
media/main.go
164
media/main.go
@@ -44,6 +44,15 @@ const (
|
||||
ArchiveFailed ArchiveStatus = "failed"
|
||||
)
|
||||
|
||||
type PreviewStatus string
|
||||
|
||||
const (
|
||||
PreviewIdle PreviewStatus = "idle"
|
||||
PreviewProcessing PreviewStatus = "processing"
|
||||
PreviewReady PreviewStatus = "ready"
|
||||
PreviewFailed PreviewStatus = "failed"
|
||||
)
|
||||
|
||||
type PlaybackInfo struct {
|
||||
WebMURL string `json:"webmUrl,omitempty"`
|
||||
MP4URL string `json:"mp4Url,omitempty"`
|
||||
@@ -77,6 +86,7 @@ type Session struct {
|
||||
Title string `json:"title"`
|
||||
Status SessionStatus `json:"status"`
|
||||
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
|
||||
PreviewStatus PreviewStatus `json:"previewStatus"`
|
||||
Format string `json:"format"`
|
||||
MimeType string `json:"mimeType"`
|
||||
QualityPreset string `json:"qualityPreset"`
|
||||
@@ -85,11 +95,13 @@ type Session struct {
|
||||
ReconnectCount int `json:"reconnectCount"`
|
||||
UploadedSegments int `json:"uploadedSegments"`
|
||||
UploadedBytes int64 `json:"uploadedBytes"`
|
||||
PreviewSegments int `json:"previewSegments"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
FinalizedAt string `json:"finalizedAt,omitempty"`
|
||||
PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
|
||||
StreamConnected bool `json:"streamConnected"`
|
||||
LastStreamAt string `json:"lastStreamAt,omitempty"`
|
||||
Playback PlaybackInfo `json:"playback"`
|
||||
@@ -159,7 +171,7 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
|
||||
if err := os.MkdirAll(store.public, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.load(); err != nil {
|
||||
if err := store.refreshFromDisk(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range store.sessions {
|
||||
@@ -168,12 +180,13 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *sessionStore) load() error {
|
||||
func (s *sessionStore) loadSessionsFromDisk() (map[string]*Session, error) {
|
||||
pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
sessions := make(map[string]*Session, len(files))
|
||||
for _, file := range files {
|
||||
body, readErr := os.ReadFile(file)
|
||||
if readErr != nil {
|
||||
@@ -183,8 +196,19 @@ func (s *sessionStore) load() error {
|
||||
if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil {
|
||||
continue
|
||||
}
|
||||
s.sessions[session.ID] = &session
|
||||
sessions[session.ID] = &session
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *sessionStore) refreshFromDisk() error {
|
||||
sessions, err := s.loadSessionsFromDisk()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions = sessions
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -228,6 +252,7 @@ func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, erro
|
||||
Title: strings.TrimSpace(input.Title),
|
||||
Status: StatusCreated,
|
||||
ArchiveStatus: ArchiveIdle,
|
||||
PreviewStatus: PreviewIdle,
|
||||
Format: defaultString(input.Format, "webm"),
|
||||
MimeType: defaultString(input.MimeType, "video/webm"),
|
||||
QualityPreset: defaultString(input.QualityPreset, "balanced"),
|
||||
@@ -295,13 +320,20 @@ func (s *sessionStore) updateSession(id string, update func(*Session) error) (*S
|
||||
return cloneSession(session), nil
|
||||
}
|
||||
|
||||
func (s *sessionStore) listFinalizingSessions() []*Session {
|
||||
func (s *sessionStore) listProcessableSessions() []*Session {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]*Session, 0, len(s.sessions))
|
||||
for _, session := range s.sessions {
|
||||
if len(session.Segments) == 0 {
|
||||
continue
|
||||
}
|
||||
if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing {
|
||||
items = append(items, cloneSession(session))
|
||||
continue
|
||||
}
|
||||
if session.PreviewSegments < len(session.Segments) && session.PreviewStatus != PreviewProcessing {
|
||||
items = append(items, cloneSession(session))
|
||||
}
|
||||
}
|
||||
return items
|
||||
@@ -315,6 +347,10 @@ func newMediaServer(store *sessionStore) *mediaServer {
|
||||
return &mediaServer{store: store}
|
||||
}
|
||||
|
||||
func (m *mediaServer) refreshSessionsForRead() error {
|
||||
return m.store.refreshFromDisk()
|
||||
}
|
||||
|
||||
func (m *mediaServer) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/media/health", m.handleHealth)
|
||||
@@ -359,6 +395,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sessionID := parts[0]
|
||||
if len(parts) == 1 && r.Method == http.MethodGet {
|
||||
if err := m.refreshSessionsForRead(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
session, err := m.store.getSession(sessionID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
@@ -402,6 +442,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := m.refreshSessionsForRead(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
session, err := m.store.getSession(sessionID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
@@ -632,7 +676,11 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
sessions := store.listFinalizingSessions()
|
||||
if err := store.refreshFromDisk(); err != nil {
|
||||
log.Printf("[worker] failed to refresh session store: %v", err)
|
||||
continue
|
||||
}
|
||||
sessions := store.listProcessableSessions()
|
||||
for _, session := range sessions {
|
||||
if err := processSession(store, session.ID); err != nil {
|
||||
log.Printf("[worker] failed to process session %s: %v", session.ID, err)
|
||||
@@ -643,6 +691,42 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
|
||||
}
|
||||
|
||||
func processSession(store *sessionStore, sessionID string) error {
|
||||
current, err := store.getSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if current.ArchiveStatus == ArchiveQueued || current.ArchiveStatus == ArchiveProcessing {
|
||||
return processFinalArchive(store, sessionID)
|
||||
}
|
||||
|
||||
if current.PreviewSegments < len(current.Segments) {
|
||||
return processRollingPreview(store, sessionID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processRollingPreview(store *sessionStore, sessionID string) error {
|
||||
session, err := store.updateSession(sessionID, func(session *Session) error {
|
||||
if session.PreviewStatus == PreviewProcessing {
|
||||
return errors.New("preview already processing")
|
||||
}
|
||||
session.PreviewStatus = PreviewProcessing
|
||||
session.LastError = ""
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "preview already processing") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return buildPlaybackArtifacts(store, session, false)
|
||||
}
|
||||
|
||||
func processFinalArchive(store *sessionStore, sessionID string) error {
|
||||
session, err := store.updateSession(sessionID, func(session *Session) error {
|
||||
if session.ArchiveStatus == ArchiveProcessing {
|
||||
return errors.New("already processing")
|
||||
@@ -668,12 +752,22 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
return errors.New("no uploaded segments found")
|
||||
}
|
||||
|
||||
return buildPlaybackArtifacts(store, session, true)
|
||||
}
|
||||
|
||||
func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool) error {
|
||||
sessionID := session.ID
|
||||
|
||||
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")
|
||||
baseName := "preview"
|
||||
if finalize {
|
||||
baseName = "recording"
|
||||
}
|
||||
outputWebM := filepath.Join(publicDir, baseName+".webm")
|
||||
outputMP4 := filepath.Join(publicDir, baseName+".mp4")
|
||||
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
|
||||
|
||||
inputs := make([]string, 0, len(session.Segments))
|
||||
@@ -684,23 +778,23 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename))
|
||||
}
|
||||
if err := writeConcatList(listFile, inputs); err != nil {
|
||||
return markArchiveError(store, sessionID, err)
|
||||
return markProcessingError(store, sessionID, err, finalize)
|
||||
}
|
||||
|
||||
if len(inputs) == 1 {
|
||||
body, copyErr := os.ReadFile(inputs[0])
|
||||
if copyErr != nil {
|
||||
return markArchiveError(store, sessionID, copyErr)
|
||||
return markProcessingError(store, sessionID, copyErr, finalize)
|
||||
}
|
||||
if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil {
|
||||
return markArchiveError(store, sessionID, writeErr)
|
||||
return markProcessingError(store, sessionID, writeErr, finalize)
|
||||
}
|
||||
} 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))
|
||||
return markProcessingError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr), finalize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -712,7 +806,7 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
|
||||
webmInfo, webmStatErr := os.Stat(outputWebM)
|
||||
if webmStatErr != nil {
|
||||
return markArchiveError(store, sessionID, webmStatErr)
|
||||
return markProcessingError(store, sessionID, webmStatErr, finalize)
|
||||
}
|
||||
var mp4Size int64
|
||||
var mp4URL string
|
||||
@@ -720,27 +814,41 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
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),
|
||||
}
|
||||
previewURL := fmt.Sprintf("/media/assets/sessions/%s/%s.webm", sessionID, baseName)
|
||||
if mp4URL != "" {
|
||||
previewURL = mp4URL
|
||||
}
|
||||
|
||||
_, updateErr := store.updateSession(sessionID, func(session *Session) error {
|
||||
session.Playback.PreviewURL = previewURL
|
||||
session.PreviewSegments = len(inputs)
|
||||
session.PreviewUpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
session.PreviewStatus = PreviewReady
|
||||
session.LastError = ""
|
||||
if finalize {
|
||||
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: previewURL,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
return updateErr
|
||||
}
|
||||
|
||||
func markArchiveError(store *sessionStore, sessionID string, err error) error {
|
||||
func markProcessingError(store *sessionStore, sessionID string, err error, finalize bool) error {
|
||||
_, _ = store.updateSession(sessionID, func(session *Session) error {
|
||||
session.ArchiveStatus = ArchiveFailed
|
||||
session.Status = StatusFailed
|
||||
session.PreviewStatus = PreviewFailed
|
||||
if finalize {
|
||||
session.ArchiveStatus = ArchiveFailed
|
||||
session.Status = StatusFailed
|
||||
}
|
||||
session.LastError = err.Error()
|
||||
return nil
|
||||
})
|
||||
|
||||
在新工单中引用
屏蔽一个用户