Files
Hightube/backend/internal/stream/server.go

401 lines
10 KiB
Go

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"
"github.com/nareix/joy4/av/pubsub"
"github.com/nareix/joy4/format"
"github.com/nareix/joy4/format/flv"
"github.com/nareix/joy4/format/rtmp"
"hightube/internal/chat"
"hightube/internal/db"
"hightube/internal/model"
"hightube/internal/monitor"
)
func init() {
// Register all supported audio/video formats
format.RegisterAll()
}
// RTMPServer manages all active live streams
type RTMPServer struct {
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 {
httpFlusher http.Flusher
io.Writer
}
func (w writeFlusher) Flush() error {
w.httpFlusher.Flush()
return nil
}
// NewRTMPServer creates and initializes a new media server
func NewRTMPServer() *RTMPServer {
s := &RTMPServer{
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} or /variant/{room_id}/{quality}/{token}
monitor.Infof("OBS publish attempt: %s", streamPath)
parts := strings.Split(streamPath, "/")
if len(parts) < 3 {
monitor.Warnf("Invalid publish path format: %s", streamPath)
return
}
roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
if !ok {
monitor.Warnf("Invalid publish key/path: %s", streamPath)
return
}
// 1. Get audio/video stream metadata
streams, err := conn.Streams()
if err != nil {
monitor.Errorf("Failed to parse stream headers: %v", err)
return
}
monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
s.mutex.Lock()
q := pubsub.NewQueue()
q.WriteHeader(streams)
s.channels[channelPath] = q
s.mutex.Unlock()
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, channelPath)
s.mutex.Unlock()
q.Close()
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
avutil.CopyPackets(q, conn)
}
// Triggered when a viewer (e.g., VLC) requests playback
s.server.HandlePlay = func(conn *rtmp.Conn) {
streamPath := conn.URL.Path // Expected format: /live/{room_id}
monitor.Infof("RTMP play requested: %s", streamPath)
// 1. Look for the requested room's data queue
s.mutex.RLock()
q, ok := s.channels[streamPath]
s.mutex.RUnlock()
if !ok {
monitor.Warnf("Stream not found or inactive: %s", streamPath)
return
}
// 2. Get the cursor from the latest position and notify client of stream format
cursor := q.Latest()
streams, _ := cursor.Streams()
conn.WriteHeader(streams)
// 3. Cleanup on end
defer monitor.Infof("Playback ended: %s", streamPath)
// 4. Continuously copy data packets to the viewer
err := avutil.CopyPackets(conn, cursor)
if err != nil && err != io.EOF {
// 如果是客户端主动断开连接引起的错误,不将其作为严重错误打印
errStr := err.Error()
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
monitor.Infof("Viewer disconnected: %s", streamPath)
} else {
monitor.Errorf("Playback error on %s: %v", streamPath, err)
}
}
}
return s
}
// Start launches the RTMP server
func (s *RTMPServer) Start(addr string) error {
s.server.Addr = addr
monitor.Infof("RTMP server listening on %s", addr)
return s.server.ListenAndServe()
}
// 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]
s.mutex.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "Stream not found or inactive"})
return
}
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming is not supported by the current server"})
return
}
c.Header("Content-Type", "video/x-flv")
c.Header("Transfer-Encoding", "chunked")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
c.Status(http.StatusOK)
flusher.Flush()
muxer := flv.NewMuxerWriteFlusher(writeFlusher{
httpFlusher: flusher,
Writer: c.Writer,
})
cursor := q.Latest()
if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF {
errStr := err.Error()
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
monitor.Infof("HTTP-FLV viewer disconnected: %s", streamPath)
return
}
monitor.Errorf("HTTP-FLV playback error on %s: %v", streamPath, err)
}
}
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()
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 {
s.mutex.RLock()
defer s.mutex.RUnlock()
paths := make([]string, 0, len(s.channels))
for path := range s.channels {
if strings.Count(path, "/") == 2 {
paths = append(paths, path)
}
}
return paths
}