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