237 lines
6.1 KiB
Go
237 lines
6.1 KiB
Go
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)
|
|
}
|