Add multi-resolution playback support
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user