feat(stream): cache scan-time thumbnail frames to the .unarr sidecar
Pre-extract the file panel's sample frames (10/30/50/70/90% of runtime, w=320) during the library scan and write-through any on-demand /thumbnail request into the hidden ".unarr/<name>.t<sec>w<width>.jpg" sidecar. The /thumbnail handler serves a fresh sidecar instantly, so the characteristics panel and seekbar previews stop re-running ffmpeg per request. - mediainfo.sidecar: ThumbnailCachePath, ReadCachedThumbnail, WriteCachedThumbnail, ExtractThumbnailJPEG (mirrors engine.buildThumbnailArgs). - library.PrewarmSidecars: also enqueues the panel frame positions (kept in lockstep with the web's THUMB_FRACTIONS / THUMB_WIDTH) per item with a duration. - thumbnailHandler: cache-read → hit; miss → extract → write-through. - config: library.cache_thumbnails (default true) + both cache toggles exposed in the interactive 'unarr config' library menu. Local only by design — frames are the user's own content, never uploaded.
This commit is contained in:
parent
178c16f458
commit
1e5de874cf
6 changed files with 237 additions and 52 deletions
|
|
@ -4,9 +4,11 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -49,6 +51,17 @@ func SubtitleCachePath(mediaPath string, index int) string {
|
|||
return filepath.Join(SidecarDir(mediaPath), fmt.Sprintf("%s.s%d.vtt", filepath.Base(mediaPath), index))
|
||||
}
|
||||
|
||||
// ThumbnailCachePath is the cached JPEG path for a single frame at posSec
|
||||
// (rounded to whole seconds) and the given width. The handler and the scan
|
||||
// prewarm round identically so the same logical frame maps to one cache file.
|
||||
func ThumbnailCachePath(mediaPath string, posSec float64, width int) string {
|
||||
sec := int(math.Round(posSec))
|
||||
if sec < 0 {
|
||||
sec = 0
|
||||
}
|
||||
return filepath.Join(SidecarDir(mediaPath), fmt.Sprintf("%s.t%dw%d.jpg", filepath.Base(mediaPath), sec, width))
|
||||
}
|
||||
|
||||
// sidecarFresh reports whether a cache file exists and is at least as new as the
|
||||
// media file. A re-download/replace bumps the media mtime and invalidates the
|
||||
// stale sidecar so we re-extract.
|
||||
|
|
@ -133,3 +146,52 @@ func ExtractSubtitleVTT(ctx context.Context, ffmpegPath, mediaPath string, index
|
|||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadCachedThumbnail returns the cached JPEG for (mediaPath, posSec, width) when
|
||||
// a fresh sidecar exists. ok=false means extract on demand.
|
||||
func ReadCachedThumbnail(mediaPath string, posSec float64, width int) ([]byte, bool) {
|
||||
p := ThumbnailCachePath(mediaPath, posSec, width)
|
||||
if !sidecarFresh(p, mediaPath) {
|
||||
return nil, false
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
// WriteCachedThumbnail stores an extracted JPEG frame next to the media. Best-effort.
|
||||
func WriteCachedThumbnail(mediaPath string, posSec float64, width int, jpeg []byte) error {
|
||||
return writeSidecar(ThumbnailCachePath(mediaPath, posSec, width), jpeg)
|
||||
}
|
||||
|
||||
// ExtractThumbnailJPEG decodes ONE frame at posSec, scaled to `width`, as JPEG
|
||||
// bytes. Mirrors engine.buildThumbnailArgs so the scan-time prewarm produces
|
||||
// frames byte-identical to the on-demand handler (`-ss` before `-i` = fast
|
||||
// input/keyframe seek). Shared by the prewarm; the handler keeps its own inline
|
||||
// extraction (covered by thumbnail_test.go) and only reuses the cache helpers.
|
||||
func ExtractThumbnailJPEG(ctx context.Context, ffmpegPath, mediaPath string, posSec float64, width int) ([]byte, error) {
|
||||
args := []string{
|
||||
"-nostdin",
|
||||
"-loglevel", "error",
|
||||
"-ss", strconv.FormatFloat(posSec, 'f', 3, 64),
|
||||
"-i", mediaPath,
|
||||
"-frames:v", "1",
|
||||
"-vf", fmt.Sprintf("scale=%d:-2", width),
|
||||
"-an", "-sn",
|
||||
"-f", "mjpeg",
|
||||
"pipe:1",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg thumbnail extract: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("ffmpeg produced no thumbnail (seek past EOF?)")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue