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 bounds the ffmpeg storm a freshly loaded library page |
| 17 | // would otherwise spawn, one process per visible item. |
| 18 | const 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. |
| 23 | func (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. |
| 47 | func (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 | |
| 73 | func (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. |
| 88 | func (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. |
| 123 | type thumbAttempt struct { |
| 124 | label string |
| 125 | out string |
| 126 | args []string |
| 127 | } |
| 128 | |
| 129 | func (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. |
| 190 | const thumbnailTimeout = 2 * time.Minute |
| 191 | |
| 192 | // dumpAttachment writes attachment stream idx to a temp file. The caller removes |
| 193 | // that file. |
| 194 | func (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". |
| 216 | func (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 |