Add multi-resolution playback support

This commit is contained in:
2026-04-15 11:42:13 +08:00
parent 146f05388e
commit 6eb0baf16e
7 changed files with 337 additions and 48 deletions

View File

@@ -48,3 +48,18 @@ func GetActiveRooms(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"active_rooms": result})
}
func GetRoomPlaybackOptions(c *gin.Context) {
roomID := c.Param("room_id")
qualities := []string{"source"}
if adminRTMP != nil {
if available := adminRTMP.AvailablePlaybackQualities(roomID); len(available) > 0 {
qualities = available
}
}
c.JSON(http.StatusOK, gin.H{
"room_id": roomID,
"qualities": qualities,
})
}

View File

@@ -37,6 +37,7 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
authGroup.Use(AuthMiddleware())
{
authGroup.GET("/room/my", GetMyRoom)
authGroup.GET("/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
authGroup.POST("/user/change-password", ChangePassword)
adminGroup := authGroup.Group("/admin")

View File

@@ -1,11 +1,16 @@
package stream
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/nareix/joy4/av/avutil"
@@ -27,9 +32,38 @@ func init() {
// RTMPServer manages all active live streams
type RTMPServer struct {
server *rtmp.Server
channels map[string]*pubsub.Queue
mutex sync.RWMutex
server *rtmp.Server
channels map[string]*pubsub.Queue
transcoders map[string][]*variantTranscoder
internalPublishKey string
mutex sync.RWMutex
}
type variantTranscoder struct {
quality string
cancel context.CancelFunc
cmd *exec.Cmd
}
type qualityProfile struct {
scale string
videoBitrate string
audioBitrate string
}
var qualityOrder = []string{"source", "720p", "480p"}
var supportedQualities = map[string]qualityProfile{
"720p": {
scale: "1280:-2",
videoBitrate: "2500k",
audioBitrate: "128k",
},
"480p": {
scale: "854:-2",
videoBitrate: "1200k",
audioBitrate: "96k",
},
}
type writeFlusher struct {
@@ -45,32 +79,29 @@ func (w writeFlusher) Flush() error {
// NewRTMPServer creates and initializes a new media server
func NewRTMPServer() *RTMPServer {
s := &RTMPServer{
channels: make(map[string]*pubsub.Queue),
server: &rtmp.Server{},
channels: make(map[string]*pubsub.Queue),
transcoders: make(map[string][]*variantTranscoder),
internalPublishKey: generateInternalPublishKey(),
server: &rtmp.Server{},
}
// Triggered when a broadcaster (e.g., OBS) starts publishing
s.server.HandlePublish = func(conn *rtmp.Conn) {
streamPath := conn.URL.Path // Expected format: /live/{stream_key}
streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
monitor.Infof("OBS publish attempt: %s", streamPath)
// Extract stream key from path
parts := strings.Split(streamPath, "/")
if len(parts) < 3 || parts[1] != "live" {
if len(parts) < 3 {
monitor.Warnf("Invalid publish path format: %s", streamPath)
return
}
streamKey := parts[2]
// Authenticate stream key
var room model.Room
if err := db.DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil {
monitor.Warnf("Invalid stream key: %s", streamKey)
return // Reject connection
roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
if !ok {
monitor.Warnf("Invalid publish key/path: %s", streamPath)
return
}
monitor.Infof("Stream authenticated for room_id=%d", room.ID)
// 1. Get audio/video stream metadata
streams, err := conn.Streams()
if err != nil {
@@ -78,31 +109,40 @@ func NewRTMPServer() *RTMPServer {
return
}
// 2. Map the active stream by Room ID so viewers can use /live/{room_id}
roomLivePath := fmt.Sprintf("/live/%d", room.ID)
monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
s.mutex.Lock()
q := pubsub.NewQueue()
q.WriteHeader(streams)
s.channels[roomLivePath] = q
s.channels[channelPath] = q
s.mutex.Unlock()
// Mark room as active in DB (using map to ensure true/false is correctly updated)
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": true})
if isSource {
roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true})
}
s.startVariantTranscoders(roomID)
}
// 3. Cleanup on end
defer func() {
s.mutex.Lock()
delete(s.channels, roomLivePath)
delete(s.channels, channelPath)
s.mutex.Unlock()
q.Close()
// Explicitly set is_active to false using map
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false})
// Clear chat history for this room
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
monitor.Infof("Publishing ended for room_id=%d", room.ID)
if isSource {
s.stopVariantTranscoders(roomID)
roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
}
chat.MainHub.ClearRoomHistory(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID)
} else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
}
}()
// 4. Continuously copy data packets to our broadcast queue
@@ -158,6 +198,9 @@ func (s *RTMPServer) Start(addr string) error {
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
if quality := normalizeQuality(c.Query("quality")); quality != "" {
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
}
s.mutex.RLock()
q, ok := s.channels[streamPath]
@@ -198,10 +241,149 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
}
}
func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) {
if parts[1] == "live" && len(parts) == 3 {
var room model.Room
if err := db.DB.Where("stream_key = ?", parts[2]).First(&room).Error; err != nil {
return "", "", false, false
}
roomID = fmt.Sprintf("%d", room.ID)
channelPath = fmt.Sprintf("/live/%s", roomID)
return roomID, channelPath, true, true
}
if parts[1] == "variant" && len(parts) == 5 {
roomID = parts[2]
quality := normalizeQuality(parts[3])
token := parts[4]
if quality == "" || token != s.internalPublishKey {
return "", "", false, false
}
return roomID, fmt.Sprintf("/live/%s/%s", roomID, quality), false, true
}
return "", "", false, false
}
func (s *RTMPServer) startVariantTranscoders(roomID string) {
s.stopVariantTranscoders(roomID)
launch := make([]*variantTranscoder, 0, len(supportedQualities))
for quality, profile := range supportedQualities {
ctx, cancel := context.WithCancel(context.Background())
inputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID)
outputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/variant/%s/%s/%s", roomID, quality, s.internalPublishKey)
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-nostdin",
"-loglevel", "error",
"-i", inputURL,
"-vf", "scale="+profile.scale+":force_original_aspect_ratio=decrease",
"-c:v", "libx264",
"-preset", "veryfast",
"-tune", "zerolatency",
"-g", "48",
"-keyint_min", "48",
"-sc_threshold", "0",
"-b:v", profile.videoBitrate,
"-maxrate", profile.videoBitrate,
"-bufsize", profile.videoBitrate,
"-c:a", "aac",
"-b:a", profile.audioBitrate,
"-ar", "44100",
"-ac", "2",
"-f", "flv",
outputURL,
)
transcoder := &variantTranscoder{
quality: quality,
cancel: cancel,
cmd: cmd,
}
launch = append(launch, transcoder)
go func(roomID string, tr *variantTranscoder) {
time.Sleep(2 * time.Second)
monitor.Infof("Starting transcoder room_id=%s quality=%s", roomID, tr.quality)
if err := tr.cmd.Start(); err != nil {
monitor.Errorf("Failed to start transcoder room_id=%s quality=%s: %v", roomID, tr.quality, err)
return
}
if err := tr.cmd.Wait(); err != nil && ctx.Err() == nil {
monitor.Warnf("Transcoder exited room_id=%s quality=%s: %v", roomID, tr.quality, err)
}
}(roomID, transcoder)
}
s.mutex.Lock()
s.transcoders[roomID] = launch
s.mutex.Unlock()
}
func (s *RTMPServer) stopVariantTranscoders(roomID string) {
s.mutex.Lock()
transcoders := s.transcoders[roomID]
delete(s.transcoders, roomID)
s.mutex.Unlock()
for _, transcoder := range transcoders {
transcoder.cancel()
}
}
func normalizeQuality(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if _, ok := supportedQualities[value]; ok {
return value
}
return ""
}
func parseRoomID(value string) uint {
var roomID uint
_, _ = fmt.Sscanf(value, "%d", &roomID)
return roomID
}
func generateInternalPublishKey() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "internal_publish_fallback_key"
}
return hex.EncodeToString(buf)
}
func (s *RTMPServer) ActiveStreamCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
return len(s.channels)
count := 0
for path := range s.channels {
if strings.Count(path, "/") == 2 {
count++
}
}
return count
}
func (s *RTMPServer) AvailablePlaybackQualities(roomID string) []string {
s.mutex.RLock()
defer s.mutex.RUnlock()
basePath := fmt.Sprintf("/live/%s", roomID)
available := make([]string, 0, len(qualityOrder))
for _, quality := range qualityOrder {
streamPath := basePath
if quality != "source" {
streamPath = fmt.Sprintf("%s/%s", basePath, quality)
}
if _, ok := s.channels[streamPath]; ok {
available = append(available, quality)
}
}
return available
}
func (s *RTMPServer) ActiveStreamPaths() []string {
@@ -210,7 +392,9 @@ func (s *RTMPServer) ActiveStreamPaths() []string {
paths := make([]string, 0, len(s.channels))
for path := range s.channels {
paths = append(paths, path)
if strings.Count(path, "/") == 2 {
paths = append(paths, path)
}
}
return paths
}