Add live room preview thumbnails
This commit is contained in:
@@ -25,6 +25,7 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
||||
r.POST("/api/login", Login)
|
||||
r.POST("/api/admin/login", AdminLogin)
|
||||
r.GET("/api/rooms/active", GetActiveRooms)
|
||||
r.GET("/api/rooms/:room_id/thumbnail", streamServer.HandleThumbnail)
|
||||
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
|
||||
|
||||
// WebSocket endpoint for live chat
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -35,7 +37,9 @@ type RTMPServer struct {
|
||||
server *rtmp.Server
|
||||
channels map[string]*pubsub.Queue
|
||||
transcoders map[string][]*variantTranscoder
|
||||
thumbnailJobs map[string]context.CancelFunc
|
||||
internalPublishKey string
|
||||
thumbnailDir string
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -53,6 +57,8 @@ type qualityProfile struct {
|
||||
|
||||
var qualityOrder = []string{"source", "720p", "480p"}
|
||||
|
||||
const thumbnailCaptureInterval = 12 * time.Second
|
||||
|
||||
var supportedQualities = map[string]qualityProfile{
|
||||
"720p": {
|
||||
scale: "1280:-2",
|
||||
@@ -81,7 +87,9 @@ func NewRTMPServer() *RTMPServer {
|
||||
s := &RTMPServer{
|
||||
channels: make(map[string]*pubsub.Queue),
|
||||
transcoders: make(map[string][]*variantTranscoder),
|
||||
thumbnailJobs: make(map[string]context.CancelFunc),
|
||||
internalPublishKey: generateInternalPublishKey(),
|
||||
thumbnailDir: filepath.Join(os.TempDir(), "hightube-thumbnails"),
|
||||
server: &rtmp.Server{},
|
||||
}
|
||||
|
||||
@@ -123,6 +131,7 @@ func NewRTMPServer() *RTMPServer {
|
||||
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true})
|
||||
}
|
||||
s.startVariantTranscoders(roomID)
|
||||
s.startThumbnailCapture(roomID)
|
||||
}
|
||||
|
||||
// 3. Cleanup on end
|
||||
@@ -134,6 +143,7 @@ func NewRTMPServer() *RTMPServer {
|
||||
|
||||
if isSource {
|
||||
s.stopVariantTranscoders(roomID)
|
||||
s.stopThumbnailCapture(roomID)
|
||||
roomIDUint := parseRoomID(roomID)
|
||||
if roomIDUint != 0 {
|
||||
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
|
||||
@@ -241,6 +251,17 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RTMPServer) HandleThumbnail(c *gin.Context) {
|
||||
thumbnailPath := s.thumbnailPath(c.Param("room_id"))
|
||||
if _, err := os.Stat(thumbnailPath); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Thumbnail not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
c.File(thumbnailPath)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -333,6 +354,95 @@ func (s *RTMPServer) stopVariantTranscoders(roomID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RTMPServer) startThumbnailCapture(roomID string) {
|
||||
s.stopThumbnailCapture(roomID)
|
||||
|
||||
if err := os.MkdirAll(s.thumbnailDir, 0o755); err != nil {
|
||||
monitor.Errorf("Failed to create thumbnail directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
s.mutex.Lock()
|
||||
s.thumbnailJobs[roomID] = cancel
|
||||
s.mutex.Unlock()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
|
||||
s.captureThumbnail(roomID)
|
||||
|
||||
ticker := time.NewTicker(thumbnailCaptureInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.captureThumbnail(roomID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *RTMPServer) stopThumbnailCapture(roomID string) {
|
||||
s.mutex.Lock()
|
||||
cancel := s.thumbnailJobs[roomID]
|
||||
delete(s.thumbnailJobs, roomID)
|
||||
s.mutex.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
|
||||
_ = os.Remove(s.thumbnailPath(roomID))
|
||||
}
|
||||
|
||||
func (s *RTMPServer) captureThumbnail(roomID string) {
|
||||
outputPath := s.thumbnailPath(roomID)
|
||||
tempPath := outputPath + ".tmp.jpg"
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(
|
||||
ctx,
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-loglevel", "error",
|
||||
"-rtmp_live", "live",
|
||||
"-i", fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID),
|
||||
"-frames:v", "1",
|
||||
"-q:v", "4",
|
||||
tempPath,
|
||||
)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
monitor.Warnf("Thumbnail capture timed out for room_id=%s", roomID)
|
||||
return
|
||||
}
|
||||
monitor.Warnf("Thumbnail capture failed for room_id=%s: %v", roomID, err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(outputPath)
|
||||
if err := os.Rename(tempPath, outputPath); err != nil {
|
||||
monitor.Warnf("Failed to store thumbnail for room_id=%s: %v", roomID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RTMPServer) thumbnailPath(roomID string) string {
|
||||
return filepath.Join(s.thumbnailDir, fmt.Sprintf("%s.jpg", roomID))
|
||||
}
|
||||
|
||||
func normalizeQuality(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if _, ok := supportedQualities[value]; ok {
|
||||
|
||||
Reference in New Issue
Block a user