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) }