This commit is contained in:
marcsello 2025-10-09 22:21:38 +02:00
commit fa47df6d89
22 changed files with 1724 additions and 0 deletions

237
handlersMedia.go Normal file
View file

@ -0,0 +1,237 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"path"
"strconv"
"stream-poc2/ffmpeg"
"stream-poc2/model"
"time"
"github.com/gin-gonic/gin"
)
func mediaListingHandler(ctx *gin.Context) {
entries, err := os.ReadDir("db")
if err != nil {
log.Println("Failed to list dir", err)
ctx.Status(500)
return
}
var medias []model.APIMedia
for _, e := range entries {
if !e.IsDir() {
continue
}
var m model.MediaMetadata
m, err = loadMediaMetadata(e.Name())
if err != nil {
log.Println("Failed to load metadata for", e)
continue
}
medias = append(medias, model.APIMedia{
Name: e.Name(),
FriendlyName: m.FriendlyName,
})
}
log.Println("loaded", medias)
ctx.JSON(200, medias)
}
func metadataHandler(ctx *gin.Context) {
_, m := getMetadata(ctx)
ctx.JSON(http.StatusOK, m)
}
func masterPlaylistHandler(ctx *gin.Context) {
mediaID, metadata := getMetadata(ctx)
masterPlaylist := fmt.Sprintf("#EXTM3U\n\n# Master playlist for %s\n\n", mediaID)
for profile, profileSettings := range model.VideoProfiles {
masterPlaylist += fmt.Sprintf(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,CODECS=\"%s\",RESOLUTION=%s,FRAME-RATE=%.6f,AUDIO=\"audios\",NAME=\"%s\",STABLE-VARIANT-ID\"%s\"\n",
profileSettings.MaxBitrate,
profileSettings.AvgBitrate,
profileSettings.HLSCodec(),
profileSettings.Resolution.String(),
metadata.VideoStream.FrameRate,
profile, profile,
)
masterPlaylist += fmt.Sprintf("video/%s/playlist.m3u8\n\n", profile)
}
profile := "orig"
masterPlaylist += fmt.Sprintf(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,CODECS=\"%s\",RESOLUTION=%s,FRAME-RATE=%.6f,AUDIO=\"audios\",NAME=\"%s\",STABLE-VARIANT-ID\"%s\"\n",
metadata.VideoStream.PeakBitrate,
metadata.VideoStream.AvgBitrate,
metadata.VideoStream.HLSCodecsString,
metadata.VideoStream.Resolution,
metadata.VideoStream.FrameRate,
profile, profile,
)
masterPlaylist += fmt.Sprintf("video/%s/playlist.m3u8\n\n", profile)
defaultMarked := false
for _, audioStream := range metadata.AudioStreams {
autoSelect := ""
if audioStream.Default && !defaultMarked {
autoSelect = ",AUTOSELECT=YES,DEFAULT=YES"
defaultMarked = true
}
audioStreamPath := fmt.Sprintf("audio/%d/playlist.m3u8", audioStream.Index)
masterPlaylist += fmt.Sprintf(
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audios\",NAME=\"%s\",LANGUAGE=\"%s\"%s,CHANNELS=\"%d\",SAMPLE-RATE=%d,URI=\"%s\"\n",
audioStream.Title, audioStream.Lang, autoSelect, audioStream.Channels, audioStream.SampleRate, audioStreamPath,
)
}
ctx.Data(200, "application/vnd.apple.mpegurl", []byte(masterPlaylist))
}
func videoPlaylistHandler(ctx *gin.Context) {
_, metadata := getMetadata(ctx)
playlist := fmt.Sprintf(`#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:%f
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
`, metadata.TargetDuration)
for i, chunk := range metadata.VideoStream.VideoChunks {
lenStr := strconv.FormatFloat(chunk.Length, 'f', 6, 64)
playlist += fmt.Sprintf("#EXTINF:%s,\n", lenStr)
playlist += fmt.Sprintf("s%d.ts\n", i)
}
playlist += "#EXT-X-ENDLIST"
ctx.Data(200, "application/vnd.apple.mpegurl", []byte(playlist))
}
func audioPlaylistHandler(ctx *gin.Context) {
_, metadata := getMetadata(ctx)
idxStr := ctx.Param("idx")
idx, err := strconv.Atoi(idxStr)
if err != nil {
log.Println("Invalid index")
ctx.Status(404)
return
}
var langChunks []model.Chunk
for _, audioStream := range metadata.AudioStreams {
if audioStream.Index == idx {
langChunks = audioStream.AudioChunks
break
}
}
if langChunks == nil {
log.Println("index not found")
ctx.Status(404)
return
}
playlist := fmt.Sprintf(`#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:%f
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
`, metadata.TargetDuration)
for i, chunk := range langChunks {
lenStr := strconv.FormatFloat(chunk.Length, 'f', 6, 64)
playlist += fmt.Sprintf("#EXTINF:%s,\n", lenStr)
playlist += fmt.Sprintf("s%d.ts\n", i)
}
playlist += "#EXT-X-ENDLIST"
ctx.Data(200, "application/vnd.apple.mpegurl", []byte(playlist))
}
// serveChunk must be the last function call in the branch
func serveChunk(ctx *gin.Context, chunkPath string) {
stat, err := os.Stat(chunkPath)
if err != nil {
log.Printf("failed to stat chunk %s: %s", chunkPath, err)
if os.IsNotExist(err) {
ctx.Status(404)
return
}
ctx.Status(500)
return
}
f, err := os.OpenFile(chunkPath, os.O_RDONLY, 0)
if err != nil {
log.Printf("failed to open chunk %s: %s", chunkPath, err)
ctx.Status(500)
return
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
log.Println("failed to close chunk:", err)
}
}(f)
ctx.DataFromReader(200, stat.Size(), "video/MP2T; charset=binary", f, nil)
}
func videoSegmentHandler(ctx *gin.Context) {
mediaID, metadata := getMetadata(ctx)
profile := ctx.Param("profile")
segment := ctx.Param("segment")
origSegmentPath := path.Join("db", mediaID, "video", segment)
// quick path for identity stream
if profile == "orig" {
log.Println("Serving identity")
serveChunk(ctx, origSegmentPath)
return
}
profileSettings, ok := model.VideoProfiles[profile]
if !ok {
log.Println("invalid profile")
ctx.Status(404)
return
}
preEncodedSegmentPath := path.Join("db", mediaID, "cache", "video", profile, segment)
if isFile(preEncodedSegmentPath) {
log.Println("Serving from cache")
serveChunk(ctx, preEncodedSegmentPath)
return
}
log.Println("Serving transcode")
// transcode video
ctx.Writer.Header().Set("Content-Type", "video/MP2T; charset=binary")
ctx.Writer.WriteHeader(200)
encodeStartTime := time.Now()
err := ffmpeg.EncodeChunk(profileSettings, metadata.VideoStream.Resolution.Height, getCurrentPreset(), origSegmentPath, ctx.Writer)
if err != nil {
log.Println("!!! WARNING: Something went wrong while encoding: ", err)
}
log.Println("transcode took ", time.Since(encodeStartTime))
}
func audioSegmentHandler(ctx *gin.Context) {
mediaID, _ := getMetadata(ctx)
idx := ctx.Param("idx")
segment := ctx.Param("segment")
origSegmentPath := path.Join("db", mediaID, "audio", idx, segment)
serveChunk(ctx, origSegmentPath)
}