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