package service import ( "context" "encoding/json" "fmt" "math" "os" "strconv" "strings" "time" "vidarchive/internal/models" "vidarchive/internal/util" ) // GetMetadata probes media details for the named file within an item. An empty // filename (or one that doesn't match) falls back to the item's primary media // file, so the detail view shows metadata for whichever file is selected. func (s *LibraryService) GetMetadata(relPath, filename string) (*MediaMetadata, error) { item, err := s.GetByRelPath(relPath) if err != nil { return nil, err } var target *models.MediaFile if filename != "" { for i := range item.MediaFiles { if item.MediaFiles[i].Filename == filename { target = &item.MediaFiles[i] break } } } if target == nil { target = primaryMediaFile(item) } if target == nil { return nil, fmt.Errorf("no media file") } return s.probeMedia(target.Filepath) } const probeTimeout = 30 * time.Second // probeStreams runs ffprobe once and returns the container format and every // stream in it. It is the single ffprobe entry point: callers that only care // about one stream kind filter the result themselves, rather than each // re-declaring the same command and JSON shape. func (s *LibraryService) probeStreams(path string) (ffprobeOutput, error) { // Reading a local file's headers is quick. A longer run means ffprobe is // stuck on a truncated or unreadable file, so cut it off rather than block // the request that asked for it. ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) defer cancel() var probe ffprobeOutput output, err := util.KillableCommand(ctx, s.ffprobePath, "-v", "error", "-show_format", "-show_streams", "-of", "json", path, ).Output() if err != nil { return probe, err } if err := json.Unmarshal(output, &probe); err != nil { return probe, err } return probe, nil } func (s *LibraryService) probeMedia(path string) (*MediaMetadata, error) { info, err := os.Stat(path) if err != nil { return nil, err } meta := &MediaMetadata{ FileSize: info.Size(), } // A file ffprobe can't read still has a size worth showing, so a probe // failure degrades to the size-only metadata rather than erroring. probe, err := s.probeStreams(path) if err != nil { return meta, nil } if probe.Format.FormatName != "" { parts := strings.Split(probe.Format.FormatName, ",") meta.Container = parts[0] } for _, stream := range probe.Streams { switch stream.CodecType { case "video": vs := VideoStream{ Codec: stream.CodecName, Profile: stream.Profile, Width: stream.Width, Height: stream.Height, FPS: parseFPS(stream.RFrameRate), PixelFormat: stream.PixFmt, Bitrate: formatBitrate(stream.BitRate), } meta.VideoStreams = append(meta.VideoStreams, vs) if stream.Width > 0 && stream.Height > 0 { meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height) } case "audio": as := AudioStream{ Codec: stream.CodecName, SampleRate: stream.SampleRate, Channels: stream.Channels, ChannelLayout: stream.ChannelLayout, SampleFormat: stream.SampleFmt, Bitrate: formatBitrate(stream.BitRate), Language: stream.Tags.Language, } meta.AudioStreams = append(meta.AudioStreams, as) case "subtitle": ss := SubtitleStream{ Codec: stream.CodecName, Language: stream.Tags.Language, Title: stream.Tags.Title, } meta.SubtitleStreams = append(meta.SubtitleStreams, ss) } } return meta, nil } func formatBitrate(bitRate string) string { if bitRate == "" { return "" } br, err := strconv.ParseInt(bitRate, 10, 64) if err != nil { return "" } return fmt.Sprintf("%d", br/1000) } func parseFPS(rate string) string { var num, den float64 if _, err := fmt.Sscanf(rate, "%f/%f", &num, &den); err != nil || den == 0 { return "" } fps := num / den if fps == math.Trunc(fps) { return fmt.Sprintf("%.0f", fps) } return fmt.Sprintf("%.2f", fps) } type MediaMetadata struct { Container string Resolution string FileSize int64 VideoStreams []VideoStream AudioStreams []AudioStream SubtitleStreams []SubtitleStream } type VideoStream struct { Codec string Profile string Width int Height int FPS string PixelFormat string Bitrate string } type AudioStream struct { Codec string SampleRate string Channels int ChannelLayout string SampleFormat string Bitrate string Language string } type SubtitleStream struct { Language string Title string Codec string } type ffprobeOutput struct { Format struct { FormatName string `json:"format_name"` } `json:"format"` Streams []ffprobeStream `json:"streams"` } type ffprobeStream struct { CodecName string `json:"codec_name"` CodecType string `json:"codec_type"` Profile string `json:"profile"` Width int `json:"width"` Height int `json:"height"` RFrameRate string `json:"r_frame_rate"` PixFmt string `json:"pix_fmt"` SampleRate string `json:"sample_rate"` Channels int `json:"channels"` ChannelLayout string `json:"channel_layout"` SampleFmt string `json:"sample_fmt"` BitRate string `json:"bit_rate"` Tags struct { Language string `json:"language"` Title string `json:"title"` Mimetype string `json:"mimetype"` Filename string `json:"filename"` } `json:"tags"` }