thumbnail.go
⎇
Raw
1package service
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12 "sync/atomic"
13
14 "vidarchive/internal/models"
15 "vidarchive/internal/util"
16)
17
18// maxConcurrentThumbnails caps how many ffmpeg extraction processes may run at
19// once, so a freshly loaded library page (which fires one thumbnail request per
20// visible item) cannot spawn an unbounded ffmpeg storm.
21const maxConcurrentThumbnails = 3
22
23// ThumbnailForFile returns the thumbnail for a specific media file within an
24// item, extracting it on demand if needed. The bool is false when no thumbnail
25// is available (file not found, audio-only, or extraction failed) so the caller
26// can serve an icon. An empty/unmatched filename yields false — every thumbnail
27// is keyed to a specific media file.
28func (s *LibraryService) ThumbnailForFile(relPath, filename string) (string, bool) {
29 if filename == "" {
30 return "", false
31 }
32 item, err := s.GetByRelPath(relPath)
33 if err != nil {
34 return "", false
35 }
36 for i := range item.MediaFiles {
37 if item.MediaFiles[i].Filename != filename {
38 continue
39 }
40 mf := item.MediaFiles[i]
41 if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
42 return path, true
43 }
44 return s.ensureThumbnailForFile(mf)
45 }
46 return "", false
47}
48
49// ensureThumbnailForFile returns an existing thumbnail for mf or extracts one,
50// serializing concurrent extraction of the same file via a per-path mutex. It
51// re-checks the disk under the lock so that whichever caller wins the race does
52// the work and the rest reuse the result. A prior in-process failure short-
53// circuits to avoid re-running ffmpeg on every request (see thumbFailed).
54func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) (string, bool) {
55 actual, _ := s.thumbLocks.LoadOrStore(mf.Filepath, &sync.Mutex{})
56 lock := actual.(*sync.Mutex)
57 lock.Lock()
58 defer lock.Unlock()
59
60 if path, ok := s.findExistingThumbnail(mf.Filepath); ok {
61 return path, true
62 }
63
64 // Trust a prior failure for this run rather than re-running ffmpeg every
65 // request; a restart clears thumbFailed and retries. Checked after the disk
66 // so a thumbnail that appears later (e.g. added manually) still wins.
67 if _, failed := s.thumbFailed.Load(mf.Filepath); failed {
68 return "", false
69 }
70
71 s.thumbSem <- struct{}{}
72 cur := atomic.AddInt32(&s.extractInFlight, 1)
73 for {
74 max := atomic.LoadInt32(&s.extractMaxConcurrent)
75 if cur <= max || atomic.CompareAndSwapInt32(&s.extractMaxConcurrent, max, cur) {
76 break
77 }
78 }
79 defer func() {
80 atomic.AddInt32(&s.extractInFlight, -1)
81 <-s.thumbSem
82 }()
83
84 atomic.AddInt32(&s.extractAttempts, 1)
85 path, err := s.extractThumbnail(mf)
86 if err != nil {
87 log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err)
88 s.thumbFailed.Store(mf.Filepath, struct{}{})
89 return "", false
90 }
91 return path, true
92}
93
94func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
95 ext := filepath.Ext(path)
96 base := strings.TrimSuffix(path, ext) + ".thumbnail"
97 for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} {
98 if info, err := os.Stat(candidate); err == nil && info.Size() > 0 {
99 return candidate, true
100 }
101 }
102 return "", false
103}
104
105// findImageAttachment returns the ordinal (0-based among attachment streams) of
106// the best image attachment in a container — e.g. the cover.jpg/cover.webp that
107// yt-dlp embeds into MKV with --embed-thumbnail — or -1 if there is none. Such
108// covers are attachment streams, not attached_pic video streams, so they must be
109// dumped with -dump_attachment rather than mapped like a normal stream.
110func (s *LibraryService) findImageAttachment(path string) int {
111 cmd := exec.Command(s.ffprobePath, "-v", "error", "-show_streams", "-of", "json", path)
112 output, err := cmd.Output()
113 if err != nil {
114 return -1
115 }
116 var probe struct {
117 Streams []struct {
118 CodecType string `json:"codec_type"`
119 Tags struct {
120 Mimetype string `json:"mimetype"`
121 Filename string `json:"filename"`
122 } `json:"tags"`
123 } `json:"streams"`
124 }
125 if err := json.Unmarshal(output, &probe); err != nil {
126 return -1
127 }
128
129 best, bestScore, attachmentIdx := -1, 0, 0
130 for _, stream := range probe.Streams {
131 if stream.CodecType != "attachment" {
132 continue
133 }
134 idx := attachmentIdx
135 attachmentIdx++
136 if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
137 continue
138 }
139 score := 20
140 switch name := strings.ToLower(stream.Tags.Filename); {
141 case strings.Contains(name, "cover"):
142 score = 100
143 case strings.Contains(name, "thumbnail"), strings.Contains(name, "thumb"):
144 score = 80
145 case strings.Contains(name, "poster"):
146 score = 60
147 case strings.Contains(name, "art"):
148 score = 40
149 }
150 if score > bestScore {
151 best, bestScore = idx, score
152 }
153 }
154 return best
155}
156
157// thumbAttempt is one ffmpeg invocation that may produce a thumbnail at out.
158type thumbAttempt struct {
159 label string
160 out string
161 args []string
162}
163
164func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
165 ext := filepath.Ext(mf.Filepath)
166 base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail"
167 webpPath := base + ".webp"
168 jpgPath := base + ".jpg"
169
170 var attempts []thumbAttempt
171 // Collect every attempt's error so a genuine failure surfaces all of them
172 // rather than only the last fallback's stderr.
173 var attemptErrs []string
174
175 // Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in
176 // MKV): dump its raw bytes, then transcode to a canonical WebP.
177 if idx := s.findImageAttachment(mf.Filepath); idx >= 0 {
178 if rawPath, err := s.dumpAttachment(mf.Filepath, idx); err != nil {
179 attemptErrs = append(attemptErrs, fmt.Sprintf("attachment-dump: %v", err))
180 } else {
181 defer os.Remove(rawPath)
182 attempts = append(attempts, thumbAttempt{"attachment-webp", webpPath,
183 []string{"-i", rawPath, "-c:v", "libwebp"}})
184 }
185 }
186
187 // Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4,
188 // falling back to jpeg if libwebp or webp encoding fails.
189 embedded := []string{"-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1"}
190 attempts = append(attempts,
191 thumbAttempt{"embedded-webp", webpPath, append(embedded, "-c:v", "libwebp")},
192 thumbAttempt{"embedded-jpg", jpgPath, append(embedded, "-q:v", "2")},
193 )
194
195 if !mf.IsAudio {
196 seekTime := "00:00:01"
197 if mf.Duration > 0 {
198 seekTime = util.FormatClock(mf.Duration / 2)
199 }
200 frame := []string{"-ss", seekTime, "-i", mf.Filepath, "-vframes", "1"}
201 attempts = append(attempts,
202 thumbAttempt{"frame-webp", webpPath, append(frame, "-c:v", "libwebp")},
203 thumbAttempt{"frame-jpg", jpgPath, append(frame, "-q:v", "2")},
204 )
205 }
206
207 for _, a := range attempts {
208 path, err := s.tryWriteThumbnail(a.out, a.args)
209 if err == nil {
210 return path, nil
211 }
212 attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", a.label, err))
213 }
214
215 if mf.IsAudio {
216 return "", fmt.Errorf("audio file has no thumbnail")
217 }
218 return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n"))
219}
220
221// dumpAttachment extracts the raw bytes of attachment stream idx (e.g. the
222// cover.jpg/cover.webp yt-dlp embeds into MKV with --embed-thumbnail) into a
223// temp file and returns its path. The caller removes the file.
224func (s *LibraryService) dumpAttachment(path string, idx int) (string, error) {
225 raw, err := os.CreateTemp("", "vidarchive-attachment-*")
226 if err != nil {
227 return "", err
228 }
229 rawPath := raw.Name()
230 raw.Close()
231 dumpArgs := []string{
232 fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath,
233 "-i", path, "-y", "-t", "0", "-f", "null", "-",
234 }
235 if out, err := exec.Command(s.ffmpegPath, dumpArgs...).CombinedOutput(); err != nil {
236 os.Remove(rawPath)
237 return "", fmt.Errorf("%v\n%s", err, out)
238 }
239 return rawPath, nil
240}
241
242// tryWriteThumbnail runs ffmpeg with args to produce outputPath. The temp file
243// keeps the final extension so ffmpeg can infer the output muxer (it cannot for
244// a bare ".tmp" suffix), then is atomically renamed into place.
245func (s *LibraryService) tryWriteThumbnail(outputPath string, args []string) (string, error) {
246 outExt := filepath.Ext(outputPath)
247 tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt
248 os.Remove(tmpPath)
249 cmd := exec.Command(s.ffmpegPath, append(args, tmpPath)...)
250 if output, err := cmd.CombinedOutput(); err != nil {
251 os.Remove(tmpPath)
252 return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output))
253 }
254 if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
255 os.Remove(tmpPath)
256 return "", fmt.Errorf("ffmpeg produced empty output")
257 }
258 if err := os.Rename(tmpPath, outputPath); err != nil {
259 os.Remove(tmpPath)
260 return "", err
261 }
262 return outputPath, nil
263}
264