package service import ( "context" "fmt" "log/slog" "os" "path/filepath" "strings" "time" "vidarchive/internal/models" "vidarchive/internal/util" ) // maxConcurrentThumbnails caps how many ffmpeg extraction processes may run at // once, so a freshly loaded library page (which fires one thumbnail request per // visible item) cannot spawn an unbounded ffmpeg storm. const maxConcurrentThumbnails = 3 // ThumbnailForFile returns the thumbnail for a specific media file within an // item, extracting it on demand if needed. The bool is false when no thumbnail // is available (file not found, audio-only, or extraction failed) so the caller // can serve an icon. An empty/unmatched filename yields false — every thumbnail // is keyed to a specific media file. func (s *LibraryService) ThumbnailForFile(relPath, filename string) (string, bool) { if filename == "" { return "", false } item, err := s.GetByRelPath(relPath) if err != nil { return "", false } for i := range item.MediaFiles { if item.MediaFiles[i].Filename != filename { continue } mf := item.MediaFiles[i] if path, ok := s.findExistingThumbnail(mf.Filepath); ok { return path, true } return s.ensureThumbnailForFile(mf) } return "", false } // ensureThumbnailForFile returns an existing thumbnail for mf or extracts one, // serializing concurrent extraction of the same file via a per-path mutex. It // re-checks the disk under the lock so that whichever caller wins the race does // the work and the rest reuse the result. A prior in-process failure short- // circuits to avoid re-running ffmpeg on every request (see thumbFailed). func (s *LibraryService) ensureThumbnailForFile(mf models.MediaFile) (string, bool) { release := s.lockThumbFile(mf.Filepath) defer release() if path, ok := s.findExistingThumbnail(mf.Filepath); ok { return path, true } // Trust a prior failure for this run rather than re-running ffmpeg every // request; a restart clears thumbFailed and retries. Checked after the disk // so a thumbnail that appears later (e.g. added manually) still wins. if _, failed := s.thumbFailed.Get(mf.Filepath); failed { return "", false } s.thumbSem <- struct{}{} defer func() { <-s.thumbSem }() path, err := s.extractThumbnail(mf) if err != nil { slog.Warn("thumbnail extraction failed", "path", mf.Filepath, "err", err) s.thumbFailed.Put(mf.Filepath, true) return "", false } return path, true } func (s *LibraryService) findExistingThumbnail(path string) (string, bool) { ext := filepath.Ext(path) base := strings.TrimSuffix(path, ext) + ".thumbnail" for _, candidate := range []string{base + ".webp", base + ".jpg", base + ".jpeg", base + ".png"} { if info, err := os.Stat(candidate); err == nil && info.Size() > 0 { return candidate, true } } return "", false } // findImageAttachment returns the ordinal (0-based among attachment streams) of // the best image attachment in a container — e.g. the cover.jpg/cover.webp that // yt-dlp embeds into MKV with --embed-thumbnail — or -1 if there is none. Such // covers are attachment streams, not attached_pic video streams, so they must be // dumped with -dump_attachment rather than mapped like a normal stream. func (s *LibraryService) findImageAttachment(path string) int { probe, err := s.probeStreams(path) if err != nil { return -1 } best, bestScore, attachmentIdx := -1, 0, 0 for _, stream := range probe.Streams { if stream.CodecType != "attachment" { continue } idx := attachmentIdx attachmentIdx++ if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") { continue } score := 20 switch name := strings.ToLower(stream.Tags.Filename); { case strings.Contains(name, "cover"): score = 100 case strings.Contains(name, "thumbnail"), strings.Contains(name, "thumb"): score = 80 case strings.Contains(name, "poster"): score = 60 case strings.Contains(name, "art"): score = 40 } if score > bestScore { best, bestScore = idx, score } } return best } // thumbAttempt is one ffmpeg invocation that may produce a thumbnail at out. type thumbAttempt struct { label string out string args []string } func (s *LibraryService) extractThumbnail(mf models.MediaFile) (string, error) { ext := filepath.Ext(mf.Filepath) base := strings.TrimSuffix(mf.Filepath, ext) + ".thumbnail" webpPath := base + ".webp" jpgPath := base + ".jpg" var attempts []thumbAttempt // Collect every attempt's error so a genuine failure surfaces all of them // rather than only the last fallback's stderr. var attemptErrs []string // Prefer an embedded image attachment (e.g. yt-dlp's cover.webp/cover.jpg in // MKV): dump its raw bytes, then transcode to a canonical WebP. if idx := s.findImageAttachment(mf.Filepath); idx >= 0 { if rawPath, err := s.dumpAttachment(mf.Filepath, idx); err != nil { attemptErrs = append(attemptErrs, fmt.Sprintf("attachment-dump: %v", err)) } else { defer os.Remove(rawPath) attempts = append(attempts, thumbAttempt{ "attachment-webp", webpPath, []string{"-i", rawPath, "-c:v", "libwebp"}, }) } } // Next try an embedded cover art video stream (attached_pic), e.g. mp3/mp4, // falling back to jpeg if libwebp or webp encoding fails. embedded := []string{"-i", mf.Filepath, "-map", "0:v", "-map", "-0:V", "-vframes", "1"} attempts = append(attempts, thumbAttempt{"embedded-webp", webpPath, append(embedded, "-c:v", "libwebp")}, thumbAttempt{"embedded-jpg", jpgPath, append(embedded, "-q:v", "2")}, ) if !mf.IsAudio { seekTime := "00:00:01" if mf.Duration > 0 { seekTime = util.FormatClock(mf.Duration / 2) } frame := []string{"-ss", seekTime, "-i", mf.Filepath, "-vframes", "1"} attempts = append(attempts, thumbAttempt{"frame-webp", webpPath, append(frame, "-c:v", "libwebp")}, thumbAttempt{"frame-jpg", jpgPath, append(frame, "-q:v", "2")}, ) } for _, a := range attempts { path, err := s.tryWriteThumbnail(a.out, a.args) if err == nil { return path, nil } attemptErrs = append(attemptErrs, fmt.Sprintf("%s: %v", a.label, err)) } if mf.IsAudio { return "", fmt.Errorf("audio file has no thumbnail") } return "", fmt.Errorf("all thumbnail extraction attempts failed:\n%s", strings.Join(attemptErrs, "\n")) } // Seeking one frame out of a file is fast. A longer run means ffmpeg is stuck, // and a stuck thumbnail must not hold a worker or a request forever. const thumbnailTimeout = 2 * time.Minute // dumpAttachment extracts the raw bytes of attachment stream idx (e.g. the // cover.jpg/cover.webp yt-dlp embeds into MKV with --embed-thumbnail) into a // temp file and returns its path. The caller removes the file. func (s *LibraryService) dumpAttachment(path string, idx int) (string, error) { raw, err := os.CreateTemp("", "vidarchive-attachment-*") if err != nil { return "", err } rawPath := raw.Name() raw.Close() dumpArgs := []string{ fmt.Sprintf("-dump_attachment:t:%d", idx), rawPath, "-i", path, "-y", "-t", "0", "-f", "null", "-", } ctx, cancel := context.WithTimeout(context.Background(), thumbnailTimeout) defer cancel() if out, err := util.KillableCommand(ctx, s.ffmpegPath, dumpArgs...).CombinedOutput(); err != nil { os.Remove(rawPath) return "", fmt.Errorf("%v\n%s", err, out) } return rawPath, nil } // tryWriteThumbnail runs ffmpeg with args to produce outputPath. The temp file // keeps the final extension so ffmpeg can infer the output muxer (it cannot for // a bare ".tmp" suffix), then is atomically renamed into place. func (s *LibraryService) tryWriteThumbnail(outputPath string, args []string) (string, error) { outExt := filepath.Ext(outputPath) tmpPath := strings.TrimSuffix(outputPath, outExt) + ".tmp" + outExt os.Remove(tmpPath) ctx, cancel := context.WithTimeout(context.Background(), thumbnailTimeout) defer cancel() cmd := util.KillableCommand(ctx, s.ffmpegPath, append(args, tmpPath)...) if output, err := cmd.CombinedOutput(); err != nil { os.Remove(tmpPath) return "", fmt.Errorf("ffmpeg failed: %v\n%s", err, string(output)) } if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 { os.Remove(tmpPath) return "", fmt.Errorf("ffmpeg produced empty output") } if err := os.Rename(tmpPath, outputPath); err != nil { os.Remove(tmpPath) return "", err } return outputPath, nil }