package src import ( "cmp" "context" "encoding/base64" "fmt" "mime" "path/filepath" "strconv" "strings" "sync" "time" "golang.org/x/text/language" "gopkg.in/vansante/go-ffprobe.v2" ) const InfoVersion = 1 type Versions struct { Info int32 `json:"info"` Extract int32 `json:"extract"` Thumbs int32 `json:"thumbs"` Keyframes int32 `json:"keyframes"` } type MediaInfo struct { // The sha1 of the video file. Sha string `json:"sha"` /// The internal path of the video file. Path string `json:"path"` /// The extension currently used to store this video file Extension string `json:"extension"` /// The whole mimetype (defined as the RFC 6381). ex: `video/mp4; codecs="avc1.640028, mp4a.40.2"` MimeCodec *string `json:"mimeCodec"` /// The file size of the video file. Size int64 `json:"size"` /// The length of the media in seconds. Duration float64 `json:"duration"` /// The container of the video file of this episode. Container *string `json:"container"` /// Version of the metadata. This can be used to invalidate older metadata from db if the extraction code has changed. Versions Versions `json:"versions"` // TODO: remove on next major Video Video `json:"video"` /// The list of videos if there are multiples. Videos []Video `json:"videos"` /// The list of audio tracks. Audios []Audio `json:"audios"` /// The list of subtitles tracks. Subtitles []Subtitle `json:"subtitles"` /// The list of fonts that can be used to display subtitles. Fonts []string `json:"fonts"` /// The list of chapters. See Chapter for more information. Chapters []Chapter `json:"chapters"` /// lock used to read/set keyframes of video/audio lock sync.Mutex } type Video struct { /// The index of this track on the media. Index uint32 `json:"index"` /// The title of the stream. Title *string `json:"title"` /// The language of this stream (as a ISO-639-2 language code) Language *string `json:"language"` /// The human readable codec name. Codec string `json:"codec"` /// The codec of this stream (defined as the RFC 6381). MimeCodec *string `json:"mimeCodec"` /// The width of the video stream Width uint32 `json:"width"` /// The height of the video stream Height uint32 `json:"height"` /// The average bitrate of the video in bytes/s Bitrate uint32 `json:"bitrate"` /// Is this stream the default one of it's type? IsDefault bool `json:"isDefault"` /// Keyframes of this video Keyframes *Keyframe `json:"-"` } type Audio struct { /// The index of this track on the media. Index uint32 `json:"index"` /// The title of the stream. Title *string `json:"title"` /// The language of this stream (as a IETF-BCP-47 language code) Language *string `json:"language"` /// The human readable codec name. Codec string `json:"codec"` /// The codec of this stream (defined as the RFC 6381). MimeCodec *string `json:"mimeCodec"` /// The average bitrate of the audio in bytes/s Bitrate uint32 `json:"bitrate"` /// Is this stream the default one of it's type? IsDefault bool `json:"isDefault"` /// Keyframes of this video Keyframes *Keyframe `json:"-"` //TODO: remove this in next major IsForced bool `json:"isForced"` } type Subtitle struct { /// The index of this track on the media. Index *uint32 `json:"index"` /// The title of the stream. Title *string `json:"title"` /// The language of this stream (as a IETF-BCP-47 language code) Language *string `json:"language"` /// The codec of this stream. Codec string `json:"codec"` /// The extension for the codec. Extension *string `json:"extension"` /// Is this stream the default one of it's type? IsDefault bool `json:"isDefault"` /// Is this stream tagged as forced? IsForced bool `json:"isForced"` /// Is this stream tagged as hearing impaired? IsHearingImpaired bool `json:"isHearingImpaired"` /// Is this an external subtitle (as in stored in a different file) IsExternal bool `json:"isExternal"` /// Where the subtitle is stored (null if stored inside the video) Path *string `json:"path"` /// The link to access this subtitle. Link *string `json:"link"` } type Chapter struct { /// The start time of the chapter (in second from the start of the episode). StartTime float32 `json:"startTime"` /// The end time of the chapter (in second from the start of the episode). EndTime float32 `json:"endTime"` /// The name of this chapter. This should be a human-readable name that could be presented to the user. Name string `json:"name"` /// The type value is used to mark special chapters (openning/credits...) Type ChapterType } type ChapterType string const ( Content ChapterType = "content" Recap ChapterType = "recap" Intro ChapterType = "intro" Credits ChapterType = "credits" Preview ChapterType = "preview" ) func ParseFloat(str string) float32 { f, err := strconv.ParseFloat(str, 32) if err != nil { return 0 } return float32(f) } func ParseUint(str string) uint32 { i, err := strconv.ParseUint(str, 10, 32) if err != nil { println(str) return 0 } return uint32(i) } func ParseInt64(str string) int64 { i, err := strconv.ParseInt(str, 10, 64) if err != nil { println(str) return 0 } return i } func Map[T, U any](ts []T, f func(T, int) U) []U { us := make([]U, len(ts)) for i := range ts { us[i] = f(ts[i], i) } 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 } return &str } func NullIfUnd(str string) *string { if str == "und" { return nil } return &str } var SubtitleExtensions = map[string]string{ "subrip": "srt", "ass": "ass", "vtt": "vtt", } func RetriveMediaInfo(path string, sha string) (*MediaInfo, error) { defer printExecTime("mediainfo for %s", path)() ctx, cancelFn := context.WithTimeout(context.Background(), 30*time.Second) defer cancelFn() mi, err := ffprobe.ProbeURL(ctx, path) if err != nil { return nil, err } ret := MediaInfo{ Sha: sha, Path: path, // Remove leading . Extension: filepath.Ext(path)[1:], Size: ParseInt64(mi.Format.Size), Duration: mi.Format.DurationSeconds, Container: OrNull(mi.Format.FormatName), Versions: Versions{ Info: InfoVersion, Extract: 0, Thumbs: 0, Keyframes: 0, }, Videos: MapStream(mi.Streams, ffprobe.StreamVideo, func(stream *ffprobe.Stream, i uint32) Video { lang, _ := language.Parse(stream.Tags.Language) return Video{ Index: i, Codec: stream.CodecName, MimeCodec: GetMimeCodec(stream), Title: OrNull(stream.Tags.Title), Language: NullIfUnd(lang.String()), Width: uint32(stream.Width), Height: uint32(stream.Height), // ffmpeg does not report bitrate in mkv files, fallback to bitrate of the whole container // (bigger than the result since it contains audio and other videos but better than nothing). Bitrate: ParseUint(cmp.Or(stream.BitRate, mi.Format.BitRate)), IsDefault: stream.Disposition.Default != 0, } }), Audios: MapStream(mi.Streams, ffprobe.StreamAudio, func(stream *ffprobe.Stream, i uint32) Audio { lang, _ := language.Parse(stream.Tags.Language) return Audio{ Index: i, Title: OrNull(stream.Tags.Title), Language: NullIfUnd(lang.String()), Codec: stream.CodecName, MimeCodec: GetMimeCodec(stream), Bitrate: ParseUint(cmp.Or(stream.BitRate, mi.Format.BitRate)), IsDefault: stream.Disposition.Default != 0, } }), Subtitles: MapStream(mi.Streams, ffprobe.StreamSubtitle, func(stream *ffprobe.Stream, i uint32) Subtitle { extension := OrNull(SubtitleExtensions[stream.CodecName]) var link string if extension != nil { link = fmt.Sprintf("%s/%s/subtitle/%d.%s", Settings.RoutePrefix, base64.RawURLEncoding.EncodeToString([]byte(path)), i, *extension) } lang, _ := language.Parse(stream.Tags.Language) idx := uint32(i) return Subtitle{ Index: &idx, Title: OrNull(stream.Tags.Title), Language: NullIfUnd(lang.String()), Codec: stream.CodecName, Extension: extension, IsDefault: stream.Disposition.Default != 0, IsForced: stream.Disposition.Forced != 0, IsHearingImpaired: stream.Disposition.HearingImpaired != 0, Link: &link, } }), Chapters: Map(mi.Chapters, func(c *ffprobe.Chapter, _ int) Chapter { return Chapter{ Name: c.Title(), StartTime: float32(c.StartTimeSeconds), EndTime: float32(c.EndTimeSeconds), // TODO: detect content type Type: Content, } }), 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.RawURLEncoding.EncodeToString([]byte(path)), font) }), } var codecs []string if len(ret.Videos) > 0 && ret.Videos[0].MimeCodec != nil { codecs = append(codecs, *ret.Videos[0].MimeCodec) } if len(ret.Audios) > 0 && ret.Audios[0].MimeCodec != nil { codecs = append(codecs, *ret.Audios[0].MimeCodec) } container := mime.TypeByExtension(fmt.Sprintf(".%s", ret.Extension)) if container != "" { if len(codecs) > 0 { codecs_str := strings.Join(codecs, ", ") mime := fmt.Sprintf("%s; codecs=\"%s\"", container, codecs_str) ret.MimeCodec = &mime } else { ret.MimeCodec = &container } } if len(ret.Videos) > 0 { ret.Video = ret.Videos[0] } return &ret, nil }