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