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