thumbnail.go
⎇
Raw
1package service
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "time"
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 defer func() { <-s.thumbSem }()
73
74 atomic.AddInt32(&s.extractAttempts, 1)
75 path, err := s.extractThumbnail(mf)
76 if err != nil {
77 log.Printf("thumbnail extraction failed for %s: %v", mf.Filepath, err)
78 s.thumbFailed.Store(mf.Filepath, struct{}{})
79 return "", false
80 }
81 return path, true
82}
83
84func (s *LibraryService) findExistingThumbnail(path string) (string, bool) {
85 ext := filepath.Ext(path)
86 base := strings.TrimSuffix(path, ext) + ".thumbnail"
87 for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} {
88 if info, err := os.Stat(candidate); err == nil && info.Size() > 0 {
89 return candidate, true
90 }
91 }
92 return "", false
93}
94
95// findImageAttachment returns the ordinal (0-based among attachment streams) of
96// the best image attachment in a container — e.g. the cover.jpg/cover.webp that
97// yt-dlp embeds into MKV with --embed-thumbnail — or -1 if there is none. Such
98// covers are attachment streams, not attached_pic video streams, so they must be
99// dumped with -dump_attachment rather than mapped like a normal stream.
100func (s *LibraryService) findImageAttachment(path string) int {
101 probe, err := s.probeStreams(path)
102 if err != nil {
103 return -1
104 }
105
106 best, bestScore, attachmentIdx := -1, 0, 0
107 for _, stream := range probe.Streams {
108 if stream.CodecType != "attachment" {
109 continue
110 }
111 idx := attachmentIdx
112 attachmentIdx++
113 if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
114 continue
115 }
116 score := 20
117 switch name := strings.ToLower(stream.Tags.Filename); {
118 case strings.Contains(name, "cover"):
119 score = 100
120 case strings.Contains(name, "thumbnail"), strings.Contains(name, "thumb"):
121 score = 80
122 case strings.Contains(name, "poster"):
123 score = 60
124 case strings.Contains(name, "art"):
125 score = 40
126 }
127 if score > bestScore {
128 best, bestScore = idx, score
129 }
130 }
131 return best
132}
133
134// thumbAttempt is one ffmpeg invocation that may produce a thumbnail at out.
135type thumbAttempt struct {
136 label string
137 out string
138 args []string
139}
140
141func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) {
142 ext := filepath.Ext(mf.Filepath)
143 base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail"
144 webpPath := base + ".webp"
145 jpgPath := base + ".jpg"
146
147 var attempts []thumbAttempt
148 // Collect every attempt's error so a genuine failure surfaces all of them
149 // rather than only the last fallback's stderr.
150 var attemptErrs []string
151
152 // Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in
153 // MKV): dump its raw bytes, then transcode to a canonical WebP.
154 if idx := s.findImageAttachment(mf.Filepath); idx >= 0 {
155 if rawPath, err := s.dumpAttachment(mf.Filepath, idx); err != nil {
156 attemptErrs = append(attemptErrs, fmt.Sprintf("attachment-dump: %v", err))
157 } else {
158 defer os.Remove(rawPath)
159 attempts = append(attempts, thumbAttempt{
160 "attachment-webp", webpPath,
161 []string{"-i", rawPath, "-c:v", "libwebp"},
162 })
163 }
164 }
165
166 // Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4,
167 // falling back to jpeg if libwebp or webp encoding fails.
168 embedded := []string{"-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1"}
169 attempts = append(attempts,
170 thumbAttempt{"embedded-webp", webpPath, append(embedded, "-c:v", "libwebp")},
171 thumbAttempt{"embedded-jpg", jpgPath, append(embedded, "-q:v", "2")},
172 )
173
174 if !mf.IsAudio {
175 seekTime := "00:00:01"
176 if mf.Duration > 0 {
177 seekTime = util.FormatClock(mf.Duration / 2)
178 }
179 frame := []string{"-ss", seekTime, "-i", mf.Filepath, "-vframes", "1"}
180 attempts = append(attempts,
181 thumbAttempt{"frame-webp", webpPath, append(frame, "-c:v", "libwebp")},
182 thumbAttempt{"frame-jpg", jpgPath, append(frame, "-q:v", "2")},
183 )
184 }
185
186 for _, a := range attempts {
187 path, err := s.tryWriteThumbnail(a.out, a.args)
188 if err == nil {
189 return path, nil
190 }
191 attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", a.label, err))
192 }
193
194 if mf.IsAudio {
195 return "", fmt.Errorf("audio file has no thumbnail")
196 }
197 return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n"))
198}
199
200// Seeking one frame out of a file is fast. A longer run means ffmpeg is stuck,
201// and a stuck thumbnail must not hold a worker or a request forever.
202const thumbnailTimeout = 2 * time.Minute
203
204// dumpAttachment extracts the raw bytes of attachment stream idx (e.g. the
205// cover.jpg/cover.webp yt-dlp embeds into MKV with --embed-thumbnail) into a
206// temp file and returns its path. The caller removes the file.
207func (s *LibraryService) dumpAttachment(path string, idx int) (string, error) {
208 raw, err := os.CreateTemp("", "vidarchive-attachment-*")
209 if err != nil {
210 return "", err
211 }
212 rawPath := raw.Name()
213 raw.Close()
214 dumpArgs := []string{
215 fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath,
216 "-i", path, "-y", "-t", "0", "-f", "null", "-",
217 }
218 ctx, cancel := context.WithTimeout(context.Background(), thumbnailTimeout)
219 defer cancel()
220 if out, err := util.KillableCommand(ctx, s.ffmpegPath, dumpArgs...).CombinedOutput(); err != nil {
221 os.Remove(rawPath)
222 return "", fmt.Errorf("%v\n%s", err, out)
223 }
224 return rawPath, nil
225}
226
227// tryWriteThumbnail runs ffmpeg with args to produce outputPath. The temp file
228// keeps the final extension so ffmpeg can infer the output muxer (it cannot for
229// a bare ".tmp" suffix), then is atomically renamed into place.
230func (s *LibraryService) tryWriteThumbnail(outputPath string, args []string) (string, error) {
231 outExt := filepath.Ext(outputPath)
232 tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt
233 os.Remove(tmpPath)
234 ctx, cancel := context.WithTimeout(context.Background(), thumbnailTimeout)
235 defer cancel()
236 cmd := util.KillableCommand(ctx, s.ffmpegPath, append(args, tmpPath)...)
237 if output, err := cmd.CombinedOutput(); err != nil {
238 os.Remove(tmpPath)
239 return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output))
240 }
241 if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
242 os.Remove(tmpPath)
243 return "", fmt.Errorf("ffmpeg produced empty output")
244 }
245 if err := os.Rename(tmpPath, outputPath); err != nil {
246 os.Remove(tmpPath)
247 return "", err
248 }
249 return outputPath, nil
250}
251