diff --git a/shell.nix b/shell.nix index e9778d1e..62457365 100644 --- a/shell.nix +++ b/shell.nix @@ -32,7 +32,6 @@ in go wgo mediainfo - libmediainfo ffmpeg-full postgresql_15 pgformatter diff --git a/transcoder/Dockerfile b/transcoder/Dockerfile index 0481e850..19161b87 100644 --- a/transcoder/Dockerfile +++ b/transcoder/Dockerfile @@ -20,7 +20,7 @@ ENV SSL_CERT_DIR=/etc/ssl/certs RUN update-ca-certificates RUN apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y \ - ffmpeg libavformat-dev libavutil-dev libswscale-dev libmediainfo-dev \ + ffmpeg libavformat-dev libavutil-dev libswscale-dev \ && apt-get clean autoclean -y \ && apt-get autoremove -y WORKDIR /app @@ -42,7 +42,7 @@ RUN sed -i -e's/ main/ main contrib non-free/g' /etc/apt/sources.list.d/debian.s RUN set -x && apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y \ # runtime dependencies - ffmpeg libmediainfo-dev \ + ffmpeg \ # hwaccel dependencies vainfo mesa-va-drivers \ # intel hwaccel dependencies, not available everywhere diff --git a/transcoder/Dockerfile.dev b/transcoder/Dockerfile.dev index ecf1daaa..70671cc9 100644 --- a/transcoder/Dockerfile.dev +++ b/transcoder/Dockerfile.dev @@ -31,9 +31,9 @@ RUN sed -i -e's/ main/ main contrib non-free/g' /etc/apt/sources.list.d/debian.s RUN apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y \ # runtime dependencies - ffmpeg libavformat-dev \ + ffmpeg \ # build dependencies - libavutil-dev libswscale-dev libmediainfo-dev \ + libavformat-dev libavutil-dev libswscale-dev \ # hwaccel dependencies vainfo mesa-va-drivers \ # intel hwaccel dependencies, not available everywhere diff --git a/transcoder/go.mod b/transcoder/go.mod index ac2ee7eb..3a34c4b0 100644 --- a/transcoder/go.mod +++ b/transcoder/go.mod @@ -12,13 +12,12 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/zoriya/go-mediainfo v0.0.0-20240113011752-07018f07efae gitlab.com/opennota/screengen v1.0.2 golang.org/x/crypto v0.22.0 // indirect + golang.org/x/image v0.10.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect + gopkg.in/vansante/go-ffprobe.v2 v2.2.0 ) - -require golang.org/x/image v0.10.0 // indirect diff --git a/transcoder/go.sum b/transcoder/go.sum index a5a25bbe..904cd449 100644 --- a/transcoder/go.sum +++ b/transcoder/go.sum @@ -22,8 +22,6 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zoriya/go-mediainfo v0.0.0-20240113011752-07018f07efae h1:Xmtcb/GrG1jcLxC3cDFwdAM1oV7fLNBcN3h3YL+gN6g= -github.com/zoriya/go-mediainfo v0.0.0-20240113011752-07018f07efae/go.mod h1:jzun1oQGoJSh65g1XKaolTmjd6HW/34WHH7VMdJdbvM= gitlab.com/opennota/screengen v1.0.2 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw= gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -71,5 +69,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY= +gopkg.in/vansante/go-ffprobe.v2 v2.2.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/transcoder/src/codec.go b/transcoder/src/codec.go index 344c0dda..a52612d4 100644 --- a/transcoder/src/codec.go +++ b/transcoder/src/codec.go @@ -1,12 +1,11 @@ package src import ( - "cmp" "fmt" "log" "strings" - "github.com/zoriya/go-mediainfo" + "gopkg.in/vansante/go-ffprobe.v2" ) // convert mediainfo to RFC 6381, waiting for either of those tickets to be resolved: @@ -15,19 +14,13 @@ import ( // https://trac.ffmpeg.org/ticket/6617 // // this code is addapted from https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs -func GetMimeCodec(mi *mediainfo.File, kind mediainfo.StreamKind, i int) *string { - codec := cmp.Or( - mi.Parameter(kind, i, "InternetMediaType"), - mi.Parameter(kind, i, "Format"), - ) - - switch codec { - case "video/H264", "AVC": +// and https://git.ffmpeg.org/gitweb/ffmpeg.git/blob/HEAD%3a/libavformat/hlsenc.c#l344 +func GetMimeCodec(stream *ffprobe.Stream) *string { + switch stream.CodecName { + case "h264": ret := "avc1" - info := strings.Split(strings.ToLower(mi.Parameter(kind, i, "Format_Profile")), "@") - format := info[0] - switch format { + switch strings.ToLower(stream.Profile) { case "high": ret += ".6400" case "main": @@ -39,37 +32,30 @@ func GetMimeCodec(mi *mediainfo.File, kind mediainfo.StreamKind, i int) *string ret += ".4240" } - // level format is l3.1 for level 31 - level := ParseFloat(info[1][1:]) - ret += fmt.Sprintf("%02x", int(level*10)) + ret += fmt.Sprintf("%02x", stream.Level) return &ret - case "video/H265", "HEVC": + case "h265": // The h265 syntax is a bit of a mystery at the time this comment was written. // This is what I've found through various sources: // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] ret := "hvc1" - info := strings.Split(strings.ToLower(mi.Parameter(kind, i, "Format_Profile")), "@") - profile := info[0] - if profile == "main 10" { + if stream.Profile == "main 10" { ret += ".2.4" } else { ret += ".1.4" } - level := ParseFloat(info[1][:1]) - ret += fmt.Sprintf(".L%02X.BO", int(level*30)) + ret += fmt.Sprintf(".L%02X.BO", stream.Level) return &ret - case "AV1": + case "av1": // https://aomedia.org/av1/specification/annex-a/ // FORMAT: [codecTag].[profile].[level][tier].[bitDepth] ret := "av01" - info := strings.Split(strings.ToLower(mi.Parameter(kind, i, "Format_Profile")), "@") - profile := info[0] - switch profile { + switch strings.ToLower(stream.Profile) { case "main": ret += ".0" case "high": @@ -77,30 +63,24 @@ func GetMimeCodec(mi *mediainfo.File, kind mediainfo.StreamKind, i int) *string case "professional": ret += ".2" default: - // Default to Main - ret += ".0" } - // level is not defined in mediainfo. using a default - // Default to the maximum defined level 6.3 - level := 19 - - bitdepth := ParseUint(mi.Parameter(kind, i, "BitDepth")) + // not sure about this field, we want pixel bit depth + bitdepth := ParseUint(stream.BitsPerRawSample) if bitdepth != 8 && bitdepth != 10 && bitdepth != 12 { // Default to 8 bits bitdepth = 8 } tierflag := 'M' - ret += fmt.Sprintf(".%02X%c.%02d", level, tierflag, bitdepth) + ret += fmt.Sprintf(".%02X%c.%02d", stream.Level, tierflag, bitdepth) return &ret - case "AAC": + case "aac": ret := "mp4a" - profile := strings.ToLower(mi.Parameter(kind, i, "Format_AdditionalFeatures")) - switch profile { + switch strings.ToLower(stream.Profile) { case "he": ret += ".40.5" case "lc": @@ -111,24 +91,24 @@ func GetMimeCodec(mi *mediainfo.File, kind mediainfo.StreamKind, i int) *string return &ret - case "audio/opus", "Opus": + case "opus": ret := "Opus" return &ret - case "AC-3": + case "ac-3": ret := "mp4a.a5" return &ret - case "audio/eac3", "E-AC-3": + case "eac3", "E-AC-3": ret := "mp4a.a6" return &ret - case "audio/x-flac", "FLAC": + case "x-flac", "FLAC": ret := "fLaC" return &ret default: - log.Printf("No known mime format for: %s", codec) + log.Printf("No known mime format for: %s", stream.CodecName) return nil } } diff --git a/transcoder/src/info.go b/transcoder/src/info.go index c536aa98..478c2d9d 100644 --- a/transcoder/src/info.go +++ b/transcoder/src/info.go @@ -1,7 +1,7 @@ package src import ( - "cmp" + "context" "encoding/base64" "encoding/json" "fmt" @@ -13,9 +13,9 @@ import ( "strconv" "strings" "sync" - "unicode" + "time" - "github.com/zoriya/go-mediainfo" + "gopkg.in/vansante/go-ffprobe.v2" ) type MediaInfo struct { @@ -136,15 +136,6 @@ func ParseUint64(str string) uint64 { return i } -func ParseTime(str string) float32 { - x := strings.Split(str, ":") - hours, minutes, sms := ParseFloat(x[0]), ParseFloat(x[1]), x[2] - y := strings.Split(sms, ".") - seconds, ms := ParseFloat(y[0]), ParseFloat(y[1]) - - return (hours*60.+minutes)*60. + seconds + ms/1000. -} - func Map[T, U any](ts []T, f func(T, int) U) []U { us := make([]U, len(ts)) for i := range ts { @@ -153,6 +144,25 @@ func Map[T, U any](ts []T, f func(T, int) U) []U { return us } +func MapStream[T any](streams []*ffprobe.Stream, kind ffprobe.StreamType, mapper func(*ffprobe.Stream, uint32) T) []T { + count := 0 + for _, stream := range streams { + if stream.CodecType == string(kind) { + count++ + } + } + ret := make([]T, count) + + i := uint32(0) + for _, stream := range streams { + if stream.CodecType == string(kind) { + ret[i] = mapper(stream, i) + i++ + } + } + return ret +} + func OrNull(str string) *string { if str == "" { return nil @@ -181,11 +191,11 @@ func GetInfo(path string, sha string) (*MediaInfo, error) { mi.ready.Add(1) go func() { save_path := fmt.Sprintf("%s/%s/info.json", Settings.Metadata, sha) - if err := getSavedInfo(save_path, mi.info); err == nil { + // if err := getSavedInfo(save_path, mi.info); err == nil { log.Printf("Using mediainfo cache on filesystem for %s", path) - mi.ready.Done() - return - } + // mi.ready.Done() + // return + // } var val *MediaInfo val, err = getInfo(path) @@ -229,65 +239,45 @@ func saveInfo[T any](save_path string, mi *T) error { func getInfo(path string) (*MediaInfo, error) { defer printExecTime("mediainfo for %s", path)() - mi, err := mediainfo.Open(path) + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + mi, err := ffprobe.ProbeURL(ctx, path) if err != nil { return nil, err } - defer mi.Close() - chapters_begin := ParseUint(mi.Parameter(mediainfo.StreamMenu, 0, "Chapters_Pos_Begin")) - chapters_end := ParseUint(mi.Parameter(mediainfo.StreamMenu, 0, "Chapters_Pos_End")) - - attachments := strings.Split(mi.Parameter(mediainfo.StreamGeneral, 0, "Attachments"), " / ") - if len(attachments) == 1 && attachments[0] == "" { - attachments = make([]string, 0) - } - - // fmt.Printf("%s", mi.Option("info_parameters", "")) - - // duration in seconds - duration := ParseFloat(mi.Parameter(mediainfo.StreamGeneral, 0, "Duration")) / 1000 ret := MediaInfo{ Path: path, // Remove leading . Extension: filepath.Ext(path)[1:], - Size: ParseUint64(mi.Parameter(mediainfo.StreamGeneral, 0, "FileSize")), - Duration: duration, - Container: OrNull(mi.Parameter(mediainfo.StreamGeneral, 0, "Format")), - Videos: Map(make([]Video, ParseUint(mi.Parameter(mediainfo.StreamVideo, 0, "StreamCount"))), func(_ Video, i int) Video { + Size: ParseUint64(mi.Format.Size), + Duration: float32(mi.Format.DurationSeconds), + Container: OrNull(mi.Format.FormatName), + Videos: MapStream(mi.Streams, ffprobe.StreamVideo, func(stream *ffprobe.Stream, i uint32) Video { return Video{ - Codec: mi.Parameter(mediainfo.StreamVideo, i, "Format"), - MimeCodec: GetMimeCodec(mi, mediainfo.StreamVideo, i), - Language: OrNull(mi.Parameter(mediainfo.StreamVideo, i, "Language")), - Quality: QualityFromHeight(ParseUint(mi.Parameter(mediainfo.StreamVideo, i, "Height"))), - Width: ParseUint(mi.Parameter(mediainfo.StreamVideo, i, "Width")), - Height: ParseUint(mi.Parameter(mediainfo.StreamVideo, i, "Height")), - Bitrate: ParseUint( - cmp.Or( - mi.Parameter(mediainfo.StreamVideo, i, "BitRate"), - mi.Parameter(mediainfo.StreamVideo, i, "OverallBitRate"), - mi.Parameter(mediainfo.StreamVideo, i, "BitRate_Nominal"), - ), - ), + Codec: stream.CodecName, + MimeCodec: GetMimeCodec(stream), + Language: OrNull(stream.Tags.Language), + Quality: QualityFromHeight(uint32(stream.Height)), + Width: uint32(stream.Width), + Height: uint32(stream.Height), + Bitrate: ParseUint(stream.BitRate), } }), - Audios: Map(make([]Audio, ParseUint(mi.Parameter(mediainfo.StreamAudio, 0, "StreamCount"))), func(_ Audio, i int) Audio { + Audios: MapStream(mi.Streams, ffprobe.StreamAudio, func(stream *ffprobe.Stream, i uint32) Audio { return Audio{ - Index: uint32(i), - Title: OrNull(mi.Parameter(mediainfo.StreamAudio, i, "Title")), - Language: OrNull(mi.Parameter(mediainfo.StreamAudio, i, "Language")), - Codec: mi.Parameter(mediainfo.StreamAudio, i, "Format"), - MimeCodec: GetMimeCodec(mi, mediainfo.StreamAudio, i), - IsDefault: mi.Parameter(mediainfo.StreamAudio, i, "Default") == "Yes", - IsForced: mi.Parameter(mediainfo.StreamAudio, i, "Forced") == "Yes", + Index: i, + Title: OrNull(stream.Tags.Title), + Language: OrNull(stream.Tags.Language), + Codec: stream.CodecName, + MimeCodec: GetMimeCodec(stream), + IsDefault: stream.Disposition.Default != 0, + IsForced: stream.Disposition.Forced != 0, } }), - Subtitles: Map(make([]Subtitle, ParseUint(mi.Parameter(mediainfo.StreamText, 0, "StreamCount"))), func(_ Subtitle, i int) Subtitle { - format := strings.ToLower(mi.Parameter(mediainfo.StreamText, i, "Format")) - if format == "utf-8" { - format = "subrip" - } - extension := OrNull(SubtitleExtensions[format]) + Subtitles: MapStream(mi.Streams, ffprobe.StreamSubtitle, func(stream *ffprobe.Stream, i uint32) Subtitle { + extension := OrNull(SubtitleExtensions[stream.CodecName]) var link *string if extension != nil { x := fmt.Sprintf("%s/%s/subtitle/%d.%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), i, *extension) @@ -295,21 +285,26 @@ func getInfo(path string) (*MediaInfo, error) { } return Subtitle{ Index: uint32(i), - Title: OrNull(mi.Parameter(mediainfo.StreamText, i, "Title")), - Language: OrNull(mi.Parameter(mediainfo.StreamText, i, "Language")), - Codec: format, + Title: OrNull(stream.Tags.Title), + Language: OrNull(stream.Tags.Language), + Codec: stream.CodecName, Extension: extension, - IsDefault: mi.Parameter(mediainfo.StreamText, i, "Default") == "Yes", - IsForced: mi.Parameter(mediainfo.StreamText, i, "Forced") == "Yes", + IsDefault: stream.Disposition.Default != 0, + IsForced: stream.Disposition.Forced != 0, Link: link, } }), - Chapters: getChapters(chapters_begin, chapters_end, mi, duration), - Fonts: Map( - attachments, - func(font string, _ int) string { - return fmt.Sprintf("%s/%s/attachment/%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), font) - }), + Chapters: Map(mi.Chapters, func(c *ffprobe.Chapter, _ int) Chapter { + return Chapter{ + Name: c.Title(), + StartTime: float32(c.StartTimeSeconds), + EndTime: float32(c.EndTimeSeconds), + } + }), + Fonts: MapStream(mi.Streams, ffprobe.StreamAttachment, func(stream *ffprobe.Stream, i uint32) string { + font, _ := stream.TagList.GetString("filename") + return fmt.Sprintf("%s/%s/attachment/%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), font) + }), } var codecs []string if len(ret.Videos) > 0 && ret.Videos[0].MimeCodec != nil { @@ -334,40 +329,3 @@ func getInfo(path string) (*MediaInfo, error) { } return &ret, nil } - -func chapterTimeIsValid(chapterTime string) bool { - return len(chapterTime) > 0 && unicode.IsDigit(rune(chapterTime[0])) -} - -func getChapters(chapters_begin uint32, chapters_end uint32, mi *mediainfo.File, duration float32) []Chapter { - chapterCount := max(chapters_end-chapters_begin, 0) - chapterIterationCount := chapterCount - chapters := make([]Chapter, chapterCount) - chapterIndex := 0 - - for i := 0; i < int(chapterIterationCount); i++ { - rawStartTime := mi.GetI(mediainfo.StreamMenu, 0, int(chapters_begin)+i, mediainfo.InfoName) - rawEndTime := mi.GetI(mediainfo.StreamMenu, 0, int(chapters_begin)+i+1, mediainfo.InfoName) - // If true, this "chapter" is invalid. We skip it - if !chapterTimeIsValid(rawStartTime) { - chapterIterationCount = chapterIterationCount + 1 - continue - } - var endTime float32 - // If this fails, we probably are at the end of the video - // Since there would be no following chapter, - // we defacto set the end time to the end of the video (i.e. its duration) - if chapterTimeIsValid(rawEndTime) { - endTime = ParseTime(rawEndTime) - } else { - endTime = duration - } - chapters[chapterIndex] = Chapter{ - StartTime: ParseTime(rawStartTime), - EndTime: endTime, - Name: mi.GetI(mediainfo.StreamMenu, 0, int(chapters_begin)+i, mediainfo.InfoText), - } - chapterIndex++ - } - return chapters -}