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