mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-12-18 12:35:25 +00:00
379 lines
11 KiB
Go
379 lines
11 KiB
Go
package src
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
|
|
"github.com/zoriya/go-mediainfo"
|
|
)
|
|
|
|
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 uint64 `json:"size"`
|
|
/// The length of the media in seconds.
|
|
Duration float32 `json:"duration"`
|
|
/// The container of the video file of this episode.
|
|
Container *string `json:"container"`
|
|
/// The video codec and infromations.
|
|
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"`
|
|
}
|
|
|
|
type Video struct {
|
|
/// The human readable codec name.
|
|
Codec string `json:"codec"`
|
|
/// The codec of this stream (defined as the RFC 6381).
|
|
MimeCodec *string `json:"mimeCodec"`
|
|
/// The language of this stream (as a ISO-639-2 language code)
|
|
Language *string `json:"language"`
|
|
/// The max quality of this video track.
|
|
Quality Quality `json:"quality"`
|
|
/// 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"`
|
|
}
|
|
|
|
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 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"`
|
|
/// Is this stream the default one of it's type?
|
|
IsDefault bool `json:"isDefault"`
|
|
/// Is this stream tagged as forced? (useful only for subtitles)
|
|
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 ISO-639-2 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? (useful only for subtitles)
|
|
IsForced bool `json:"isForced"`
|
|
/// 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"`
|
|
// TODO: add a type field for Opening, Credits...
|
|
}
|
|
|
|
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 ParseUint64(str string) uint64 {
|
|
i, err := strconv.ParseUint(str, 10, 64)
|
|
if err != nil {
|
|
println(str)
|
|
return 0
|
|
}
|
|
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 {
|
|
us[i] = f(ts[i], i)
|
|
}
|
|
return us
|
|
}
|
|
|
|
func Filter[T any](ss []T, test func(T, int) bool) (ret []T) {
|
|
for i, s := range ss {
|
|
if test(s, i) {
|
|
ret = append(ret, s)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func OrNull(str string) *string {
|
|
if str == "" {
|
|
return nil
|
|
}
|
|
return &str
|
|
}
|
|
|
|
var SubtitleExtensions = map[string]string{
|
|
"subrip": "srt",
|
|
"ass": "ass",
|
|
"vtt": "vtt",
|
|
}
|
|
|
|
type MICache struct {
|
|
info *MediaInfo
|
|
ready sync.WaitGroup
|
|
}
|
|
|
|
var infos = NewCMap[string, *MICache]()
|
|
|
|
func GetInfo(path string, sha string) (*MediaInfo, error) {
|
|
var err error
|
|
|
|
ret, _ := infos.GetOrCreate(sha, func() *MICache {
|
|
mi := &MICache{info: &MediaInfo{Sha: sha}}
|
|
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 {
|
|
log.Printf("Using mediainfo cache on filesystem for %s", path)
|
|
mi.ready.Done()
|
|
return
|
|
}
|
|
|
|
var val *MediaInfo
|
|
val, err = getInfo(path)
|
|
*mi.info = *val
|
|
mi.info.Sha = sha
|
|
mi.ready.Done()
|
|
saveInfo(save_path, mi.info)
|
|
}()
|
|
return mi
|
|
})
|
|
ret.ready.Wait()
|
|
return ret.info, err
|
|
}
|
|
|
|
func getSavedInfo[T any](save_path string, mi *T) error {
|
|
saved_file, err := os.Open(save_path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
saved, err := io.ReadAll(saved_file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = json.Unmarshal([]byte(saved), mi)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func saveInfo[T any](save_path string, mi *T) error {
|
|
content, err := json.Marshal(*mi)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(save_path, content, 0o644)
|
|
}
|
|
|
|
func getInfo(path string) (*MediaInfo, error) {
|
|
defer printExecTime("mediainfo for %s", path)()
|
|
|
|
mi, err := mediainfo.Open(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 {
|
|
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"),
|
|
),
|
|
),
|
|
}
|
|
}),
|
|
Audios: Map(make([]Audio, ParseUint(mi.Parameter(mediainfo.StreamAudio, 0, "StreamCount"))), func(_ Audio, i int) 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",
|
|
}
|
|
}),
|
|
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])
|
|
var link *string
|
|
if extension != nil {
|
|
x := fmt.Sprintf("%s/%s/subtitle/%d.%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), i, *extension)
|
|
link = &x
|
|
}
|
|
return Subtitle{
|
|
Index: uint32(i),
|
|
Title: OrNull(mi.Parameter(mediainfo.StreamText, i, "Title")),
|
|
Language: OrNull(mi.Parameter(mediainfo.StreamText, i, "Language")),
|
|
Codec: format,
|
|
Extension: extension,
|
|
IsDefault: mi.Parameter(mediainfo.StreamText, i, "Default") == "Yes",
|
|
IsForced: mi.Parameter(mediainfo.StreamText, i, "Forced") == "Yes",
|
|
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)
|
|
}),
|
|
}
|
|
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
|
|
}
|
|
|
|
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, 1)
|
|
chapterIterationCount := chapterCount
|
|
|
|
for i := uint32(0); i < chapterIterationCount; i++ {
|
|
startTime := mi.GetI(mediainfo.StreamMenu, 0, int(chapters_begin+i), mediainfo.InfoName)
|
|
if !chapterTimeIsValid(startTime) {
|
|
chapterIterationCount = chapterIterationCount + 1
|
|
}
|
|
}
|
|
return Filter(Map(make([]Chapter, chapterIterationCount), func(_ Chapter, i int) Chapter {
|
|
startTime := mi.GetI(mediainfo.StreamMenu, 0, int(chapters_begin)+i, mediainfo.InfoName)
|
|
if !chapterTimeIsValid(startTime) {
|
|
return Chapter{}
|
|
}
|
|
// +1 is safe, the value at chapters_end contains the right duration
|
|
rawEndTime := mi.GetI(mediainfo.StreamMenu, 0, int(chapters_begin)+i+1, mediainfo.InfoName)
|
|
parsedEndTime := duration
|
|
if chapterTimeIsValid(rawEndTime) {
|
|
parsedEndTime = ParseTime(rawEndTime)
|
|
}
|
|
return Chapter{
|
|
StartTime: ParseTime(startTime),
|
|
EndTime: parsedEndTime,
|
|
Name: mi.GetI(mediainfo.StreamMenu, 0, int(chapters_begin)+i, mediainfo.InfoText),
|
|
}
|
|
}), func(chapter Chapter, i int) bool {
|
|
return chapter != Chapter{}
|
|
})
|
|
}
|