diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e632c6ce..7f2b95f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes to this project will be documented in this file. For commit - Transcoding for media playback with quality presets -- requires ffmpeg. - Hardware Acceleration device selector (Software / detected GPU devices) - More operations use native api operations rather than ffmpeg, improving performance in many cases. Added temporary config option to force ffmpeg to be used + - HLS keyframe indexes for MP4/MOV/M4V/MKV/WebM are read from the container index with go-media instead of demuxing every packet with ffprobe; ffprobe still runs when go-media cannot read the file or reports a keyframe gap that would rule out stream copy. + - HEIC/AVIF rotation (irot) for the ffmpeg decode fallback is read with go-media instead of a full `ffmpeg -f null` decode. + - AVIF images get previews through the HEIC preview path (go-media decode, ffmpeg fallback) when HEIC previews are enabled. + - WMV/ASF video thumbnails use go-media for WMV3; WMV1/WMV2 still use ffmpeg. + - ffmpeg and go-media share one media metadata cache keyed by path, size and modification time, so a probe, duration or subtitle result from either backend is reused by the other. - Document enhancements - Official Collabora support using WOPI config. - Document previews use a pure-go in-process package (all versions get it now). diff --git a/backend/internal/adapters/fs/files/files.go b/backend/internal/adapters/fs/files/files.go index eef714ca5..cb7d7125d 100644 --- a/backend/internal/adapters/fs/files/files.go +++ b/backend/internal/adapters/fs/files/files.go @@ -17,6 +17,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/internal/errors" "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" "github.com/gtsteffaniak/filebrowser/backend/internal/gomedia" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing" "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo" @@ -393,7 +394,8 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u info.GetSubtitles(parentInfo) } subtitles, ok := gomedia.EmbeddedSubtitles(info.RealPath, info.ModTime, func() ([]utils.SubtitleTrack, bool) { - return ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime), true + // Shadow run: bypass the shared cache so ffprobe is compared fresh. + return ffmpeg.DetectEmbeddedSubtitlesContext(mediacache.WithoutCache(context.Background()), info.RealPath), true }) if !ok { subtitles = ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime) @@ -529,8 +531,9 @@ func audioDuration(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPat if service == nil { service = ffmpeg.Get() } - // The shadow run can outlive the request, so it uses a context that is not cancelled with it. - shadowCtx := context.WithoutCancel(ctx) + // The shadow run can outlive the request, so it uses a context that is not + // cancelled with it. It bypasses the shared cache so ffmpeg is compared fresh. + shadowCtx := mediacache.WithoutCache(context.WithoutCancel(ctx)) legacy := func() (float64, bool) { d, err := service.GetMediaDuration(shadowCtx, realPath) return d, err == nil @@ -701,8 +704,9 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, if probeService == nil { probeService = ffmpeg.Get() } - // The shadow run can outlive the request, so it uses a context that is not cancelled with it. - shadowCtx := context.WithoutCancel(ctx) + // The shadow run can outlive the request, so it uses a context that is not + // cancelled with it. It bypasses the shared cache so ffprobe is compared fresh. + shadowCtx := mediacache.WithoutCache(context.WithoutCancel(ctx)) legacyProbe := func() (gomedia.ProbeResult, bool) { info, err := probeService.ProbeFile(shadowCtx, realPath) if err != nil || info == nil { diff --git a/backend/internal/ffmpeg/cache.go b/backend/internal/ffmpeg/cache.go deleted file mode 100644 index 63c5f59f7..000000000 --- a/backend/internal/ffmpeg/cache.go +++ /dev/null @@ -1,14 +0,0 @@ -package ffmpeg - -import ( - "time" - - "github.com/gtsteffaniak/filebrowser/backend/internal/utils" - "github.com/gtsteffaniak/go-cache/cache" -) - -var ( - MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitle track lists from ffprobe - SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // extracted embedded subtitle content - MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration from ffprobe -) diff --git a/backend/internal/ffmpeg/heic_orientation.go b/backend/internal/ffmpeg/heic_orientation.go index 8b67c9a1f..6c5a04921 100644 --- a/backend/internal/ffmpeg/heic_orientation.go +++ b/backend/internal/ffmpeg/heic_orientation.go @@ -9,13 +9,51 @@ import ( "os/exec" "strconv" "strings" + "sync/atomic" "github.com/kovidgoyal/imaging" ) +// HEICDisplayRotationReader reads the display rotation that a HEIC/HEIF/AVIF +// file's item properties (irot) ask for, without decoding it. The rotation is +// in degrees counter-clockwise, normalized to (-180, 180], which is the value +// ffmpeg prints as "Display Matrix: rotation of ..." for the same file. ok is +// false when the reader could not tell; the ffmpeg probe is used instead. +type HEICDisplayRotationReader func(path string) (rotation float64, ok bool) + +// heicDisplayRotationReader is the header reader set by +// SetHEICDisplayRotationReader, or nil. +var heicDisplayRotationReader atomic.Pointer[HEICDisplayRotationReader] + +// SetHEICDisplayRotationReader installs the reader HEICDisplayRotation tries +// before running ffmpeg. The gomedia package installs go-media's reader when it +// is loaded; it cannot be called from here directly because gomedia imports +// this package. Passing nil removes the reader. +func SetHEICDisplayRotationReader(fn HEICDisplayRotationReader) { + if fn == nil { + heicDisplayRotationReader.Store(nil) + return + } + heicDisplayRotationReader.Store(&fn) +} + +// HEICDisplayRotation returns the display rotation of heicPath and whether it is +// known. The installed header reader (go-media) answers first; the +// "ffmpeg -f null" stderr probe runs only when no reader is installed or the +// reader could not read the file. +func (s *Service) HEICDisplayRotation(ctx context.Context, heicPath string) (float64, bool) { + if p := heicDisplayRotationReader.Load(); p != nil { + if rotation, ok := (*p)(heicPath); ok { + return rotation, true + } + } + return s.GetHEICDisplayMatrixRotation(ctx, heicPath) +} + // GetHEICDisplayMatrixRotation returns the largest rotation (degrees) ffmpeg reports for a // HEIC display matrix. The second return value is false when the probe could not run or // parse stderr — callers must not treat that as “no rotation was applied”. +// HEICDisplayRotation is preferred; it only runs this probe as a fallback. func (s *Service) GetHEICDisplayMatrixRotation(ctx context.Context, heicPath string) (float64, bool) { if s == nil { return 0, false @@ -36,6 +74,8 @@ func (s *Service) GetHEICDisplayMatrixRotation(ctx context.Context, heicPath str return rotation, ok } +// parseDisplayMatrixRotation returns the largest-magnitude "Display Matrix" +// rotation in ffmpeg stderr, and whether any was found. func parseDisplayMatrixRotation(stderr string) (float64, bool) { var best float64 var found bool @@ -65,6 +105,7 @@ func parseDisplayMatrixRotation(stderr string) (float64, bool) { return best, found } +// isNormalOrientation reports whether an EXIF orientation needs no change. func isNormalOrientation(orientation string) bool { switch orientation { case "", "Horizontal (normal)", "Top-left", "1": @@ -74,6 +115,8 @@ func isNormalOrientation(orientation string) bool { } } +// isPureRotationOrientation reports whether an EXIF orientation is a rotation +// without a mirror. func isPureRotationOrientation(orientation string) bool { switch orientation { case "Rotate 90 CW", "Right-top", "6", @@ -100,6 +143,8 @@ func orientationNeedsPostCorrection(orientation string, displayRotation float64, return true } +// applyOrientationToJPEG rotates or flips JPEG bytes for an EXIF orientation. +// The input is returned unchanged when it cannot be decoded or re-encoded. func applyOrientationToJPEG(jpegBytes []byte, orientation string) []byte { if len(jpegBytes) < 100 || isNormalOrientation(orientation) { return jpegBytes diff --git a/backend/internal/ffmpeg/heic_orientation_test.go b/backend/internal/ffmpeg/heic_orientation_test.go index 4709b1204..f89277fe6 100644 --- a/backend/internal/ffmpeg/heic_orientation_test.go +++ b/backend/internal/ffmpeg/heic_orientation_test.go @@ -50,3 +50,35 @@ func TestOrientationNeedsPostCorrection(t *testing.T) { } } } + +func TestHEICDisplayRotationPrefersReader(t *testing.T) { + prev := heicDisplayRotationReader.Load() + t.Cleanup(func() { heicDisplayRotationReader.Store(prev) }) + + var calls int + SetHEICDisplayRotationReader(func(path string) (float64, bool) { + calls++ + if path == "known.heic" { + return -90, true + } + return 0, false + }) + var svc *Service // nil: the ffmpeg probe cannot run + got, ok := svc.HEICDisplayRotation(t.Context(), "known.heic") + if !ok || got != -90 { + t.Fatalf("HEICDisplayRotation(known) = %v, %v; want -90, true", got, ok) + } + // The reader cannot tell: the ffmpeg probe runs, and with no service it + // reports unknown rather than "no rotation". + if _, ok := svc.HEICDisplayRotation(t.Context(), "other.heic"); ok { + t.Fatal("HEICDisplayRotation(other) ok = true, want the ffmpeg probe's false") + } + if calls != 2 { + t.Fatalf("reader calls = %d, want 2", calls) + } + + SetHEICDisplayRotationReader(nil) + if _, ok := svc.HEICDisplayRotation(t.Context(), "known.heic"); ok { + t.Fatal("with no reader, HEICDisplayRotation must use the ffmpeg probe") + } +} diff --git a/backend/internal/ffmpeg/image.go b/backend/internal/ffmpeg/image.go index 02fca64b0..e7892553a 100644 --- a/backend/internal/ffmpeg/image.go +++ b/backend/internal/ffmpeg/image.go @@ -81,14 +81,16 @@ func (s *Service) ConvertHEICToJPEG(ctx context.Context, heicPath string, target return s.CorrectHEICJPEGOrientation(ctx, heicPath, jpegBytes), nil } -// CorrectHEICJPEGOrientation applies irot / display-matrix correction after a full-resolution -// HEIC decode when pixels are still in coded orientation (go-media and ffmpeg filter-free paths). +// CorrectHEICJPEGOrientation applies EXIF orientation after a filter-free ffmpeg HEIC decode +// when the pixels are still in coded orientation. The display rotation (irot) comes from +// HEICDisplayRotation: go-media reads it from the item properties, and the ffmpeg stderr probe +// is only a fallback. func (s *Service) CorrectHEICJPEGOrientation(ctx context.Context, heicPath string, jpegBytes []byte) []byte { orientation, err := s.GetHEICOrientation(heicPath) if err != nil { orientation = "Horizontal (normal)" } - displayRotation, displayKnown := s.GetHEICDisplayMatrixRotation(ctx, heicPath) + displayRotation, displayKnown := s.HEICDisplayRotation(ctx, heicPath) if orientationNeedsPostCorrection(orientation, displayRotation, displayKnown) { return applyOrientationToJPEG(jpegBytes, orientation) } diff --git a/backend/internal/ffmpeg/mediacache_integration_test.go b/backend/internal/ffmpeg/mediacache_integration_test.go new file mode 100644 index 000000000..e2b83d0fc --- /dev/null +++ b/backend/internal/ffmpeg/mediacache_integration_test.go @@ -0,0 +1,115 @@ +package ffmpeg + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" +) + +const cacheFixtureMP4 = "../../../frontend/tests/playwright-files/myfolder/testdata/sample-640x480.mp4" + +var ( + testServiceOnce sync.Once + testServiceErr error +) + +// initTestService starts the ffmpeg service once, without hardware probes, or +// skips. +func initTestService(t *testing.T) *Service { + t.Helper() + if testing.Short() { + t.Skip("starts ffmpeg") + } + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not installed") + } + if _, err := exec.LookPath("ffprobe"); err != nil { + t.Skip("ffprobe not installed") + } + testServiceOnce.Do(func() { + testServiceErr = Initialize(context.Background(), InitOptions{SkipHWTests: true, CacheDir: os.TempDir()}) + }) + if testServiceErr != nil { + t.Skipf("ffmpeg init failed: %v", testServiceErr) + } + svc := Get() + if svc == nil { + t.Skip("ffmpeg service unavailable") + } + return svc +} + +// copyFixture copies the MP4 fixture so the test owns its cache key. +func copyFixture(t *testing.T) (string, mediacache.Key) { + t.Helper() + data, err := os.ReadFile(cacheFixtureMP4) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "sample.mp4") + if err = os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + key, err := mediacache.Stat(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + mediacache.Probes.Delete(key.String()) + mediacache.Durations.Delete(key.String()) + }) + return path, key +} + +// TestProbeFileUsesSharedCache checks that ffprobe results go into the shared +// cache, cached values are reused, and WithoutCache bypasses both. +func TestProbeFileUsesSharedCache(t *testing.T) { + svc := initTestService(t) + path, key := copyFixture(t) + + got, err := svc.ProbeFile(context.Background(), path) + if err != nil { + t.Fatal(err) + } + cached, ok := mediacache.Probes.Get(key.String()) + if !ok || cached != *got { + t.Fatalf("ProbeFile must store its result in the shared cache: cached=%+v ok=%v got=%+v", cached, ok, *got) + } + + sentinel := FileProbeInfo{Duration: 1, VideoCodec: "sentinel"} + mediacache.Probes.Set(key.String(), sentinel) + got, err = svc.ProbeFile(context.Background(), path) + if err != nil || *got != sentinel { + t.Fatalf("ProbeFile must reuse a cached value (e.g. from go-media): got=%+v err=%v", got, err) + } + + fresh, err := svc.ProbeFile(mediacache.WithoutCache(context.Background()), path) + if err != nil || *fresh == sentinel { + t.Fatalf("WithoutCache must probe fresh: got=%+v err=%v", fresh, err) + } + if cached, _ := mediacache.Probes.Get(key.String()); cached != sentinel { + t.Fatal("WithoutCache must not overwrite the shared cache") + } +} + +// TestGetMediaDurationUsesSharedCache checks that cached durations are reused +// and WithoutCache probes fresh. +func TestGetMediaDurationUsesSharedCache(t *testing.T) { + svc := initTestService(t) + path, key := copyFixture(t) + + mediacache.Durations.Set(key.String(), 4242) + d, err := svc.GetMediaDuration(context.Background(), path) + if err != nil || d != 4242 { + t.Fatalf("GetMediaDuration must reuse a cached value: d=%v err=%v", d, err) + } + d, err = svc.GetMediaDuration(mediacache.WithoutCache(context.Background()), path) + if err != nil || d == 4242 || d <= 0 { + t.Fatalf("WithoutCache must probe fresh: d=%v err=%v", d, err) + } +} diff --git a/backend/internal/ffmpeg/metadata.go b/backend/internal/ffmpeg/metadata.go index aec381eac..faa1b53ab 100644 --- a/backend/internal/ffmpeg/metadata.go +++ b/backend/internal/ffmpeg/metadata.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" goffmpeg "github.com/gtsteffaniak/go-ffmpeg" ) @@ -49,24 +50,29 @@ func resolveLocalMediaPath(mediaPath string) (string, os.FileInfo, error) { return clean, info, nil } -// FileProbeInfo holds ffprobe results for a local media file. -type FileProbeInfo struct { - Duration float64 - VideoCodec string - AudioCodec string - FormatName string -} +// FileProbeInfo holds probe results for a local media file. It is the shared +// cache type, so go-media and ffprobe results are interchangeable. +type FileProbeInfo = mediacache.ProbeInfo -// ProbeFile extracts duration and codec info from a media file. +// ProbeFile extracts duration and codec info from a media file. Results are +// stored in the shared media cache, which the go-media probe also reads, unless +// ctx is mediacache.WithoutCache. func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeInfo, error) { if s == nil || s.inner == nil { return nil, fmt.Errorf("ffmpeg service not available") } - localPath, _, err := resolveLocalMediaPath(mediaPath) + localPath, fileInfo, err := resolveLocalMediaPath(mediaPath) if err != nil { return nil, err } + useCache := !mediacache.Bypassed(ctx) + cacheKey := mediacache.FromFileInfo(localPath, fileInfo).String() + if useCache { + if cached, ok := mediacache.Probes.Get(cacheKey); ok { + return &cached, nil + } + } info, err := s.inner.ProbeStream(ctx, goffmpeg.ProbeStreamOptions{ URL: localPath, @@ -77,15 +83,20 @@ func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeIn return nil, err } - return &FileProbeInfo{ + res := FileProbeInfo{ Duration: info.Duration, VideoCodec: info.VideoCodec, AudioCodec: info.AudioCodec, FormatName: info.FormatName, - }, nil + } + if useCache { + mediacache.Probes.Set(cacheKey, res) + } + return &res, nil } -// GetMediaDuration extracts the duration of a media file in seconds. +// GetMediaDuration extracts the duration of a media file in seconds. Results +// are stored in the shared media cache unless ctx is mediacache.WithoutCache. func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float64, error) { if s == nil || s.inner == nil { return 0, fmt.Errorf("ffmpeg service not available") @@ -95,9 +106,12 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float if err != nil { return 0, err } - cacheKey := fmt.Sprintf("%s:%d", localPath, fileInfo.ModTime().Unix()) - if duration, found := MetadataCache.Get(cacheKey); found { - return duration, nil + useCache := !mediacache.Bypassed(ctx) + cacheKey := mediacache.FromFileInfo(localPath, fileInfo).String() + if useCache { + if duration, found := mediacache.Durations.Get(cacheKey); found { + return duration, nil + } } duration, err := s.inner.GetMediaDuration(ctx, localPath) @@ -105,6 +119,8 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float return 0, err } - MetadataCache.Set(cacheKey, duration) + if useCache { + mediacache.Durations.Set(cacheKey, duration) + } return duration, nil } diff --git a/backend/internal/ffmpeg/service.go b/backend/internal/ffmpeg/service.go index 129052cd9..2c19c563d 100644 --- a/backend/internal/ffmpeg/service.go +++ b/backend/internal/ffmpeg/service.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" goffmpeg "github.com/gtsteffaniak/go-ffmpeg" "github.com/gtsteffaniak/go-ffmpeg/capabilities" "github.com/gtsteffaniak/go-ffmpeg/ops" @@ -460,7 +461,8 @@ func (s *Service) VideoPreview(ctx context.Context, w io.Writer, opts ops.Previe return s.inner.CaptureFrame(ctx, w, frame) } -// ExtractSubtitle returns embedded subtitle content as WebVTT. +// ExtractSubtitle returns embedded subtitle content as WebVTT. Results are +// stored in the shared media cache unless ctx is mediacache.WithoutCache. func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamIndex int) (string, error) { if s == nil || s.inner == nil { return "", fmt.Errorf("ffmpeg service not available") @@ -470,9 +472,12 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI if err != nil { return "", fmt.Errorf("failed to stat file: %w", err) } - cacheKey := fmt.Sprintf("subtitle_content:%s:%d:%d", videoPath, streamIndex, fileInfo.ModTime().Unix()) - if content, ok := SubtitleContentCache.Get(cacheKey); ok { - return content, nil + useCache := !mediacache.Bypassed(ctx) + cacheKey := mediacache.FromFileInfo(videoPath, fileInfo).Stream(streamIndex) + if useCache { + if content, ok := mediacache.SubtitleContent.Get(cacheKey); ok { + return content, nil + } } content, err := s.inner.ExtractSubtitle(ctx, videoPath, streamIndex) @@ -480,7 +485,9 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI return "", err } - SubtitleContentCache.Set(cacheKey, content) + if useCache { + mediacache.SubtitleContent.Set(cacheKey, content) + } return content, nil } diff --git a/backend/internal/ffmpeg/subtitles.go b/backend/internal/ffmpeg/subtitles.go index f7a2037fb..ea953679d 100644 --- a/backend/internal/ffmpeg/subtitles.go +++ b/backend/internal/ffmpeg/subtitles.go @@ -7,16 +7,30 @@ import ( "strings" "time" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" "github.com/gtsteffaniak/filebrowser/backend/internal/utils" goffmpeg "github.com/gtsteffaniak/go-ffmpeg" "github.com/gtsteffaniak/go-logger/logger" ) -// DetectEmbeddedSubtitles detects embedded subtitle streams. +// DetectEmbeddedSubtitles detects embedded subtitle streams. Results are stored +// in the shared media cache, keyed by the file's current size and modification +// time; modtime is kept for API compatibility and is not used for the key. func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack { - key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339) - if cached, ok := MediaCache.Get(key); ok { - return cached + _ = modtime + return DetectEmbeddedSubtitlesContext(context.Background(), videoPath) +} + +// DetectEmbeddedSubtitlesContext is DetectEmbeddedSubtitles with a context. The +// shared media cache is skipped when ctx is mediacache.WithoutCache. +func DetectEmbeddedSubtitlesContext(ctx context.Context, videoPath string) []utils.SubtitleTrack { + useCache := !mediacache.Bypassed(ctx) + key, statErr := mediacache.Stat(videoPath) + useCache = useCache && statErr == nil + if useCache { + if cached, ok := mediacache.SubtitleTracks.Get(key.String()); ok { + return cached + } } svc := Get() @@ -24,14 +38,16 @@ func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.Subtit return nil } - tracks, err := svc.inner.DetectSubtitles(context.Background(), videoPath) + tracks, err := svc.inner.DetectSubtitles(ctx, videoPath) if err != nil { logger.Debug("ffprobe failed for file: " + videoPath + ", error: " + err.Error()) return nil } subtitles := mapSubtitleTracks(tracks) - MediaCache.Set(key, subtitles) + if useCache { + mediacache.SubtitleTracks.Set(key.String(), subtitles) + } return subtitles } diff --git a/backend/internal/gomedia/containers_test.go b/backend/internal/gomedia/containers_test.go new file mode 100644 index 000000000..a1a5c60b2 --- /dev/null +++ b/backend/internal/gomedia/containers_test.go @@ -0,0 +1,73 @@ +package gomedia + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +const fixtureWMV2 = fixtureDir + "/file_example_WMV_480_1_2MB.wmv" + +// TestContainerGates pins which extensions go-media handles for probes and +// for thumbnails. +func TestContainerGates(t *testing.T) { + cases := []struct { + name string + probe, thumbnail bool + }{ + {"a.mp4", true, true}, + {"a.M4V", true, true}, + {"a.mov", true, true}, + {"a.mkv", true, true}, + {"a.webm", true, true}, + {"a.wmv", false, true}, + {"a.ASF", false, true}, + {"a.avi", false, false}, + {"a.ts", false, false}, + {"a.m2ts", false, false}, + {"a.mts", false, false}, + {"a.mpg", false, false}, + {"a.mpeg", false, false}, + {"a.vob", false, false}, + {"a.flv", false, false}, + } + for _, c := range cases { + require.Equal(t, c.probe, supportsVideoContainer(c.name), "probe gate for %s", c.name) + require.Equal(t, c.thumbnail, supportsVideoThumbnail(c.name), "thumbnail gate for %s", c.name) + } + for _, name := range []string{"a.heic", "a.HEIF", "a.heics", "a.avif"} { + require.True(t, supportsHEIFImage(name), name) + } + require.False(t, supportsHEIFImage("a.jpg")) +} + +// TestWMV2FallsBackToFFmpeg checks that a WMV2 file is not probed by go-media +// and that its thumbnail falls back to ffmpeg (go-media has no WMV1/WMV2 +// decoder). +func TestWMV2FallsBackToFFmpeg(t *testing.T) { + withGate(t, false) + _, ok := Probe(fixtureWMV2, nil) + require.False(t, ok, "WMV probes stay on ffprobe") + _, ok = VideoThumbnail(fixtureWMV2, 320, 240, 80, 10, nil) + require.False(t, ok, "WMV2 thumbnails must fall back to ffmpeg") +} + +// TestWMV1FallsBackToFFmpeg is TestWMV2FallsBackToFFmpeg for a WMV1 stream +// encoded on the fly. +func TestWMV1FallsBackToFFmpeg(t *testing.T) { + withGate(t, false) + requireTool(t, "ffmpeg") + path := filepath.Join(t.TempDir(), "wmv1.wmv") + out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=2", + "-c:v", "wmv1", path).CombinedOutput() + if err != nil { + t.Skipf("ffmpeg cannot encode wmv1: %v: %s", err, out) + } + _, err = os.Stat(path) + require.NoError(t, err) + _, ok := VideoThumbnail(path, 160, 120, 80, 10, nil) + require.False(t, ok, "WMV1 thumbnails must fall back to ffmpeg") +} diff --git a/backend/internal/gomedia/gomedia.go b/backend/internal/gomedia/gomedia.go index c4b0c2657..c1a048e1e 100644 --- a/backend/internal/gomedia/gomedia.go +++ b/backend/internal/gomedia/gomedia.go @@ -20,6 +20,7 @@ import ( "time" "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" "github.com/gtsteffaniak/go-cache/cache" @@ -28,25 +29,27 @@ import ( "golang.org/x/sync/singleflight" ) -var ( - subtitleListCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) - subtitleContentCache = cache.NewCache[string](24 * time.Hour) +// Probe, duration, and subtitle results are cached in the mediacache package, +// which the ffmpeg code path shares. A directory listing probes every video +// child, and the preview flow probes the focused file again; the cache keeps +// each request from re-reading the same containers. Keys include size and +// modification time, so an edited file is read again. - // probeCache caches successful probe results by file identity (path, modtime, - // size). A directory listing probes every video child, and the preview flow - // probes the focused file again; without this each request re-read the same - // containers. Keying on modtime+size means an edited file is re-probed. - probeCache = cache.NewCache[ProbeResult](24 * time.Hour) +// probeGroup coalesces concurrent probes of the same file so a burst of +// requests for one container reads it once. +var probeGroup singleflight.Group - // tagsCache caches the embedded part of a tag read by file identity, like probeCache. - // A listing reads tags for every media child on each uncached request. Sidecar lyrics - // are not cached: adding an .lrc next to a file does not change its modtime. - tagsCache = cache.NewCache[AudioInfo](24 * time.Hour) +// tagsCache caches the embedded part of a tag read by file identity. A listing +// reads tags for every media child on each uncached request. Sidecar lyrics +// are not cached: adding an .lrc next to a file does not change its modtime. +var tagsCache = cache.NewCache[AudioInfo](24 * time.Hour) - // probeGroup coalesces concurrent probes of the same file so a burst of - // requests for one container reads it once. - probeGroup singleflight.Group -) +func init() { + // The ffmpeg HEIC decode path corrects orientation after decoding and needs + // the file's irot. internal/ffmpeg cannot import this package, so go-media's + // header reader is installed here. + ffmpeg.SetHEICDisplayRotationReader(HEICDisplayRotation) +} // Enabled reports whether the go-media fast path may run. The admin // "Always use ffmpeg" setting turns it off. @@ -65,9 +68,15 @@ func videoDimensions(info *media.ProbeInfo) (w, h int, ok bool) { } // videoContainerExts are the containers go-media parses for video probes, -// thumbnails, and embedded subtitles. Other video containers (AVI, WMV, TS, ...) -// are not go-media targets, so they go straight to ffmpeg with no file open and -// no log line. go-media reads MOV as MP4. +// thumbnails, embedded subtitles, and keyframe indexes. go-media reads MOV as +// MP4. +// +// go-media also demuxes AVI, ASF/WMV, MPEG-TS/M2TS, and MPEG-PS (VOB), but their +// probes are left to ffmpeg on purpose: go-media's results for those containers +// do not yet match ffprobe (TS/M2TS and PS list no audio streams, PS durations +// are missing or wrong, AVI misnames MPEG-4 Part 2 and AAC, ASF reports WMA Pro +// as wmav2). Those files go straight to ffmpeg with no file open and no log +// line. See videoThumbnailExts for the thumbnail exception. var videoContainerExts = map[string]bool{ ".mp4": true, ".m4v": true, @@ -76,11 +85,31 @@ var videoContainerExts = map[string]bool{ ".webm": true, } +// videoThumbnailExts are the containers go-media decodes video thumbnails from: +// videoContainerExts plus ASF/WMV, whose WMV3 keyframes go-media decodes +// bit-exact with ffmpeg. WMV1, WMV2, MS-MPEG4, and WVC1 Advanced Profile return +// an unsupported error and fall back to ffmpeg. AVI, TS/M2TS, and PS stay on +// ffmpeg: go-media's TS and PS thumbnails scan the whole file for the duration +// (several times slower than ffmpeg on large files), and AVI seeks by a +// duration that can be wrong. +var videoThumbnailExts = map[string]bool{ + ".mp4": true, + ".m4v": true, + ".mov": true, + ".mkv": true, + ".webm": true, + ".wmv": true, + ".asf": true, +} + // heifImageExts are still-image containers go-media decodes for HEIC preview. +// AVIF is a HEIF container with an AV1 image item; go-media decodes it in pure +// Go and applies its irot/imir, like HEIC. var heifImageExts = map[string]bool{ ".heic": true, ".heif": true, ".heics": true, + ".avif": true, } const heicDecodeMaxEdge = 16384 @@ -98,15 +127,27 @@ var cameraRawExts = map[string]bool{ ".dc2": true, ".x3f": true, ".iiq": true, ".nkc": true, ".r3d": true, } -// supportsVideoContainer reports whether go-media targets path's container. +// supportsVideoContainer reports whether go-media targets path's container for +// probes, embedded subtitles, and keyframe indexes. func supportsVideoContainer(path string) bool { return videoContainerExts[strings.ToLower(filepath.Ext(path))] } +// supportsVideoThumbnail reports whether go-media targets path's container for +// video thumbnails. +func supportsVideoThumbnail(path string) bool { + return videoThumbnailExts[strings.ToLower(filepath.Ext(path))] +} + +// supportsHEIFImage reports whether path is a HEIF-family still image +// (HEIC/HEIF/AVIF) go-media decodes. + func supportsHEIFImage(path string) bool { return heifImageExts[strings.ToLower(filepath.Ext(path))] } +// supportsCameraRAW reports whether path is a camera RAW format go-media +// extracts a preview from. func supportsCameraRAW(path string) bool { return cameraRawExts[strings.ToLower(filepath.Ext(path))] } @@ -145,8 +186,8 @@ func openFile(path string) (*os.File, int64, error) { return f, info.Size(), nil } -// openFileInfo is openFile for callers that also need the modtime (cache keys), -// so they do not stat the file a second time. +// openFileInfo opens path and returns its FileInfo, which also gives the +// shared cache key. func openFileInfo(path string) (*os.File, os.FileInfo, error) { f, err := os.Open(path) if err != nil { @@ -201,19 +242,18 @@ func probeFast(path string) (ProbeResult, bool) { } defer f.Close() - size := info.Size() - cacheKey := probeCacheKey(path, info.ModTime().UnixNano(), size) - if res, ok := probeCache.Get(cacheKey); ok { + cacheKey := mediacache.FromFileInfo(path, info).String() + if res, ok := mediacache.Probes.Get(cacheKey); ok { return res, true } // Coalesce concurrent probes of the same file into one container read. v, err, _ := probeGroup.Do(cacheKey, func() (any, error) { - res, ok := parseProbe(path, f, size) + res, ok := parseProbe(path, f, info.Size()) if !ok { return nil, errProbeUnsupported } - probeCache.Set(cacheKey, res) + mediacache.Probes.Set(cacheKey, res) return res, nil }) if err != nil { @@ -230,12 +270,6 @@ func probeFast(path string) (ProbeResult, bool) { // no detail because parseProbe already logged the specific reason. var errProbeUnsupported = errors.New("probe unsupported") -// probeCacheKey identifies a file version for caching: path plus modtime and size. -// An edited file gets a new key, so stale metadata is never served. -func probeCacheKey(path string, modTimeNanos, size int64) string { - return fmt.Sprintf("probe:%s:%d:%d", path, modTimeNanos, size) -} - // parseProbe reads a container and builds a ProbeResult. It reports false when // go-media should defer to ffmpeg, having logged why. func parseProbe(path string, f *os.File, size int64) (ProbeResult, bool) { @@ -291,14 +325,18 @@ func durationFast(path string) (float64, bool) { if !Enabled() { return 0, false } - f, size, err := openFile(path) + f, info, err := openFileInfo(path) if err != nil { logFallback("duration", path, err) return 0, false } defer f.Close() - res, err := media.Duration(f, size) + cacheKey := mediacache.FromFileInfo(path, info).String() + if d, ok := mediacache.Durations.Get(cacheKey); ok { + return d, true + } + res, err := media.Duration(f, info.Size()) if err != nil { logFallback("duration", path, err) return 0, false @@ -307,7 +345,9 @@ func durationFast(path string) (float64, bool) { // Unknown duration is not an error; ffmpeg may still find one. return 0, false } - return res.Duration.Seconds(), true + d := res.Duration.Seconds() + mediacache.Durations.Set(cacheKey, d) + return d, true } // AudioInfo is the tag, lyrics availability, and cover art FileBrowser reads @@ -417,7 +457,9 @@ func lyricsFast(path string) (text, format string, ok bool) { // EmbeddedSubtitles lists embedded subtitle tracks. go-media supports MKV, WebM, // and MP4 only. Other containers are reported as unsupported, not as "no -// subtitles", because go-media returns an empty list for them. +// subtitles", because go-media returns an empty list for them. modtime is kept +// for API compatibility; the cache key comes from the file's current size and +// modification time. // legacy is the ffmpeg subtitle detection, run only in shadow mode. func EmbeddedSubtitles(path string, modtime time.Time, legacy Legacy[[]utils.SubtitleTrack]) ([]utils.SubtitleTrack, bool) { return timed("subtitle list", path, legacy, subtitleListDiff, func() ([]utils.SubtitleTrack, bool) { @@ -432,19 +474,20 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac if !supportsVideoContainer(path) { return nil, false } - key := fmt.Sprintf("embedded_subtitles:%s:%d", path, modtime.Unix()) - if cached, ok := subtitleListCache.Get(key); ok { - return cached, true - } - - f, size, err := openFile(path) + _ = modtime + f, st, err := openFileInfo(path) if err != nil { logFallback("subtitle list", path, err) return nil, false } defer f.Close() - info, err := media.Probe(f, size) + key := mediacache.FromFileInfo(path, st).String() + if cached, ok := mediacache.SubtitleTracks.Get(key); ok { + return cached, true + } + + info, err := media.Probe(f, st.Size()) if err != nil { logFallback("subtitle list", path, err) return nil, false @@ -466,7 +509,7 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac }) } tracks := ffmpeg.BuildSubtitleTracks(streams) - subtitleListCache.Set(key, tracks) + mediacache.SubtitleTracks.Set(key, tracks) return tracks, true } @@ -486,24 +529,19 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) { if !supportsVideoContainer(path) { return "", false } - st, err := os.Stat(path) - if err != nil { - logFallback("subtitle extract", path, err) - return "", false - } - key := fmt.Sprintf("subtitle_content:%s:%d:%d", path, streamIndex, st.ModTime().Unix()) - if content, ok := subtitleContentCache.Get(key); ok { - return content, true - } - - f, size, err := openFile(path) + f, st, err := openFileInfo(path) if err != nil { logFallback("subtitle extract", path, err) return "", false } defer f.Close() - vtt, err := media.ExtractEmbeddedSubtitle(f, size, streamIndex) + key := mediacache.FromFileInfo(path, st).Stream(streamIndex) + if content, ok := mediacache.SubtitleContent.Get(key); ok { + return content, true + } + + vtt, err := media.ExtractEmbeddedSubtitle(f, st.Size(), streamIndex) if err != nil { logFallback("subtitle extract", path, err) return "", false @@ -512,7 +550,7 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) { logFallback("subtitle extract", path, errors.New("empty subtitle output")) return "", false } - subtitleContentCache.Set(key, vtt) + mediacache.SubtitleContent.Set(key, vtt) return vtt, true } @@ -534,7 +572,7 @@ func videoThumbnailFast(path string, width, height, quality int, seekPercent flo if !Enabled() { return nil, false } - if !supportsVideoContainer(path) { + if !supportsVideoThumbnail(path) { return nil, false } f, size, err := openFile(path) diff --git a/backend/internal/gomedia/gomedia_test.go b/backend/internal/gomedia/gomedia_test.go index 7ee29e48d..73840114a 100644 --- a/backend/internal/gomedia/gomedia_test.go +++ b/backend/internal/gomedia/gomedia_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" + "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" "github.com/stretchr/testify/require" ) @@ -131,27 +133,78 @@ func TestProbeUsesCache(t *testing.T) { require.NoError(t, err) require.NoError(t, os.WriteFile(path, data, 0o644)) - st, err := os.Stat(path) + key, err := mediacache.Stat(path) require.NoError(t, err) - key := probeCacheKey(path, st.ModTime().UnixNano(), st.Size()) sentinel := ProbeResult{Duration: 123, VideoCodec: "sentinel", AudioCodec: "sentinel", FormatName: "sentinel"} - probeCache.Set(key, sentinel) - t.Cleanup(func() { probeCache.Delete(key) }) + // The ffmpeg probe stores into the same shared cache, so this also stands in + // for a value ffprobe produced earlier. + mediacache.Probes.Set(key.String(), sentinel) + t.Cleanup(func() { mediacache.Probes.Delete(key.String()) }) got, ok := Probe(path, nil) require.True(t, ok) require.Equal(t, sentinel, got, "probe must return the cached value for an unchanged file") + + // An edit (new size and mtime) must miss the cached value. + require.NoError(t, os.WriteFile(path, append(data, 0), 0o644)) + future := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(path, future, future)) + got, ok = Probe(path, nil) + require.True(t, ok) + require.NotEqual(t, sentinel, got, "an edited file must be probed again") + edited, err := mediacache.Stat(path) + require.NoError(t, err) + t.Cleanup(func() { mediacache.Probes.Delete(edited.String()) }) } -// TestProbeCacheKeyTracksFileIdentity checks that path, modtime, and size all -// participate in the cache key, so an edited file is never served stale metadata. -func TestProbeCacheKeyTracksFileIdentity(t *testing.T) { - base := probeCacheKey("f.mp4", 100, 200) - require.Equal(t, base, probeCacheKey("f.mp4", 100, 200), "identical identity must hash the same") - require.NotEqual(t, base, probeCacheKey("f.mp4", 101, 200), "modtime change must change the key") - require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "size change must change the key") - require.NotEqual(t, base, probeCacheKey("g.mp4", 100, 200), "path change must change the key") - require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "key must not collide across fields") +// TestDurationUsesSharedCache checks that Duration reads and fills the shared +// cache that ffmpeg's GetMediaDuration also uses. +func TestDurationUsesSharedCache(t *testing.T) { + withGate(t, false) + path := filepath.Join(t.TempDir(), "sample.mp4") + data, err := os.ReadFile(fixtureMP4) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) + key, err := mediacache.Stat(path) + require.NoError(t, err) + t.Cleanup(func() { mediacache.Durations.Delete(key.String()) }) + + d, ok := Duration(path, nil) + require.True(t, ok) + cached, found := mediacache.Durations.Get(key.String()) + require.True(t, found, "go-media duration must be stored in the shared cache") + require.Equal(t, d, cached) + + mediacache.Durations.Set(key.String(), 4242) + d, ok = Duration(path, nil) + require.True(t, ok) + require.Equal(t, 4242.0, d, "a cached duration (from either backend) must be reused") +} + +// TestSubtitleCachesUseFileIdentity checks the subtitle list cache is keyed by +// the file's real size and mtime, not by the caller's modtime argument. +func TestSubtitleCachesUseFileIdentity(t *testing.T) { + withGate(t, false) + path := filepath.Join(t.TempDir(), "sample.mp4") + data, err := os.ReadFile(fixtureMP4) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) + key, err := mediacache.Stat(path) + require.NoError(t, err) + sentinel := []utils.SubtitleTrack{{Name: "sentinel"}} + mediacache.SubtitleTracks.Set(key.String(), sentinel) + t.Cleanup(func() { mediacache.SubtitleTracks.Delete(key.String()) }) + + // The modtime argument is ignored for the key. + got, ok := EmbeddedSubtitles(path, time.Unix(1, 0), nil) + require.True(t, ok) + require.Equal(t, sentinel, got) + + mediacache.SubtitleContent.Set(key.Stream(7), "WEBVTT\n\nsentinel") + t.Cleanup(func() { mediacache.SubtitleContent.Delete(key.Stream(7)) }) + vtt, ok := ExtractSubtitle(path, 7, nil) + require.True(t, ok) + require.Equal(t, "WEBVTT\n\nsentinel", vtt) } func TestProbeWebMMatchesFFprobe(t *testing.T) { diff --git a/backend/internal/gomedia/heif_rotation.go b/backend/internal/gomedia/heif_rotation.go new file mode 100644 index 000000000..244a4922d --- /dev/null +++ b/backend/internal/gomedia/heif_rotation.go @@ -0,0 +1,101 @@ +package gomedia + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" + "github.com/gtsteffaniak/go-media/pkg/media" +) + +// heicRotationShadowTimeout bounds the dev-mode ffmpeg comparison probe. +const heicRotationShadowTimeout = time.Minute + +// HEICDisplayRotation returns the display rotation that a HEIC/HEIF/AVIF +// file's irot property asks for, read from the item properties without +// decoding. The value is degrees counter-clockwise in (-180, 180], the same +// number ffmpeg prints as "Display Matrix: rotation of ..." for the file. A +// file with no irot returns 0 and ok=true. ok is false when go-media is +// disabled or cannot read the item properties; the caller then runs the ffmpeg +// probe. +// +// It is installed as ffmpeg's HEIC display rotation reader. In shadow mode the +// ffmpeg stderr probe runs for comparison. +func HEICDisplayRotation(path string) (float64, bool) { + return timed("heic rotation", path, heicRotationLegacy(path), heicRotationDiff, func() (float64, bool) { + return heicDisplayRotationFast(path) + }) +} + +// heicRotationLegacy returns the ffmpeg stderr probe for shadow mode, or nil +// when ffmpeg is not available. +func heicRotationLegacy(path string) Legacy[float64] { + if !ShadowEnabled() { + return nil + } + svc := ffmpeg.Get() + if svc == nil { + return nil + } + return func() (float64, bool) { + ctx, cancel := context.WithTimeout(context.Background(), heicRotationShadowTimeout) + defer cancel() + return svc.GetHEICDisplayMatrixRotation(ctx, path) + } +} + +// heicDisplayRotationFast reads the primary item's irot with go-media and +// converts it to ffmpeg's display rotation. +func heicDisplayRotationFast(path string) (float64, bool) { + if !Enabled() { + return 0, false + } + // No extension check: the resolved path can have another name (symlink + // target). go-media sniffs the content and reports other files unsupported. + f, size, err := openFile(path) + if err != nil { + logFallback("heic rotation", path, err) + return 0, false + } + defer f.Close() + + t, err := media.HEIFTransform(f, size) + if err != nil { + logFallback("heic rotation", path, err) + return 0, false + } + return displayRotation(t), true +} + +// displayRotation converts go-media's counter-clockwise irot angle to ffmpeg's +// display matrix convention: counter-clockwise degrees normalized to +// (-180, 180]. ffmpeg builds the matrix with av_display_rotation_set(-irot) +// and prints av_display_rotation_get, so irot 270 is reported as -90. A mirror +// does not change the rotation value. +func displayRotation(t media.ImageTransform) float64 { + r := ((t.Rotation % 360) + 360) % 360 + if r > 180 { + r -= 360 + } + return float64(r) +} + +// heicRotationDiff compares go-media's display rotation with ffmpeg's. 180 and +// -180 are the same rotation. +func heicRotationDiff(fast, legacy float64) []string { + norm := func(v float64) float64 { + v = math.Mod(v, 360) + if v <= -180 { + v += 360 + } else if v > 180 { + v -= 360 + } + return v + } + if math.Abs(norm(fast)-norm(legacy)) > 0.5 { + return []string{fmt.Sprintf("display rotation go-media=%.2f ffmpeg=%.2f", fast, legacy)} + } + return nil +} diff --git a/backend/internal/gomedia/heif_rotation_test.go b/backend/internal/gomedia/heif_rotation_test.go new file mode 100644 index 000000000..002f52714 --- /dev/null +++ b/backend/internal/gomedia/heif_rotation_test.go @@ -0,0 +1,82 @@ +package gomedia + +import ( + "math" + "os" + "path/filepath" + "testing" + + "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" + "github.com/gtsteffaniak/go-media/pkg/media" + "github.com/stretchr/testify/require" +) + +// fixtureAVIFRotated has irot=1 (90° counter-clockwise) and imir axis 0. +const fixtureAVIFRotated = "testdata/irot1_imir0.avif" + +// TestDisplayRotation checks the irot to ffmpeg display rotation mapping. +func TestDisplayRotation(t *testing.T) { + cases := []struct { + rotation int + want float64 + }{ + {0, 0}, {90, 90}, {180, 180}, {270, -90}, {-90, -90}, {360, 0}, {450, 90}, + } + for _, c := range cases { + require.Equal(t, c.want, displayRotation(media.ImageTransform{Rotation: c.rotation}), "rotation %d", c.rotation) + } + require.Equal(t, 90.0, displayRotation(media.ImageTransform{Rotation: 90, Mirror: true}), "a mirror does not change the rotation") +} + +// TestHEICDisplayRotationReadsIrot checks the rotation read from a fixture +// with irot=1. +func TestHEICDisplayRotationReadsIrot(t *testing.T) { + withGate(t, false) + got, ok := HEICDisplayRotation(fixtureAVIFRotated) + require.True(t, ok) + require.Equal(t, 90.0, got) +} + +// TestHEICDisplayRotationFallsBack checks that non-HEIF input, broken input, +// and the Always use ffmpeg setting leave the answer to ffmpeg. +func TestHEICDisplayRotationFallsBack(t *testing.T) { + withGate(t, false) + _, ok := HEICDisplayRotation(fixtureMP4) + require.False(t, ok, "a non-HEIF file must fall back to the ffmpeg probe") + + junk := filepath.Join(t.TempDir(), "junk.heic") + require.NoError(t, os.WriteFile(junk, []byte("not heif"), 0o644)) + _, ok = HEICDisplayRotation(junk) + require.False(t, ok) + + withGate(t, true) + _, ok = HEICDisplayRotation(fixtureAVIFRotated) + require.False(t, ok, "the Always use ffmpeg setting must skip go-media") +} + +// TestHEICDisplayRotationInstalled checks that loading this package installs +// go-media as the ffmpeg package's rotation reader. +func TestHEICDisplayRotationInstalled(t *testing.T) { + withGate(t, false) + var svc *ffmpeg.Service // nil: the ffmpeg probe cannot run, so a result proves the reader answered + got, ok := svc.HEICDisplayRotation(t.Context(), fixtureAVIFRotated) + require.True(t, ok) + require.Equal(t, 90.0, got) +} + +// TestHEICRotationDiff checks the shadow comparison of display rotations. +func TestHEICRotationDiff(t *testing.T) { + require.Empty(t, heicRotationDiff(-90, -90)) + require.Empty(t, heicRotationDiff(180, -180)) + require.Empty(t, heicRotationDiff(0, math.Copysign(0, -1))) + require.Len(t, heicRotationDiff(90, -90), 1) +} + +// TestAVIFDecodesThroughHEICPath checks that an AVIF file is accepted by the +// HEIC decode path. +func TestAVIFDecodesThroughHEICPath(t *testing.T) { + withGate(t, false) + data, ok := HEICJPEG(fixtureAVIFRotated, 80, nil) + require.True(t, ok) + require.NotEmpty(t, data) +} diff --git a/backend/internal/gomedia/keyframes.go b/backend/internal/gomedia/keyframes.go new file mode 100644 index 000000000..f951c3bc4 --- /dev/null +++ b/backend/internal/gomedia/keyframes.go @@ -0,0 +1,145 @@ +package gomedia + +import ( + "context" + "fmt" + "io" + "math" + "sort" + + "github.com/gtsteffaniak/go-media/pkg/media" +) + +// keyframeTimeDedupeSec matches go-ffmpeg's ffprobe keyframe probe, which +// drops keyframes within 1 ms of the previous one. +const keyframeTimeDedupeSec = 0.001 + +// keyframeShadowTolerance is how far apart two backends' keyframe times can be +// before the shadow comparison reports them. ffprobe prints six decimals. +const keyframeShadowTolerance = 0.002 + +// KeyframeTimes returns the presentation times, in seconds, of the first video +// stream's keyframes, for HLS segment planning. The result is sorted, has no +// negative times, and drops keyframes within 1 ms of the previous one, the same +// as go-ffmpeg's ffprobe probe. go-media reads only the container index (MP4 +// stss/stts/ctts, Matroska Cues or block headers), so it is much faster than +// demuxing every packet with ffprobe. +// +// ok is false when go-media is disabled, the container is not one go-media +// indexes, or the read fails; the caller then runs ffprobe. ctx cancels the +// file reads. legacy is the ffprobe probe, run only in shadow mode. +// +// go-media can report fewer keyframes than ffprobe (ffprobe's parsers also flag +// IDR frames the container index omits, and some Matroska muxers index only +// part of the keyframes in Cues). It never reports a keyframe ffprobe would not. +func KeyframeTimes(ctx context.Context, path string, legacy Legacy[[]float64]) ([]float64, bool) { + return timed("keyframe index", path, legacy, keyframeDiff, func() ([]float64, bool) { + return keyframeTimesFast(ctx, path, 0) + }) +} + +// KeyframeTimesWithin is KeyframeTimes limited to keyframes within windowSec +// seconds of the first keyframe. It is the pre-flight check that can rule out +// stream copy without reading the whole index (Matroska files without Cues). +// It has no shadow comparison. +func KeyframeTimesWithin(ctx context.Context, path string, windowSec float64) ([]float64, bool) { + return timed("keyframe preflight", path, nil, nil, func() ([]float64, bool) { + return keyframeTimesFast(ctx, path, windowSec) + }) +} + +// keyframeTimesFast reads the keyframe index with go-media. windowSec > 0 +// limits it to keyframes within that many seconds of the first one. +func keyframeTimesFast(ctx context.Context, path string, windowSec float64) ([]float64, bool) { + if !Enabled() { + return nil, false + } + if !supportsVideoContainer(path) { + return nil, false + } + if err := ctx.Err(); err != nil { + return nil, false + } + f, size, err := openFile(path) + if err != nil { + logFallback("keyframe index", path, err) + return nil, false + } + defer f.Close() + + r := ctxReaderAt{ctx: ctx, r: f} + var times []float64 + if windowSec > 0 { + times, err = media.KeyframeTimesWithin(r, size, windowSec) + } else { + times, err = media.KeyframeTimes(r, size) + } + if err != nil { + if ctx.Err() == nil { + logFallback("keyframe index", path, err) + } + return nil, false + } + times = dedupeKeyframeTimes(times) + if len(times) == 0 { + logUnsupported("keyframe index", path, "no keyframes in container index") + return nil, false + } + return times, true +} + +// dedupeKeyframeTimes sorts times, drops negative values, and drops times +// within keyframeTimeDedupeSec of the previous kept time, as go-ffmpeg does for +// ffprobe output. +func dedupeKeyframeTimes(times []float64) []float64 { + sorted := append([]float64(nil), times...) + sort.Float64s(sorted) + out := make([]float64, 0, len(sorted)) + for _, t := range sorted { + if t < 0 || math.IsNaN(t) || math.IsInf(t, 0) { + continue + } + if len(out) == 0 || t > out[len(out)-1]+keyframeTimeDedupeSec { + out = append(out, t) + } + } + return out +} + +// ctxReaderAt fails reads once ctx is done, so a cancelled request or the plan +// timeout stops a long index walk on a slow share. +type ctxReaderAt struct { + ctx context.Context + r io.ReaderAt +} + +// ReadAt reads from the wrapped reader unless the context is done. +func (c ctxReaderAt) ReadAt(p []byte, off int64) (int, error) { + if err := c.ctx.Err(); err != nil { + return 0, err + } + return c.r.ReadAt(p, off) +} + +// keyframeDiff compares go-media's keyframe index with ffprobe's. go-media +// reporting a subset of ffprobe's keyframes is expected (parser-flagged IDR +// frames, partial Cues) and is logged at info level. +func keyframeDiff(fast, legacy []float64) []string { + missing := 0 + j := 0 + for _, t := range fast { + for j < len(legacy) && legacy[j] < t-keyframeShadowTolerance { + j++ + missing++ + } + if j >= len(legacy) || math.Abs(legacy[j]-t) > keyframeShadowTolerance { + return []string{fmt.Sprintf("keyframe at %.6fs from go-media not in ffmpeg index (go-media=%d ffmpeg=%d keyframes)", t, len(fast), len(legacy))} + } + j++ + } + missing += len(legacy) - j + if missing > 0 { + return []string{fmt.Sprintf("%sgo-media found %d of %d ffmpeg keyframes (keyframes outside the container index)", expectedDiffPrefix, len(fast), len(legacy))} + } + return nil +} diff --git a/backend/internal/gomedia/keyframes_test.go b/backend/internal/gomedia/keyframes_test.go new file mode 100644 index 000000000..16ae1450a --- /dev/null +++ b/backend/internal/gomedia/keyframes_test.go @@ -0,0 +1,176 @@ +package gomedia + +import ( + "context" + "math" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// ffprobeKeyframes runs the same packet probe as go-ffmpeg's +// ProbeVideoKeyframeTimes: key packets of v:0, pts_time falling back to +// dts_time, sorted and de-duplicated within 1 ms. +func ffprobeKeyframes(t *testing.T, path string) []float64 { + t.Helper() + requireTool(t, "ffprobe") + out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "packet=pts_time,dts_time,flags", "-of", "csv=p=0", path).Output() + require.NoError(t, err) + var times []float64 + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + f := strings.Split(strings.TrimSpace(line), ",") + if len(f) < 3 || !strings.Contains(f[2], "K") { + continue + } + v, perr := strconv.ParseFloat(f[0], 64) + if perr != nil { + v, perr = strconv.ParseFloat(f[1], 64) + } + if perr == nil { + times = append(times, v) + } + } + sort.Float64s(times) + return dedupeKeyframeTimes(times) +} + +// encodeKeyframeClip makes a short clip with a fixed GOP. B-frames give MP4 +// files a ctts table and an edit list, the hard case for the timestamp model. +func encodeKeyframeClip(t *testing.T, name string, args ...string) string { + t.Helper() + requireTool(t, "ffmpeg") + path := filepath.Join(t.TempDir(), name) + full := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=6"} + full = append(full, args...) + full = append(full, path) + if out, err := exec.Command("ffmpeg", full...).CombinedOutput(); err != nil { + t.Skipf("ffmpeg cannot encode %s: %v: %s", name, err, out) + } + return path +} + +// requireSameKeyframes fails unless got matches want within the shadow +// tolerance. +func requireSameKeyframes(t *testing.T, want, got []float64) { + t.Helper() + require.Equal(t, len(want), len(got), "keyframe count: ffprobe=%v go-media=%v", want, got) + for i := range want { + require.InDelta(t, want[i], got[i], keyframeShadowTolerance, "keyframe %d: ffprobe=%v go-media=%v", i, want, got) + } +} + +// TestKeyframeTimesMatchFFprobe compares go-media keyframe indexes with +// ffprobe for fixtures and encoded clips, including B-frame MP4/MOV. +func TestKeyframeTimesMatchFFprobe(t *testing.T) { + if testing.Short() { + t.Skip("encodes clips with ffmpeg") + } + withGate(t, false) + x264 := []string{"-c:v", "libx264", "-g", "50", "-keyint_min", "50", "-sc_threshold", "0", "-bf", "2", "-pix_fmt", "yuv420p"} + cases := map[string]string{ + "fixture.mp4": fixtureMP4, + "fixture.webm": fixtureWebM, + "bframes.mp4": encodeKeyframeClip(t, "bframes.mp4", x264...), + "bframes.mov": encodeKeyframeClip(t, "bframes.mov", x264...), + "bframes.mkv": encodeKeyframeClip(t, "bframes.mkv", x264...), + "mpeg4.m4v": encodeKeyframeClip(t, "mpeg4.m4v", "-c:v", "mpeg4", "-g", "30", "-f", "mp4"), + } + for name, path := range cases { + t.Run(name, func(t *testing.T) { + want := ffprobeKeyframes(t, path) + got, ok := KeyframeTimes(context.Background(), path, nil) + require.True(t, ok) + requireSameKeyframes(t, want, got) + + within, ok := KeyframeTimesWithin(context.Background(), path, 2.5) + require.True(t, ok) + require.NotEmpty(t, within) + require.LessOrEqual(t, len(within), len(got)) + for i := range within { + require.Equal(t, got[i], within[i]) + require.LessOrEqual(t, within[i]-got[0], 2.5+1e-9) + } + }) + } +} + +// TestKeyframeTimesFallsBack checks the cases that must leave the index to +// ffprobe. +func TestKeyframeTimesFallsBack(t *testing.T) { + withGate(t, false) + dir := t.TempDir() + + // Not an indexed container: no file open, ffprobe answers. + ts := filepath.Join(dir, "clip.ts") + require.NoError(t, os.WriteFile(ts, []byte{0x47}, 0o644)) + _, ok := KeyframeTimes(context.Background(), ts, nil) + require.False(t, ok) + + // Indexed extension but not a real container. + junk := filepath.Join(dir, "junk.mp4") + require.NoError(t, os.WriteFile(junk, []byte("not a video"), 0o644)) + _, ok = KeyframeTimes(context.Background(), junk, nil) + require.False(t, ok) + + // Missing file. + _, ok = KeyframeTimes(context.Background(), filepath.Join(dir, "missing.mp4"), nil) + require.False(t, ok) + + // Cancelled context. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, ok = KeyframeTimes(ctx, fixtureMP4, nil) + require.False(t, ok) +} + +// TestKeyframeTimesGate checks that the Always use ffmpeg setting disables +// the go-media keyframe index. +func TestKeyframeTimesGate(t *testing.T) { + withGate(t, true) + _, ok := KeyframeTimes(context.Background(), fixtureMP4, nil) + require.False(t, ok, "KeyframeTimes must skip go-media when the gate is on") + _, ok = KeyframeTimesWithin(context.Background(), fixtureMP4, 10) + require.False(t, ok) +} + +// TestDedupeKeyframeTimes checks sorting, filtering, and the 1 ms dedupe. +func TestDedupeKeyframeTimes(t *testing.T) { + got := dedupeKeyframeTimes([]float64{2, -1, 0, 0.0005, 1, 1.001, 1.0021, math.NaN(), math.Inf(1)}) + require.Equal(t, []float64{0, 1, 1.0021, 2}, got) + require.Empty(t, dedupeKeyframeTimes(nil)) +} + +// TestCtxReaderAt checks that reads stop once the context is cancelled. +func TestCtxReaderAt(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + r := ctxReaderAt{ctx: ctx, r: strings.NewReader("abcdef")} + buf := make([]byte, 3) + n, err := r.ReadAt(buf, 1) + require.NoError(t, err) + require.Equal(t, "bcd", string(buf[:n])) + cancel() + _, err = r.ReadAt(buf, 0) + require.ErrorIs(t, err, context.Canceled) +} + +// TestKeyframeDiff checks the shadow comparison: a subset is expected, an +// extra keyframe is a real difference. +func TestKeyframeDiff(t *testing.T) { + require.Empty(t, keyframeDiff([]float64{0, 2, 4}, []float64{0, 2.001, 4})) + + subset := keyframeDiff([]float64{0, 4}, []float64{0, 2, 4, 6}) + require.Len(t, subset, 1) + require.True(t, strings.HasPrefix(subset[0], expectedDiffPrefix), subset[0]) + require.Contains(t, subset[0], "2 of 4") + + extra := keyframeDiff([]float64{0, 1, 2}, []float64{0, 2}) + require.Len(t, extra, 1) + require.False(t, strings.HasPrefix(extra[0], expectedDiffPrefix), extra[0]) +} diff --git a/backend/internal/gomedia/testdata/irot1_imir0.avif b/backend/internal/gomedia/testdata/irot1_imir0.avif new file mode 100644 index 000000000..e5dc60355 Binary files /dev/null and b/backend/internal/gomedia/testdata/irot1_imir0.avif differ diff --git a/backend/internal/mediacache/mediacache.go b/backend/internal/mediacache/mediacache.go new file mode 100644 index 000000000..b98015124 --- /dev/null +++ b/backend/internal/mediacache/mediacache.go @@ -0,0 +1,109 @@ +// Package mediacache holds the media metadata caches shared by the ffmpeg and +// go-media code paths. Both backends read and write the same entries, so a +// value found by one is reused by the other instead of being probed again. +// +// Every entry is keyed by file identity: path, size, and modification time in +// nanoseconds. An edited file gets a new key, so a stale value is never served +// for it; the old entry ages out after TTL. +package mediacache + +import ( + "context" + "fmt" + "io/fs" + "os" + "time" + + "github.com/gtsteffaniak/filebrowser/backend/internal/utils" + "github.com/gtsteffaniak/go-cache/cache" +) + +// TTL is how long a cached media value is kept after it is stored. +const TTL = 24 * time.Hour + +// ProbeInfo is the container summary both backends produce: ffprobe through +// go-ffmpeg, and go-media. FormatName uses ffprobe's format_name strings. +type ProbeInfo struct { + Duration float64 + VideoCodec string + AudioCodec string + FormatName string +} + +var ( + // Probes caches container probes (duration, first video and audio codec, + // format name). + Probes = cache.NewCache[ProbeInfo](TTL) + // Durations caches media length in seconds. + Durations = cache.NewCache[float64](TTL) + // SubtitleTracks caches the embedded subtitle track list of a video. + SubtitleTracks = cache.NewCache[[]utils.SubtitleTrack](TTL) + // SubtitleContent caches one embedded subtitle stream converted to WebVTT. + // Use Key.Stream for its key. + SubtitleContent = cache.NewCache[string](TTL) +) + +// Key identifies one version of a file. The zero Key is never produced for a +// real file, because Path is always set. +type Key struct { + Path string + Size int64 + ModTime int64 // modification time in Unix nanoseconds +} + +// FromFileInfo returns the Key for path using an already-read FileInfo, so +// callers that stat or open the file anyway do not stat it twice. +func FromFileInfo(path string, info fs.FileInfo) Key { + return Key{Path: path, Size: info.Size(), ModTime: info.ModTime().UnixNano()} +} + +// Stat returns the Key for path. It follows symlinks, like os.Stat. +func Stat(path string) (Key, error) { + info, err := os.Stat(path) + if err != nil { + return Key{}, err + } + return FromFileInfo(path, info), nil +} + +// String returns the cache key for k. The path is quoted so a path containing +// the separator cannot collide with another file's key. +func (k Key) String() string { + return fmt.Sprintf("%q:%d:%d", k.Path, k.Size, k.ModTime) +} + +// Stream returns the cache key for one stream of k, used by SubtitleContent. +func (k Key) Stream(index int) string { + return fmt.Sprintf("%s:%d", k.String(), index) +} + +// ClearAll drops every cached media value. It is used when the backend that +// produced the values is switched (the admin "Always use ffmpeg" setting), so +// the newly selected backend answers fresh. +func ClearAll() { + Probes.ClearAll() + Durations.ClearAll() + SubtitleTracks.ClearAll() + SubtitleContent.ClearAll() +} + +// bypassKey is the context key set by WithoutCache. +type bypassKey struct{} + +// WithoutCache returns a context under which the ffmpeg code paths neither read +// nor write the shared caches. Dev-mode shadow runs use it, so the ffmpeg result +// they compare against go-media is computed fresh rather than read back from the +// value go-media just stored. +func WithoutCache(ctx context.Context) context.Context { + return context.WithValue(ctx, bypassKey{}, true) +} + +// Bypassed reports whether ctx was returned by WithoutCache. A nil context is +// not bypassed. +func Bypassed(ctx context.Context) bool { + if ctx == nil { + return false + } + v, _ := ctx.Value(bypassKey{}).(bool) + return v +} diff --git a/backend/internal/mediacache/mediacache_test.go b/backend/internal/mediacache/mediacache_test.go new file mode 100644 index 000000000..f5da66663 --- /dev/null +++ b/backend/internal/mediacache/mediacache_test.go @@ -0,0 +1,117 @@ +package mediacache + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// TestStatKeyChangesWithSizeAndModTime checks that a size change or a 1 ns +// mtime change gives a new key. +func TestStatKeyChangesWithSizeAndModTime(t *testing.T) { + path := filepath.Join(t.TempDir(), "a.mp4") + if err := os.WriteFile(path, []byte("one"), 0o600); err != nil { + t.Fatal(err) + } + k1, err := Stat(path) + if err != nil { + t.Fatal(err) + } + if k1.Path != path || k1.Size != 3 || k1.ModTime == 0 { + t.Fatalf("unexpected key %+v", k1) + } + + // Same size, different mtime (nanosecond precision). + mt := time.Unix(1_700_000_000, 123) + if err = os.Chtimes(path, mt, mt); err != nil { + t.Fatal(err) + } + k2, err := Stat(path) + if err != nil { + t.Fatal(err) + } + if k2.String() == k1.String() { + t.Fatalf("mtime change kept key %s", k2) + } + mt2 := time.Unix(1_700_000_000, 124) + if err = os.Chtimes(path, mt2, mt2); err != nil { + t.Fatal(err) + } + k3, _ := Stat(path) + if k3.String() == k2.String() { + t.Fatalf("1ns mtime change kept key %s", k3) + } + + // Same mtime, different size. + if err = os.WriteFile(path, []byte("three"), 0o600); err != nil { + t.Fatal(err) + } + if err = os.Chtimes(path, mt2, mt2); err != nil { + t.Fatal(err) + } + k4, _ := Stat(path) + if k4.String() == k3.String() { + t.Fatalf("size change kept key %s", k4) + } +} + +// TestStatMissingFile checks that Stat reports a missing file. +func TestStatMissingFile(t *testing.T) { + if _, err := Stat(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected an error for a missing file") + } +} + +// TestKeyStringDoesNotCollide checks that keys of different files, and of +// different streams of one file, never collide. +func TestKeyStringDoesNotCollide(t *testing.T) { + a := Key{Path: "a:1", Size: 2, ModTime: 3} + b := Key{Path: "a", Size: 1, ModTime: 2} + if a.String() == b.String() { + t.Fatalf("keys collide: %s", a) + } + if a.Stream(1) == a.Stream(2) || a.Stream(1) == a.String() { + t.Fatalf("stream keys collide: %s %s", a.Stream(1), a.Stream(2)) + } +} + +// TestClearAll checks that ClearAll empties every shared cache. +func TestClearAll(t *testing.T) { + k := Key{Path: "/clear", Size: 1, ModTime: 1} + Probes.Set(k.String(), ProbeInfo{Duration: 1}) + Durations.Set(k.String(), 1) + SubtitleContent.Set(k.Stream(0), "x") + ClearAll() + if _, ok := Probes.Get(k.String()); ok { + t.Fatal("probe survived ClearAll") + } + if _, ok := Durations.Get(k.String()); ok { + t.Fatal("duration survived ClearAll") + } + if _, ok := SubtitleContent.Get(k.Stream(0)); ok { + t.Fatal("subtitle content survived ClearAll") + } +} + +// TestWithoutCache checks the cache bypass marker and that derived contexts +// keep it. +func TestWithoutCache(t *testing.T) { + if Bypassed(context.Background()) { + t.Fatal("background context must not bypass") + } + //nolint:staticcheck // nil is part of the documented contract. + if Bypassed(nil) { + t.Fatal("nil context must not bypass") + } + ctx := WithoutCache(context.Background()) + if !Bypassed(ctx) { + t.Fatal("WithoutCache context must bypass") + } + child, cancel := context.WithCancel(ctx) + defer cancel() + if !Bypassed(child) { + t.Fatal("bypass must survive derived contexts") + } +} diff --git a/backend/internal/preview/preview.go b/backend/internal/preview/preview.go index 90cad3558..37a0413b8 100644 --- a/backend/internal/preview/preview.go +++ b/backend/internal/preview/preview.go @@ -272,8 +272,9 @@ func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType { return previewTypeOffice } - // Check HEIC with conversion enabled - if strings.HasPrefix(file.Type, "image/heic") && + // Check HEIC with conversion enabled. AVIF is a HEIF container too and uses + // the same path (go-media decode, ffmpeg fallback) under the HEIC setting. + if (strings.HasPrefix(file.Type, "image/heic") || file.Type == "image/avif") && settings.CanConvertImage(string(settings.HEICImagePreview)) { return previewTypeHEIC } diff --git a/backend/internal/preview/preview_type_test.go b/backend/internal/preview/preview_type_test.go new file mode 100644 index 000000000..ce5b07339 --- /dev/null +++ b/backend/internal/preview/preview_type_test.go @@ -0,0 +1,49 @@ +package preview + +import ( + "testing" + + "github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo" + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" +) + +// withHEICConversion sets ffmpeg availability and the HEIC preview setting for +// one test. +func withHEICConversion(t *testing.T, enabled bool) { + t.Helper() + prevAvail := settings.Env.FFmpegAvailable + prevPreview := settings.Config.Integrations.Media.Convert.ImagePreview + t.Cleanup(func() { + settings.Env.FFmpegAvailable = prevAvail + settings.Config.Integrations.Media.Convert.ImagePreview = prevPreview + }) + settings.Env.FFmpegAvailable = true + settings.Config.Integrations.Media.Convert.ImagePreview = map[settings.ImagePreviewType]*bool{ + settings.HEICImagePreview: &enabled, + } +} + +// previewFile returns a file info with only a name and MIME type set. +func previewFile(name, mimeType string) iteminfo.ExtendedFileInfo { + var f iteminfo.ExtendedFileInfo + f.Name = name + f.Type = mimeType + return f +} + +// TestDeterminePreviewTypeAVIF checks that AVIF uses the HEIF decode path when +// HEIC conversion is enabled, and the plain image path otherwise. +func TestDeterminePreviewTypeAVIF(t *testing.T) { + withHEICConversion(t, true) + if got := determinePreviewType(previewFile("a.avif", "image/avif")); got != previewTypeHEIC { + t.Fatalf("avif with HEIC conversion = %v, want previewTypeHEIC", got) + } + if got := determinePreviewType(previewFile("a.heic", "image/heic")); got != previewTypeHEIC { + t.Fatalf("heic with HEIC conversion = %v, want previewTypeHEIC", got) + } + + withHEICConversion(t, false) + if got := determinePreviewType(previewFile("a.avif", "image/avif")); got != previewTypeImage { + t.Fatalf("avif without HEIC conversion = %v, want previewTypeImage", got) + } +} diff --git a/backend/internal/preview/video.go b/backend/internal/preview/video.go index f22302f46..c9909a4e8 100644 --- a/backend/internal/preview/video.go +++ b/backend/internal/preview/video.go @@ -28,8 +28,11 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSi return nil, err } - // go-media fast path: fit within the box, no crop. Falls back to ffmpeg when - // unsupported (e.g. HEVC, VP9, AV1) or failed. + // go-media fast path: fit within the box, no crop. go-media decodes H.264, + // HEVC, VP8, VP9, AV1, MPEG-4 Part 2, and WMV3 keyframes in pure Go. It falls + // back to ffmpeg when the container is not a go-media target (AVI, MPEG-TS, + // MPEG-PS, ...), the codec or profile is unsupported (WMV1/WMV2, VC-1 + // Advanced, AV1 above 4K, ...), or the decode fails. // In shadow mode (dev only) the ffmpeg preview also runs, for comparison. // The shadow run can outlive the request, so it uses a context that is not cancelled with it. shadowCtx := context.WithoutCancel(ctx) diff --git a/backend/internal/transcoding/keyframe_cache.go b/backend/internal/transcoding/keyframe_cache.go index aacf94039..e448f603a 100644 --- a/backend/internal/transcoding/keyframe_cache.go +++ b/backend/internal/transcoding/keyframe_cache.go @@ -12,6 +12,7 @@ import ( "time" "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" + "github.com/gtsteffaniak/filebrowser/backend/internal/gomedia" "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" "github.com/gtsteffaniak/go-logger/logger" ) @@ -55,6 +56,9 @@ type keyframeCache struct { var defaultKeyframeCache = &keyframeCache{entries: map[keyframeKey]*keyframeEntry{}} +// get returns the keyframe timeline for key from memory, then disk, then +// probe. Concurrent callers for one key wait on a single probe; failures are +// not cached. func (c *keyframeCache) get(ctx context.Context, key keyframeKey, probe func(context.Context) ([]float64, error)) ([]float64, error) { c.mu.Lock() e := c.entries[key] @@ -105,12 +109,15 @@ func keyframeDiskCacheEnabled() bool { return settings.Config.Server.CacheDir != "" } +// keyframeDiskPath returns the disk cache file for key's path. func keyframeDiskPath(key keyframeKey) string { sum := sha256.Sum256([]byte(key.path)) name := hex.EncodeToString(sum[:])[:32] + ".json" return filepath.Join(settings.TranscodeKeyframeCacheDir(), name) } +// readKeyframeDiskCache returns the persisted timeline for key when it exists +// and matches the file's size, mtime, and the cache version. func readKeyframeDiskCache(key keyframeKey) ([]float64, bool) { if !keyframeDiskCacheEnabled() { return nil, false @@ -132,6 +139,8 @@ func readKeyframeDiskCache(key keyframeKey) ([]float64, bool) { return stamp.Times, true } +// writeKeyframeDiskCache persists a timeline for key atomically. Errors are +// ignored; the cache is an optimization. func writeKeyframeDiskCache(key keyframeKey, times []float64) { if len(times) == 0 || !keyframeDiskCacheEnabled() { return @@ -224,14 +233,52 @@ func probeKeyframesWithPreflight(ctx context.Context, svc *ffmpeg.Service, realP // stall plan building (and the client's session POST) indefinitely. const keyframeProbeTimeout = 2 * time.Minute +// probeKeyframesCached returns the keyframe timeline of a file version from +// the memory or disk cache, or probes it: go-media reads the container index +// first, and ffprobe runs only when go-media cannot answer or reports a gap +// that would rule out stream copy. func probeKeyframesCached(ctx context.Context, svc *ffmpeg.Service, realPath string, size, modTime int64, segmentSec float64, preflight bool) ([]float64, error) { key := keyframeKey{path: realPath, size: size, modTime: modTime} return defaultKeyframeCache.get(ctx, key, func(ctx context.Context) ([]float64, error) { probeCtx, cancel := context.WithTimeout(ctx, keyframeProbeTimeout) defer cancel() + if times, ok := gomediaKeyframes(probeCtx, svc, realPath, segmentSec, preflight); ok { + return times, nil + } if preflight { return probeKeyframesWithPreflight(probeCtx, svc, realPath, segmentSec) } return svc.ProbeVideoKeyframeTimes(probeCtx, realPath) }) } + +// gomediaKeyframes reads the keyframe timeline from the container index with +// go-media. It reports ok=false when go-media cannot answer, and also when +// preflight is set and the timeline has a gap past the copy cap: go-media can +// miss keyframes that ffprobe's parsers flag (IDR frames not in the index, +// partial Matroska Cues) but never adds one, so only ffprobe may decide that +// stream copy is impossible. A bounded go-media read runs first, so a file +// whose head already shows such a gap does not pay for a full index walk. +func gomediaKeyframes(ctx context.Context, svc *ffmpeg.Service, realPath string, segmentSec float64, preflight bool) ([]float64, bool) { + if preflight { + if head, ok := gomedia.KeyframeTimesWithin(ctx, realPath, keyframeBoundedPreflightWindowSec); ok && keyframesExceedCopyCap(head, segmentSec) { + logger.Debugf("transcode: go-media keyframe pre-flight shows a gap past the copy cap on %s; confirming with ffprobe", realPath) + return nil, false + } + } + legacy := func() ([]float64, bool) { + shadowCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), keyframeProbeTimeout) + defer cancel() + times, err := svc.ProbeVideoKeyframeTimes(shadowCtx, realPath) + return times, err == nil + } + times, ok := gomedia.KeyframeTimes(ctx, realPath, legacy) + if !ok { + return nil, false + } + if preflight && keyframesExceedCopyCap(times, segmentSec) { + logger.Debugf("transcode: go-media keyframe index shows a gap past the copy cap on %s; confirming with ffprobe", realPath) + return nil, false + } + return times, true +} diff --git a/backend/internal/transcoding/keyframe_gomedia_test.go b/backend/internal/transcoding/keyframe_gomedia_test.go new file mode 100644 index 000000000..44eb071db --- /dev/null +++ b/backend/internal/transcoding/keyframe_gomedia_test.go @@ -0,0 +1,81 @@ +package transcoding + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" +) + +const keyframeFixtureMP4 = "../../../frontend/tests/playwright-files/myfolder/testdata/sample-640x480.mp4" + +// TestProbeKeyframesCachedUsesGoMedia checks that an MP4 keyframe timeline is +// built without ffprobe: with no ffmpeg service, only go-media can answer. +func TestProbeKeyframesCachedUsesGoMedia(t *testing.T) { + if settings.AlwaysUseFfmpegEnabled() { + t.Skip("go-media disabled by settings") + } + prevCacheDir := settings.Config.Server.CacheDir + settings.Config.Server.CacheDir = "" // keep the disk cache out of the test + t.Cleanup(func() { settings.Config.Server.CacheDir = prevCacheDir }) + + path := filepath.Join(t.TempDir(), "sample.mp4") + data, err := os.ReadFile(keyframeFixtureMP4) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + times, err := probeKeyframesCached(context.Background(), nil, path, st.Size(), st.ModTime().UnixNano(), 4, true) + if err != nil { + t.Fatalf("probeKeyframesCached: %v", err) + } + if len(times) == 0 || times[0] < 0 { + t.Fatalf("unexpected keyframes %v", times) + } + for i := 1; i < len(times); i++ { + if times[i] <= times[i-1] { + t.Fatalf("keyframes not strictly increasing: %v", times) + } + } +} + +// TestGomediaKeyframesDefersCopyDecision checks that a go-media timeline with a +// gap past the copy cap is not used for the pre-flight decision (ffprobe must +// confirm it), while a plain probe still returns it. +func TestGomediaKeyframesDefersCopyDecision(t *testing.T) { + if testing.Short() { + t.Skip("encodes a clip with ffmpeg") + } + if settings.AlwaysUseFfmpegEnabled() { + t.Skip("go-media disabled by settings") + } + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not installed") + } + path := filepath.Join(t.TempDir(), "longgop.mp4") + out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=128x96:rate=25:duration=40", + "-c:v", "libx264", "-g", "750", "-keyint_min", "750", "-sc_threshold", "0", "-pix_fmt", "yuv420p", path).CombinedOutput() + if err != nil { + t.Skipf("ffmpeg cannot encode libx264: %v: %s", err, out) + } + + if times, ok := gomediaKeyframes(context.Background(), nil, path, 4, true); ok { + t.Fatalf("pre-flight must defer a 30s GOP to ffprobe, got %v", times) + } + times, ok := gomediaKeyframes(context.Background(), nil, path, 4, false) + if !ok { + t.Fatal("a plain probe must return the go-media timeline") + } + if len(times) != 2 || times[0] != 0 || times[1] < 29.9 || times[1] > 30.1 { + t.Fatalf("unexpected keyframes %v, want [0 30]", times) + } +} diff --git a/backend/internal/web/media.go b/backend/internal/web/media.go index 11191f127..f0e865416 100644 --- a/backend/internal/web/media.go +++ b/backend/internal/web/media.go @@ -12,6 +12,7 @@ import ( "github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files" "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" "github.com/gtsteffaniak/filebrowser/backend/internal/gomedia" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" "github.com/gtsteffaniak/filebrowser/backend/internal/utils" "github.com/gtsteffaniak/filebrowser/backend/pkg/settings" ) @@ -123,7 +124,8 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, } // Shadow ffmpeg runs after the fast path; bound it so a detached request cannot // leave extraction running indefinitely (sync fallback still uses r.Context()). - legacyCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), time.Minute) + // WithoutCache: compare against a fresh extraction, not the value go-media stored. + legacyCtx, cancel := context.WithTimeout(mediacache.WithoutCache(context.WithoutCancel(r.Context())), time.Minute) defer cancel() vtt, extractErr := svc.ExtractSubtitle(legacyCtx, fileInfo.RealPath, *track.Index) return vtt, extractErr == nil diff --git a/backend/pkg/settings/media_runtime.go b/backend/pkg/settings/media_runtime.go index 29153294b..7e8f316e1 100644 --- a/backend/pkg/settings/media_runtime.go +++ b/backend/pkg/settings/media_runtime.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg" + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" goffmpeg "github.com/gtsteffaniak/go-ffmpeg" "github.com/gtsteffaniak/go-ffmpeg/capabilities" "github.com/gtsteffaniak/go-ffmpeg/encode" @@ -288,6 +289,11 @@ func ApplyMediaSettings(s FullAdminMedia) { Config.Integrations.Media.Debug = s.Debug Config.Integrations.Media.HardwareAcceleration = s.HardwareAcceleration Config.Integrations.Media.AccelDevice = s.AccelDevice + if Config.Integrations.Media.AlwaysUseFfmpeg != s.AlwaysUseFfmpeg { + // The shared media cache holds values from whichever backend answered + // first. Switching backends drops them so the selected one answers fresh. + mediacache.ClearAll() + } Config.Integrations.Media.AlwaysUseFfmpeg = s.AlwaysUseFfmpeg Config.Integrations.Media.AlwaysUseOnlyOffice = s.AlwaysUseOnlyOffice Config.Integrations.Media.Transcode = s.Transcode diff --git a/backend/pkg/settings/media_runtime_test.go b/backend/pkg/settings/media_runtime_test.go index 0ead92a5e..26c852f59 100644 --- a/backend/pkg/settings/media_runtime_test.go +++ b/backend/pkg/settings/media_runtime_test.go @@ -4,6 +4,8 @@ import ( "sync" "testing" "time" + + "github.com/gtsteffaniak/filebrowser/backend/internal/mediacache" ) func withMediaConfig(t *testing.T) { @@ -308,3 +310,26 @@ func TestInitFFmpegIsIdempotent(t *testing.T) { t.Fatal("InitFFmpeg must not run the probe during tests") } } + +// TestApplyMediaSettingsClearsMediaCacheOnBackendSwitch checks that toggling +// "Always use ffmpeg" drops values the other backend cached, and that an +// unrelated settings change keeps them. +func TestApplyMediaSettingsClearsMediaCacheOnBackendSwitch(t *testing.T) { + withMediaConfig(t) + key := mediacache.Key{Path: "/settings-test.mp4", Size: 1, ModTime: 1}.String() + t.Cleanup(func() { mediacache.Durations.Delete(key) }) + + media := CurrentMediaSettings() + mediacache.Durations.Set(key, 1) + media.Debug = !media.Debug + ApplyMediaSettings(media) + if _, ok := mediacache.Durations.Get(key); !ok { + t.Fatal("an unrelated media settings change must keep the shared media cache") + } + + media.AlwaysUseFfmpeg = !media.AlwaysUseFfmpeg + ApplyMediaSettings(media) + if _, ok := mediacache.Durations.Get(key); ok { + t.Fatal("switching Always use ffmpeg must clear the shared media cache") + } +}