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