thumbnail.go
| 1 | package service |
| 2 | |
| 3 | import ( |
| 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. |
| 19 | const 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. |
| 26 | func (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). |
| 52 | func (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 | |
| 79 | func (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. |
| 95 | func (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. |
| 130 | type thumbAttempt struct { |
| 131 | label string |
| 132 | out string |
| 133 | args []string |
| 134 | } |
| 135 | |
| 136 | func (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. |
| 197 | const 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. |
| 202 | func (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. |
| 225 | func (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 |