media_probe.go
⎇
Raw
1package service
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "math"
8 "os"
9 "strconv"
10 "strings"
11 "time"
12
13 "vidarchive/internal/models"
14 "vidarchive/internal/util"
15)
16
17// GetMetadata falls back to the item's primary media file when filename is empty
18// or does not match.
19func (s *LibraryService) GetMetadata(relPath, filename string) (*MediaMetadata, error) {
20 item, err := s.GetByRelPath(relPath)
21 if err != nil {
22 return nil, err
23 }
24
25 var target *models.MediaFile
26 if filename != "" {
27 for i := range item.MediaFiles {
28 if item.MediaFiles[i].Filename == filename {
29 target = &item.MediaFiles[i]
30 break
31 }
32 }
33 }
34 if target == nil {
35 target = primaryMediaFile(item)
36 }
37 if target == nil {
38 return nil, fmt.Errorf("no media file")
39 }
40
41 return s.probeMedia(target.Filepath)
42}
43
44const probeTimeout = 30 * time.Second
45
46// probeStreams is the single ffprobe entry point. A caller that wants one stream
47// kind filters the result itself, rather than re-declaring the same command and
48// JSON shape.
49func (s *LibraryService) probeStreams(path string) (ffprobeOutput, error) {
50 // Reading local headers is quick, so a longer run means ffprobe is stuck on a
51 // truncated or unreadable file.
52 ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
53 defer cancel()
54
55 var probe ffprobeOutput
56 output, err := util.KillableCommand(ctx, s.ffprobePath,
57 "-v", "error",
58 "-show_format",
59 "-show_streams",
60 "-of", "json",
61 path,
62 ).Output()
63 if err != nil {
64 return probe, err
65 }
66 if err := json.Unmarshal(output, &probe); err != nil {
67 return probe, err
68 }
69 return probe, nil
70}
71
72func (s *LibraryService) probeMedia(path string) (*MediaMetadata, error) {
73 info, err := os.Stat(path)
74 if err != nil {
75 return nil, err
76 }
77
78 meta := &MediaMetadata{
79 FileSize: info.Size(),
80 }
81
82 // A file ffprobe can't read still has a size worth showing.
83 probe, err := s.probeStreams(path)
84 if err != nil {
85 return meta, nil
86 }
87
88 if probe.Format.FormatName != "" {
89 parts := strings.Split(probe.Format.FormatName, ",")
90 meta.Container = parts[0]
91 }
92
93 for _, stream := range probe.Streams {
94 switch stream.CodecType {
95 case "video":
96 vs := VideoStream{
97 Codec: stream.CodecName,
98 Profile: stream.Profile,
99 Width: stream.Width,
100 Height: stream.Height,
101 FPS: parseFPS(stream.RFrameRate),
102 PixelFormat: stream.PixFmt,
103 Bitrate: formatBitrate(stream.BitRate),
104 }
105 meta.VideoStreams = append(meta.VideoStreams, vs)
106 if stream.Width > 0 && stream.Height > 0 {
107 meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
108 }
109 case "audio":
110 as := AudioStream{
111 Codec: stream.CodecName,
112 SampleRate: stream.SampleRate,
113 Channels: stream.Channels,
114 ChannelLayout: stream.ChannelLayout,
115 SampleFormat: stream.SampleFmt,
116 Bitrate: formatBitrate(stream.BitRate),
117 Language: stream.Tags.Language,
118 }
119 meta.AudioStreams = append(meta.AudioStreams, as)
120 case "subtitle":
121 ss := SubtitleStream{
122 Codec: stream.CodecName,
123 Language: stream.Tags.Language,
124 Title: stream.Tags.Title,
125 }
126 meta.SubtitleStreams = append(meta.SubtitleStreams, ss)
127 }
128 }
129
130 return meta, nil
131}
132
133func formatBitrate(bitRate string) string {
134 if bitRate == "" {
135 return ""
136 }
137 br, err := strconv.ParseInt(bitRate, 10, 64)
138 if err != nil {
139 return ""
140 }
141 return fmt.Sprintf("%d", br/1000)
142}
143
144func parseFPS(rate string) string {
145 var num, den float64
146 if _, err := fmt.Sscanf(rate, "%f/%f", &num, &den); err != nil || den == 0 {
147 return ""
148 }
149 fps := num / den
150 if fps == math.Trunc(fps) {
151 return fmt.Sprintf("%.0f", fps)
152 }
153 return fmt.Sprintf("%.2f", fps)
154}
155
156type MediaMetadata struct {
157 Container string
158 Resolution string
159 FileSize int64
160 VideoStreams []VideoStream
161 AudioStreams []AudioStream
162 SubtitleStreams []SubtitleStream
163}
164
165type VideoStream struct {
166 Codec string
167 Profile string
168 Width int
169 Height int
170 FPS string
171 PixelFormat string
172 Bitrate string
173}
174
175type AudioStream struct {
176 Codec string
177 SampleRate string
178 Channels int
179 ChannelLayout string
180 SampleFormat string
181 Bitrate string
182 Language string
183}
184
185type SubtitleStream struct {
186 Language string
187 Title string
188 Codec string
189}
190
191type ffprobeOutput struct {
192 Format struct {
193 FormatName string `json:"format_name"`
194 } `json:"format"`
195 Streams []ffprobeStream `json:"streams"`
196}
197
198type ffprobeStream struct {
199 CodecName string `json:"codec_name"`
200 CodecType string `json:"codec_type"`
201 Profile string `json:"profile"`
202 Width int `json:"width"`
203 Height int `json:"height"`
204 RFrameRate string `json:"r_frame_rate"`
205 PixFmt string `json:"pix_fmt"`
206 SampleRate string `json:"sample_rate"`
207 Channels int `json:"channels"`
208 ChannelLayout string `json:"channel_layout"`
209 SampleFmt string `json:"sample_fmt"`
210 BitRate string `json:"bit_rate"`
211 Tags struct {
212 Language string `json:"language"`
213 Title string `json:"title"`
214 Mimetype string `json:"mimetype"`
215 Filename string `json:"filename"`
216 } `json:"tags"`
217}
218