Compare commits

..
80 changed files with 1283 additions and 3005 deletions

No files matched your search

-5
View File
@@ -12,11 +12,6 @@ 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).
+1 -1
View File
@@ -22,7 +22,7 @@ require (
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.6.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/gtsteffaniak/go-media v1.3.0
github.com/gtsteffaniak/go-media v1.2.2
github.com/kovidgoyal/imaging v1.9.0
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
+2 -2
View File
@@ -165,8 +165,8 @@ github.com/gtsteffaniak/go-ffmpeg v1.6.2 h1:gtvBg3G4PHO1Z/ZIPEK/XP4XT/g+l9S0Rpmm
github.com/gtsteffaniak/go-ffmpeg v1.6.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-media v1.3.0 h1:EOxoaOQq05V3OUPo5NndnPZ0/VYt4gFEwa6l7MdR6rk=
github.com/gtsteffaniak/go-media v1.3.0/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
github.com/gtsteffaniak/go-media v1.2.2 h1:CgdXyqiZGa81vZUGL1AXGxQe+8owkUkX6roNACGQJ0w=
github.com/gtsteffaniak/go-media v1.2.2/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
github.com/gtsteffaniak/go-vpx v1.0.1 h1:9lSI8yoz6omDChaeV0AJrhdfe2YTrWvJrDENI/bnkAM=
github.com/gtsteffaniak/go-vpx v1.0.1/go.mod h1:tRCdDZ15oZZTsiwmQAwyH56PTt1jn1C1O0P03oOkoJ8=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+22 -35
View File
@@ -17,7 +17,6 @@ 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"
@@ -53,7 +52,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, sharedFFmpegService); err != nil {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
}
}
@@ -381,7 +380,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt, nil)
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
if err != nil {
logger.Debugf("failed to extract video metadata for file: %s (%s): %v", info.RealPath, info.Name, err)
} else {
@@ -394,8 +393,7 @@ 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) {
// Shadow run: bypass the shared cache so ffprobe is compared fresh.
return ffmpeg.DetectEmbeddedSubtitlesContext(mediacache.WithoutCache(context.Background()), info.RealPath), true
return ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime), true
})
if !ok {
subtitles = ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
@@ -473,11 +471,23 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
// go-media fast path: tags, lyrics availability, and artwork. Unsupported or
// failed reads fall through to the existing dhowden/tag code below.
if info, ok := gomedia.Audio(realPath, getArt, func() (gomedia.AudioInfo, bool) { return legacyAudioInfo(realPath, getArt) }); ok {
item.Metadata = &iteminfo.MediaMetadata{HasLyrics: info.HasLyrics}
applyMediaTags(item.Metadata, info, realPath)
item.Metadata = &iteminfo.MediaMetadata{
Title: info.Tags.Title,
Artist: info.Tags.Artist,
Album: info.Tags.Album,
Year: info.Tags.Year,
Genre: info.Tags.Genre,
Track: info.Tags.Track,
HasLyrics: info.HasLyrics,
}
if getDuration {
audioDuration(ctx, item, realPath, ffmpegService)
}
if n := len(info.Artwork); n > 0 && n <= 5*1024*1024 {
item.Metadata.AlbumArt = info.Artwork
} else if n > 5*1024*1024 {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
}
return nil
}
@@ -531,9 +541,8 @@ 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. It bypasses the shared cache so ffmpeg is compared fresh.
shadowCtx := mediacache.WithoutCache(context.WithoutCancel(ctx))
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
shadowCtx := context.WithoutCancel(ctx)
legacy := func() (float64, bool) {
d, err := service.GetMediaDuration(shadowCtx, realPath)
return d, err == nil
@@ -698,15 +707,14 @@ func legacyLyrics(realPath string) (gomedia.LyricsResult, bool) {
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
// go-media fast path. Works without ffmpeg installed.
probeService := ffmpegService
if probeService == nil {
probeService = ffmpeg.Get()
}
// 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))
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
shadowCtx := context.WithoutCancel(ctx)
legacyProbe := func() (gomedia.ProbeResult, bool) {
info, err := probeService.ProbeFile(shadowCtx, realPath)
if err != nil || info == nil {
@@ -751,33 +759,12 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
if info.FormatName != "" {
meta.Container = info.FormatName
}
if ok {
if tags, tagsOK := gomedia.VideoTags(realPath, getArt); tagsOK {
applyMediaTags(meta, tags, realPath)
}
}
if meta.Duration > 0 || meta.VideoCodec != "" || meta.AudioCodec != "" || meta.Container != "" {
item.Metadata = meta
}
return nil
}
// applyMediaTags copies go-media tags and embedded cover art into meta. Covers
// over 8MB are skipped.
func applyMediaTags(meta *iteminfo.MediaMetadata, info gomedia.AudioInfo, realPath string) {
meta.Title = info.Tags.Title
meta.Artist = info.Tags.Artist
meta.Album = info.Tags.Album
meta.Year = info.Tags.Year
meta.Genre = info.Tags.Genre
meta.Track = info.Tags.Track
if n := len(info.Artwork); n > 0 && n <= 8*1024*1024 {
meta.AlbumArt = info.Artwork
} else if n > 8*1024*1024 {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, n)
}
}
// DeleteFiles deletes the file or directory at indexPath. boundIndexPath limits
// symlink resolution to the caller's scope ("" or "/" = source root only). The
// entry itself is removed: a symlink leaf is deleted, never followed, and an
+14
View File
@@ -0,0 +1,14 @@
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
)
@@ -9,51 +9,13 @@ 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
@@ -74,8 +36,6 @@ 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
@@ -105,7 +65,6 @@ 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":
@@ -115,8 +74,6 @@ 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",
@@ -143,8 +100,6 @@ 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
@@ -50,35 +50,3 @@ 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")
}
}
+3 -5
View File
@@ -81,16 +81,14 @@ func (s *Service) ConvertHEICToJPEG(ctx context.Context, heicPath string, target
return s.CorrectHEICJPEGOrientation(ctx, heicPath, jpegBytes), nil
}
// 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.
// 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).
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.HEICDisplayRotation(ctx, heicPath)
displayRotation, displayKnown := s.GetHEICDisplayMatrixRotation(ctx, heicPath)
if orientationNeedsPostCorrection(orientation, displayRotation, displayKnown) {
return applyOrientationToJPEG(jpegBytes, orientation)
}
@@ -1,115 +0,0 @@
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)
}
}
+16 -32
View File
@@ -8,7 +8,6 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
)
@@ -50,29 +49,24 @@ func resolveLocalMediaPath(mediaPath string) (string, os.FileInfo, error) {
return clean, info, nil
}
// 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
// FileProbeInfo holds ffprobe results for a local media file.
type FileProbeInfo struct {
Duration float64
VideoCodec string
AudioCodec string
FormatName string
}
// 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.
// ProbeFile extracts duration and codec info from a media file.
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, fileInfo, err := resolveLocalMediaPath(mediaPath)
localPath, _, 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,
@@ -83,20 +77,15 @@ func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeIn
return nil, err
}
res := FileProbeInfo{
return &FileProbeInfo{
Duration: info.Duration,
VideoCodec: info.VideoCodec,
AudioCodec: info.AudioCodec,
FormatName: info.FormatName,
}
if useCache {
mediacache.Probes.Set(cacheKey, res)
}
return &res, nil
}, nil
}
// GetMediaDuration extracts the duration of a media file in seconds. Results
// are stored in the shared media cache unless ctx is mediacache.WithoutCache.
// GetMediaDuration extracts the duration of a media file in seconds.
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")
@@ -106,12 +95,9 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
if err != nil {
return 0, err
}
useCache := !mediacache.Bypassed(ctx)
cacheKey := mediacache.FromFileInfo(localPath, fileInfo).String()
if useCache {
if duration, found := mediacache.Durations.Get(cacheKey); found {
return duration, nil
}
cacheKey := fmt.Sprintf("%s:%d", localPath, fileInfo.ModTime().Unix())
if duration, found := MetadataCache.Get(cacheKey); found {
return duration, nil
}
duration, err := s.inner.GetMediaDuration(ctx, localPath)
@@ -119,8 +105,6 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
return 0, err
}
if useCache {
mediacache.Durations.Set(cacheKey, duration)
}
MetadataCache.Set(cacheKey, duration)
return duration, nil
}
+5 -12
View File
@@ -9,7 +9,6 @@ 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"
@@ -461,8 +460,7 @@ 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. Results are
// stored in the shared media cache unless ctx is mediacache.WithoutCache.
// ExtractSubtitle returns embedded subtitle content as WebVTT.
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")
@@ -472,12 +470,9 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
if err != nil {
return "", fmt.Errorf("failed to stat file: %w", err)
}
useCache := !mediacache.Bypassed(ctx)
cacheKey := mediacache.FromFileInfo(videoPath, fileInfo).Stream(streamIndex)
if useCache {
if content, ok := mediacache.SubtitleContent.Get(cacheKey); ok {
return content, nil
}
cacheKey := fmt.Sprintf("subtitle_content:%s:%d:%d", videoPath, streamIndex, fileInfo.ModTime().Unix())
if content, ok := SubtitleContentCache.Get(cacheKey); ok {
return content, nil
}
content, err := s.inner.ExtractSubtitle(ctx, videoPath, streamIndex)
@@ -485,9 +480,7 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
return "", err
}
if useCache {
mediacache.SubtitleContent.Set(cacheKey, content)
}
SubtitleContentCache.Set(cacheKey, content)
return content, nil
}
+6 -22
View File
@@ -7,30 +7,16 @@ 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. 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.
// DetectEmbeddedSubtitles detects embedded subtitle streams.
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
_ = 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
}
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
if cached, ok := MediaCache.Get(key); ok {
return cached
}
svc := Get()
@@ -38,16 +24,14 @@ func DetectEmbeddedSubtitlesContext(ctx context.Context, videoPath string) []uti
return nil
}
tracks, err := svc.inner.DetectSubtitles(ctx, videoPath)
tracks, err := svc.inner.DetectSubtitles(context.Background(), videoPath)
if err != nil {
logger.Debug("ffprobe failed for file: " + videoPath + ", error: " + err.Error())
return nil
}
subtitles := mapSubtitleTracks(tracks)
if useCache {
mediacache.SubtitleTracks.Set(key.String(), subtitles)
}
MediaCache.Set(key, subtitles)
return subtitles
}
@@ -1,73 +0,0 @@
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")
}
+75 -146
View File
@@ -20,7 +20,6 @@ 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"
@@ -29,27 +28,20 @@ import (
"golang.org/x/sync/singleflight"
)
// 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.
var (
subtitleListCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour)
subtitleContentCache = cache.NewCache[string](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
// 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)
// 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)
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)
}
// probeGroup coalesces concurrent probes of the same file so a burst of
// requests for one container reads it once.
probeGroup singleflight.Group
)
// Enabled reports whether the go-media fast path may run. The admin
// "Always use ffmpeg" setting turns it off.
@@ -68,15 +60,9 @@ func videoDimensions(info *media.ProbeInfo) (w, h int, ok bool) {
}
// videoContainerExts are the containers go-media parses for video probes,
// 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.
// 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.
var videoContainerExts = map[string]bool{
".mp4": true,
".m4v": true,
@@ -85,31 +71,11 @@ 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
@@ -127,27 +93,15 @@ var cameraRawExts = map[string]bool{
".dc2": true, ".x3f": true, ".iiq": true, ".nkc": true, ".r3d": true,
}
// supportsVideoContainer reports whether go-media targets path's container for
// probes, embedded subtitles, and keyframe indexes.
// supportsVideoContainer reports whether go-media targets path's container.
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))]
}
@@ -179,26 +133,16 @@ func logUnsupported(op, path, reason string) {
// openFile opens path for go-media, which reads through io.ReaderAt.
func openFile(path string) (*os.File, int64, error) {
f, info, err := openFileInfo(path)
if err != nil {
return nil, 0, err
}
return f, info.Size(), nil
}
// 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 {
return nil, nil, err
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, nil, err
return nil, 0, err
}
return f, info, nil
return f, info.Size(), nil
}
// ffprobeFormatNames maps go-media containers to the format_name strings ffprobe
@@ -235,25 +179,30 @@ func probeFast(path string) (ProbeResult, bool) {
return ProbeResult{}, false
}
f, info, err := openFileInfo(path)
f, size, err := openFile(path)
if err != nil {
logFallback("probe", path, err)
return ProbeResult{}, false
}
defer f.Close()
cacheKey := mediacache.FromFileInfo(path, info).String()
if res, ok := mediacache.Probes.Get(cacheKey); ok {
info, err := f.Stat()
if err != nil {
logFallback("probe", path, err)
return ProbeResult{}, false
}
cacheKey := probeCacheKey(path, info.ModTime().UnixNano(), size)
if res, ok := probeCache.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, info.Size())
res, ok := parseProbe(path, f, size)
if !ok {
return nil, errProbeUnsupported
}
mediacache.Probes.Set(cacheKey, res)
probeCache.Set(cacheKey, res)
return res, nil
})
if err != nil {
@@ -270,6 +219,12 @@ 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) {
@@ -325,18 +280,14 @@ func durationFast(path string) (float64, bool) {
if !Enabled() {
return 0, false
}
f, info, err := openFileInfo(path)
f, size, err := openFile(path)
if err != nil {
logFallback("duration", path, err)
return 0, false
}
defer f.Close()
cacheKey := mediacache.FromFileInfo(path, info).String()
if d, ok := mediacache.Durations.Get(cacheKey); ok {
return d, true
}
res, err := media.Duration(f, info.Size())
res, err := media.Duration(f, size)
if err != nil {
logFallback("duration", path, err)
return 0, false
@@ -345,9 +296,7 @@ func durationFast(path string) (float64, bool) {
// Unknown duration is not an error; ffmpeg may still find one.
return 0, false
}
d := res.Duration.Seconds()
mediacache.Durations.Set(cacheKey, d)
return d, true
return res.Duration.Seconds(), true
}
// AudioInfo is the tag, lyrics availability, and cover art FileBrowser reads
@@ -362,60 +311,38 @@ type AudioInfo struct {
// is true. A missing cover is not an error.
// legacy is the dhowden tag read, run only in shadow mode.
func Audio(path string, withArt bool, legacy Legacy[AudioInfo]) (AudioInfo, bool) {
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt, true) })
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt) })
}
func audioFast(path string, withArt, withSidecar bool) (AudioInfo, bool) {
func audioFast(path string, withArt bool) (AudioInfo, bool) {
if !Enabled() {
return AudioInfo{}, false
}
f, st, err := openFileInfo(path)
f, size, err := openFile(path)
if err != nil {
logFallback("tags", path, err)
return AudioInfo{}, false
}
defer f.Close()
size := st.Size()
cacheKey := tagsCacheKey(path, st.ModTime().UnixNano(), size)
var info AudioInfo
cached := false
if !withArt {
info, cached = tagsCache.Get(cacheKey)
// One container parse gives tags, the embedded lyrics flag, and cover bytes.
// Separate ReadTags/HasLyrics/EmbeddedArtwork calls each re-parsed the file.
audio, err := media.InspectAudio(f, size, media.AudioOptions{WithArtwork: withArt})
if err != nil {
logFallback("tags", path, err)
return AudioInfo{}, false
}
if !cached {
// One container parse gives tags, the embedded lyrics flag, and cover bytes.
// Separate ReadTags/HasLyrics/EmbeddedArtwork calls each re-parsed the file.
audio, err := media.InspectAudio(f, size, media.AudioOptions{WithArtwork: withArt})
if err != nil {
logFallback("tags", path, err)
return AudioInfo{}, false
}
info = AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
stored := info
stored.Tags.Raw = nil
tagsCache.Set(cacheKey, stored)
if withArt {
info.Artwork = audio.Artwork
}
}
if withSidecar && !info.HasLyrics {
info := AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
if !info.HasLyrics {
// Sidecar lyrics are only checked when the file has no embedded lyrics.
_, _, info.HasLyrics = media.LyricsSidecar(path)
}
if withArt {
info.Artwork = audio.Artwork
}
return info, true
}
// tagsCacheKey identifies a file version for the tag cache: path plus modtime and size.
func tagsCacheKey(path string, modTimeNanos, size int64) string {
return fmt.Sprintf("tags:%s:%d:%d", path, modTimeNanos, size)
}
// VideoTags reads embedded tags and cover from a video container
func VideoTags(path string, withArt bool) (AudioInfo, bool) {
return timed("tags", path, nil, nil, func() (AudioInfo, bool) { return audioFast(path, withArt, false) })
}
// Lyrics returns lyrics from a sidecar file, or else from embedded tags. The
// format is "embedded" for tags or the sidecar extension. An empty result with
// ok=true means go-media checked and found no lyrics.
@@ -457,9 +384,7 @@ 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. modtime is kept
// for API compatibility; the cache key comes from the file's current size and
// modification time.
// subtitles", because go-media returns an empty list for them.
// 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) {
@@ -474,20 +399,19 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac
if !supportsVideoContainer(path) {
return nil, false
}
_ = modtime
f, st, err := openFileInfo(path)
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)
if err != nil {
logFallback("subtitle list", path, err)
return nil, false
}
defer f.Close()
key := mediacache.FromFileInfo(path, st).String()
if cached, ok := mediacache.SubtitleTracks.Get(key); ok {
return cached, true
}
info, err := media.Probe(f, st.Size())
info, err := media.Probe(f, size)
if err != nil {
logFallback("subtitle list", path, err)
return nil, false
@@ -509,7 +433,7 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac
})
}
tracks := ffmpeg.BuildSubtitleTracks(streams)
mediacache.SubtitleTracks.Set(key, tracks)
subtitleListCache.Set(key, tracks)
return tracks, true
}
@@ -529,19 +453,24 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) {
if !supportsVideoContainer(path) {
return "", false
}
f, st, err := openFileInfo(path)
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)
if err != nil {
logFallback("subtitle extract", path, err)
return "", false
}
defer f.Close()
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)
vtt, err := media.ExtractEmbeddedSubtitle(f, size, streamIndex)
if err != nil {
logFallback("subtitle extract", path, err)
return "", false
@@ -550,7 +479,7 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) {
logFallback("subtitle extract", path, errors.New("empty subtitle output"))
return "", false
}
mediacache.SubtitleContent.Set(key, vtt)
subtitleContentCache.Set(key, vtt)
return vtt, true
}
@@ -572,7 +501,7 @@ func videoThumbnailFast(path string, width, height, quality int, seekPercent flo
if !Enabled() {
return nil, false
}
if !supportsVideoThumbnail(path) {
if !supportsVideoContainer(path) {
return nil, false
}
f, size, err := openFile(path)
+13 -66
View File
@@ -11,8 +11,6 @@ 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"
)
@@ -133,78 +131,27 @@ func TestProbeUsesCache(t *testing.T) {
require.NoError(t, err)
require.NoError(t, os.WriteFile(path, data, 0o644))
key, err := mediacache.Stat(path)
st, err := os.Stat(path)
require.NoError(t, err)
key := probeCacheKey(path, st.ModTime().UnixNano(), st.Size())
sentinel := ProbeResult{Duration: 123, VideoCodec: "sentinel", AudioCodec: "sentinel", FormatName: "sentinel"}
// 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()) })
probeCache.Set(key, sentinel)
t.Cleanup(func() { probeCache.Delete(key) })
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()) })
}
// 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)
// 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")
}
func TestProbeWebMMatchesFFprobe(t *testing.T) {
-101
View File
@@ -1,101 +0,0 @@
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
}
@@ -1,82 +0,0 @@
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)
}
-145
View File
@@ -1,145 +0,0 @@
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
}
-176
View File
@@ -1,176 +0,0 @@
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])
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

-109
View File
@@ -1,109 +0,0 @@
// 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
}
@@ -1,157 +0,0 @@
package mediacache
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"time"
)
// TestStatKeyChangesWithSizeAndModTime checks on disk that a size change
// or an mtime change gives a new key. The mtimes differ by whole seconds so
// the check holds on filesystems with coarse timestamps; the 1 ns case is
// covered by TestFromFileInfoNanosecondModTime.
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.
mt := time.Unix(1_700_000_000, 0)
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 := mt.Add(time.Second)
if err = os.Chtimes(path, mt2, mt2); err != nil {
t.Fatal(err)
}
k3, _ := Stat(path)
if k3.String() == k2.String() {
t.Fatalf("1s 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)
}
}
// stubFileInfo is an fs.FileInfo with a fixed size and mtime.
type stubFileInfo struct {
size int64
modTime time.Time
}
// Name implements fs.FileInfo.
func (s stubFileInfo) Name() string { return "a.mp4" }
// Size implements fs.FileInfo.
func (s stubFileInfo) Size() int64 { return s.size }
// Mode implements fs.FileInfo.
func (s stubFileInfo) Mode() fs.FileMode { return 0o600 }
// ModTime implements fs.FileInfo.
func (s stubFileInfo) ModTime() time.Time { return s.modTime }
// IsDir implements fs.FileInfo.
func (s stubFileInfo) IsDir() bool { return false }
// Sys implements fs.FileInfo.
func (s stubFileInfo) Sys() any { return nil }
// TestFromFileInfoNanosecondModTime checks that mtimes 1 ns apart give
// different keys, independent of what a filesystem can store.
func TestFromFileInfoNanosecondModTime(t *testing.T) {
a := FromFileInfo("/m/a.mp4", stubFileInfo{size: 3, modTime: time.Unix(1_700_000_000, 123)})
b := FromFileInfo("/m/a.mp4", stubFileInfo{size: 3, modTime: time.Unix(1_700_000_000, 124)})
if a.String() == b.String() {
t.Fatalf("1ns mtime change kept key %s", a)
}
if a.ModTime != time.Unix(1_700_000_000, 123).UnixNano() {
t.Fatalf("ModTime = %d, want nanoseconds", a.ModTime)
}
}
// 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")
}
}
+2 -3
View File
@@ -272,9 +272,8 @@ func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
return previewTypeOffice
}
// 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") &&
// Check HEIC with conversion enabled
if strings.HasPrefix(file.Type, "image/heic") &&
settings.CanConvertImage(string(settings.HEICImagePreview)) {
return previewTypeHEIC
}
@@ -1,49 +0,0 @@
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)
}
}
+2 -5
View File
@@ -28,11 +28,8 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSi
return nil, err
}
// 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.
// go-media fast path: fit within the box, no crop. Falls back to ffmpeg when
// unsupported (e.g. HEVC, VP9, AV1) or failed.
// 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)
+1 -1
View File
@@ -133,7 +133,7 @@ func (e *MediaEngine) BuildPlan(ctx context.Context, realPath string, profile Pr
copyImpossible := false
plan.segmentSec = defaults.SegmentDurationSec
if plan.remux || plan.videoCopy {
rawKeyframes, kfErr := probeKeyframesCached(ctx, svc, realPath, stat.Size(), stat.ModTime().UnixNano(), plan.segmentSec, mediaSpan{durationSec: info.Duration, startTimeSec: info.StartTime}, true)
rawKeyframes, kfErr := probeKeyframesCached(ctx, svc, realPath, stat.Size(), stat.ModTime().UnixNano(), plan.segmentSec, true)
if kfErr != nil {
copyImpossible = true
logger.Warningf("transcode: keyframe probe failed on %s (%v) — forcing video transcode for HLS", realPath, kfErr)
+1 -79
View File
@@ -12,7 +12,6 @@ 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"
)
@@ -56,9 +55,6 @@ 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]
@@ -109,15 +105,12 @@ 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
@@ -139,8 +132,6 @@ 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
@@ -228,88 +219,19 @@ func probeKeyframesWithPreflight(ctx context.Context, svc *ffmpeg.Service, realP
)
}
// mediaSpan is the ffprobe format duration and start time of a file, the
// values the HLS planner turns into its media timeline. The zero value means
// unknown.
type mediaSpan struct {
durationSec float64
startTimeSec float64
}
// trailingGapExceedsCopyCap reports whether the stretch from the last
// keyframe to the end of the media is longer than the copy cap, measured on
// the same normalized timeline the planner builds: the final segment runs
// from the last keyframe to the media end, so such a gap forces a transcode
// just like a gap between two keyframes. An unknown span reports false.
func trailingGapExceedsCopyCap(keyframes []float64, span mediaSpan, segmentSec float64) bool {
if len(keyframes) == 0 || span.durationSec <= 0 {
return false
}
kf, mediaDur := ffmpeg.PrepareHLSMediaTimeline(keyframes, span.durationSec, span.startTimeSec)
if len(kf) == 0 || mediaDur <= 0 {
return false
}
return mediaDur-kf[len(kf)-1] > maxCopySegmentDurationSec(segmentSec)
}
// keyframeProbeTimeout bounds a single full-file keyframe probe. The default
// demux has no deadline of its own, so a slow network share would otherwise
// 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. span is the file's duration and start
// time, used to check the gap after the last keyframe; the zero value skips
// that check.
func probeKeyframesCached(ctx context.Context, svc *ffmpeg.Service, realPath string, size, modTime int64, segmentSec float64, span mediaSpan, preflight bool) ([]float64, error) {
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, span, 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, including
// the gap from the last keyframe to the end of span: 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, span mediaSpan, 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
}
if preflight && trailingGapExceedsCopyCap(times, span, segmentSec) {
logger.Debugf("transcode: go-media keyframe index ends more than the copy cap before the end of %s; confirming with ffprobe", realPath)
return nil, false
}
return times, true
}
@@ -177,33 +177,3 @@ func TestKeyframeCacheDoesNotCacheFailures(t *testing.T) {
t.Fatalf("retry after failure: %v, %v", got, err)
}
}
// TestTrailingGapExceedsCopyCap checks the gap from the last keyframe to the
// end of the media, on the planner's start-time-normalized timeline.
func TestTrailingGapExceedsCopyCap(t *testing.T) {
every2 := func(from, to float64) []float64 {
var out []float64
for v := from; v < to; v += 2 {
out = append(out, v)
}
return out
}
cases := []struct {
name string
kf []float64
span mediaSpan
want bool
}{
{"partial index", []float64{0, 2}, mediaSpan{durationSec: 60}, true},
{"full index", every2(0, 60), mediaSpan{durationSec: 60}, false},
{"unknown span", []float64{0, 2}, mediaSpan{}, false},
{"start time, partial", []float64{10, 12}, mediaSpan{durationSec: 70, startTimeSec: 10}, true},
{"start time, full", every2(10, 70), mediaSpan{durationSec: 70, startTimeSec: 10}, false},
{"no keyframes", nil, mediaSpan{durationSec: 60}, false},
}
for _, c := range cases {
if got := trailingGapExceedsCopyCap(c.kf, c.span, 4); got != c.want {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
@@ -1,111 +0,0 @@
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, mediaSpan{}, 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, mediaSpan{}, true); ok {
t.Fatalf("pre-flight must defer a 30s GOP to ffprobe, got %v", times)
}
times, ok := gomediaKeyframes(context.Background(), nil, path, 4, mediaSpan{}, 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)
}
}
// TestGomediaKeyframesDefersTrailingGap checks that a go-media index whose
// last keyframe is more than the copy cap before the end of the media is not
// used for the pre-flight decision, so ffprobe can confirm it, while the
// same index is used when it reaches the end.
func TestGomediaKeyframesDefersTrailingGap(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(), "short.mp4")
out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=128x96:rate=25:duration=4",
"-c:v", "libx264", "-g", "50", "-keyint_min", "50", "-sc_threshold", "0", "-pix_fmt", "yuv420p", path).CombinedOutput()
if err != nil {
t.Skipf("ffmpeg cannot encode libx264: %v: %s", err, out)
}
// The index ends at 2 s; a 60 s span leaves a 58 s final segment.
if times, ok := gomediaKeyframes(context.Background(), nil, path, 4, mediaSpan{durationSec: 60}, true); ok {
t.Fatalf("pre-flight must defer a 58s trailing gap to ffprobe, got %v", times)
}
times, ok := gomediaKeyframes(context.Background(), nil, path, 4, mediaSpan{durationSec: 4}, true)
if !ok || len(times) != 2 {
t.Fatalf("index reaching the end must be used, got %v, %v", times, ok)
}
}
+1 -3
View File
@@ -12,7 +12,6 @@ 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"
)
@@ -124,8 +123,7 @@ 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()).
// WithoutCache: compare against a fresh extraction, not the value go-media stored.
legacyCtx, cancel := context.WithTimeout(mediacache.WithoutCache(context.WithoutCancel(r.Context())), time.Minute)
legacyCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), time.Minute)
defer cancel()
vtt, extractErr := svc.ExtractSubtitle(legacyCtx, fileInfo.RealPath, *track.Index)
return vtt, extractErr == nil
-6
View File
@@ -8,7 +8,6 @@ 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"
@@ -289,11 +288,6 @@ 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
@@ -4,8 +4,6 @@ import (
"sync"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
)
func withMediaConfig(t *testing.T) {
@@ -310,26 +308,3 @@ 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")
}
}
-1
View File
@@ -38,7 +38,6 @@ const EXTRA_ICONS = [
'create_new_folder',
'do_not_disturb_on',
'done',
'equalizer',
'error_outline',
'file',
'file_copy',
+4 -4
View File
@@ -1,6 +1,6 @@
import { notify } from "@/notify/index.ts";
import { getApiPath } from "@/utils/url.js";
import { invalidateDirMetadataItem, markDirMetadataItemChanged } from "@/utils/cache/metadataCache.ts";
import { getApiPath, getParentDir } from "@/utils/url.js";
import { invalidateDirMetadataCache, invalidateDirMetadataItem } from "@/utils/cache/metadataCache.ts";
import { fetchURL } from "./utils.ts";
/**
@@ -35,7 +35,7 @@ export async function createArchive(opts) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
markDirMetadataItemChanged({ source: toSource || fromSource }, destination);
invalidateDirMetadataCache({ source: toSource || fromSource, path: getParentDir(destination) });
if (deleteAfter) {
for (const p of paths) {
invalidateDirMetadataItem({ source: fromSource }, p);
@@ -81,7 +81,7 @@ export async function unarchive(opts) {
body: JSON.stringify(body),
});
if (!preview) {
invalidateDirMetadataItem({ source: toSource || fromSource }, destination);
invalidateDirMetadataCache({ source: toSource || fromSource, path: destination });
if (deleteAfter) {
invalidateDirMetadataItem({ source: fromSource }, path);
}
+2 -2
View File
@@ -8,12 +8,12 @@
// - /api/tools/ -> tools.js
// - /api/office/ -> office.js
// - /api/wopi/ -> wopi.js
// - /api/media/ -> media.ts
// - /api/media/ -> media.js
// - /public/api/* -> public functions in respective files (e.g., resourcesApi.fetchFilesPublic)
import * as accessApi from "./access.js";
import * as authApi from "./auth.js";
import * as mediaApi from "./media.ts";
import * as mediaApi from "./media.js";
import * as officeApi from "./office.js";
import * as resourcesApi from "./resources.js";
import * as settingsApi from "./settings.js";
+247
View File
@@ -0,0 +1,247 @@
import { notify } from "@/notify/index.ts";
import { state } from "@/store/index.ts";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL } from "./utils.ts";
import {
getDirMetadataEntry,
setCachedDirMetadata,
setDirMetadataEntryPromise,
stemOf,
takePendingDirChanges,
} from "@/utils/cache/metadataCache.ts";
// GET /api/media/subtitles or /public/api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const hash = state.shareInfo?.hash || null
const baseParams = {
path,
name: subtitleName,
...(embedded && { embedded: 'true' }),
}
let res
if (hash) {
const apiPath = getPublicApiPath('media/subtitles', {
hash,
...baseParams,
})
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
res = await fetchURL(apiPath, {
headers: { 'X-SHARE-PASSWORD': encodeURIComponent(sharePassword) },
})
} else {
const apiPath = getApiPath('media/subtitles', {
source,
...baseParams,
})
res = await fetchURL(apiPath)
}
const content = await res.text()
return content
} catch (err) {
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
// GET /api/media/lyrics
export async function getLyrics(source, path) {
const apiPath = getApiPath('media/lyrics', {
source: source,
path: path,
});
const res = await fetchURL(apiPath);
const data = await res.json();
return { lyrics: data.lyrics || "", format: data.format || "" };
}
async function publicResponseError(response) {
const error = new Error(response.statusText);
try {
const data = await response.json();
if (data?.message) {
error.message = data.message;
}
} catch (_e) {
// keep the status text
}
/** @type {any} */ (error).status = response.status;
return error;
}
// GET /public/api/media/lyrics
export async function getLyricsPublic(path, hash, password = "") {
const params = {
path,
hash,
};
const apiPath = getPublicApiPath("media/lyrics", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
});
if (!response.ok) {
throw await publicResponseError(response);
}
const data = await response.json();
return { lyrics: data.lyrics || "", format: data.format || "" };
}
// GET /api/media/metadata — directory or file with metadata; optional albumArt for embedded cover extraction.
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
/** @returns {Promise<object>} resource (adjustedData) */
export async function fetchDirectoryMediaMetadata(source, path, albumArt = false) {
const apiPath = getApiPath("media/metadata", {
source,
path,
...(albumArt ? { albumArt: "true" } : {}),
});
const res = await fetchURL(apiPath);
const data = await res.json();
return adjustedData(data);
}
// GET /public/api/media/metadata
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
/** @returns {Promise<object>} resource (adjustedData) */
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "", albumArt = false) {
const params = {
path,
hash,
...(albumArt ? { albumArt: "true" } : {}),
};
const apiPath = getPublicApiPath("media/metadata", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password || "") },
});
if (!response.ok) {
throw await publicResponseError(response);
}
const data = await response.json();
return adjustedData(data);
}
async function fetchWholeDirectoryMetadata({ isShare, source, hash, path, albumArt }, password) {
const payload = isShare
? await fetchDirectoryMediaMetadataPublic(path, hash, password, albumArt)
: await fetchDirectoryMediaMetadata(source, path, albumArt);
return new Map(
(payload?.items || []).filter((i) => i.metadata).map((i) => [i.name, i.metadata])
);
}
async function fetchItemMetadata({ isShare, source, hash, path, albumArt }, password, name) {
const base = !path || path === "/" ? "" : path.replace(/\/+$/, "");
const itemPath = `${base}/${name}`;
const payload = isShare
? await fetchDirectoryMediaMetadataPublic(itemPath, hash, password, albumArt)
: await fetchDirectoryMediaMetadata(source, itemPath, albumArt);
return payload && payload.type !== "directory" && payload.metadata ? payload.metadata : null;
}
// Applies the per-item changes recorded since the folder was cached, fetching only the changed items.
function resolvePendingChanges(entry, { removed, dirty, stems }, cacheOpts, password) {
const base = entry.promise;
const promise = (async () => {
const map = new Map(await base);
for (const name of removed) {
map.delete(name);
}
const toRefresh = new Set(dirty);
if (stems.size) {
for (const name of map.keys()) {
if (stems.has(stemOf(name))) {
toRefresh.add(name);
}
}
}
// returns the names that failed, others are applied
const refresh = async (names) => {
const failed = [];
await Promise.all(
names.map(async (name) => {
try {
const metadata = await fetchItemMetadata(cacheOpts, password, name);
if (metadata) {
map.set(name, metadata);
} else {
map.delete(name);
}
} catch (_e) {
failed.push(name);
}
})
);
return failed;
};
const failed = await refresh([...toRefresh]);
if (failed.length && (await refresh(failed)).length) {
return fetchWholeDirectoryMetadata(cacheOpts, password);
}
return map;
})();
setDirMetadataEntryPromise(entry, promise);
return promise;
}
/**
* Fetches media metadata, and caches it by directory, shared for all the callers to avoid unnecessary requests.
* Items changed (deleted, uploaded, moved) are patched individually instead of refetch the whole dir
* @param {string} path - directory path
* @param {{isShare?: boolean, source?: string, hash?: string, password?: string, albumArt?: boolean}} opts
* @returns {Promise<Map<string, object>>}
*/
export async function getDirectoryMetadataMap(path, opts = {}) {
const { isShare = false, source, hash, password = "", albumArt = false } = opts;
const cacheOpts = { isShare, source, hash, path, albumArt };
const entry = albumArt ? null : getDirMetadataEntry(cacheOpts);
if (entry) {
const pending = takePendingDirChanges(entry);
return pending ? resolvePendingChanges(entry, pending, cacheOpts, password) : entry.promise;
}
const promise = fetchWholeDirectoryMetadata(cacheOpts, password);
if (!albumArt) {
setCachedDirMetadata(cacheOpts, promise);
}
return promise;
}
// GET /api/media/stream — audio/video bytes via viewToken (range-based, not download-metered).
export function getStreamURL(source, path, viewToken) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
if (!viewToken) {
throw new Error('view token required')
}
try {
const params = {
source: source,
file: path,
viewToken: viewToken,
sessionId: state.sessionId,
}
const apiPath = getApiPath('media/stream', params)
return window.origin + apiPath
} catch (err) {
notify.showError(err.message || 'Error getting stream URL')
throw err
}
}
// GET /public/api/media/stream
export function getStreamURLPublic(share, files, viewToken) {
if (!viewToken) {
throw new Error('view token required')
}
const fileArray = Array.isArray(files) ? files : [files]
const params = {
file: fileArray,
hash: share.hash,
viewToken: viewToken,
sessionId: state.sessionId,
}
const apiPath = getPublicApiPath('media/stream', params)
return window.origin + apiPath
}
-120
View File
@@ -1,120 +0,0 @@
import { notify } from "@/notify/index.ts";
import { getters, state } from "@/store/index.ts";
import type { MediaMetadata, SubtitleTrack } from "@/store/types.ts";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL, type ListingResponse } from "./utils.ts";
import { cachedDirectory, cachedItem, type DirMetadataMap, type MetadataScope } from "@/utils/cache/metadataCache.ts";
/** GET /api/media/metadata: a directory with the metadata of its items, or a single file */
export type MediaResource = ListingResponse & {
metadata?: MediaMetadata;
subtitles?: SubtitleTrack[];
hasPreview?: boolean;
};
export interface Lyrics {
lyrics: string;
format: string;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "";
}
// Inside a share the requests go to the public API, otherwise to the source.
// Decided here so callers don't branch, and it is the scope of what gets cached.
function mediaScope(source?: string): MetadataScope {
const hash = getters.shareHash();
return hash ? { isShare: true, hash } : { source };
}
// GET /api/media/{endpoint} or /public/api/media/{endpoint}
function fetchMedia(endpoint: string, scope: MetadataScope, params: Record<string, string>): Promise<Response> {
if (scope.isShare) {
const password = localStorage.getItem(`sharepass:${scope.hash}`) || "";
return fetchURL(getPublicApiPath(`media/${endpoint}`, { hash: scope.hash, ...params }), {
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password) },
});
}
return fetchURL(getApiPath(`media/${endpoint}`, { source: scope.source, ...params }));
}
export function getSubtitleContent(source: string, path: string, subtitleName: string, embedded = false): Promise<string> {
const scope = mediaScope(source);
return cachedItem(scope, path, `subtitle:${subtitleName}:${embedded}`, async () => {
try {
const res = await fetchMedia("subtitles", scope, {
path,
name: subtitleName,
...(embedded && { embedded: "true" }),
});
return await res.text();
} catch (err) {
notify.showError(errorMessage(err) || `Error fetching subtitle ${subtitleName}`);
throw err;
}
});
}
export function getLyrics(source: string, path: string): Promise<Lyrics> {
const scope = mediaScope(source);
return cachedItem(scope, path, "lyrics", async () => {
const res = await fetchMedia("lyrics", scope, { path });
const data = await res.json();
return { lyrics: data.lyrics || "", format: data.format || "" };
});
}
// albumArt adds the embedded cover of audio files.
async function fetchMetadata(scope: MetadataScope, path: string, albumArt = false): Promise<MediaResource> {
const res = await fetchMedia("metadata", scope, { path, ...(albumArt && { albumArt: "true" }) });
return adjustedData(await res.json());
}
/** Metadata of a single file */
export function getItemMediaMetadata(
path: string,
{ source, albumArt = false }: { source?: string; albumArt?: boolean } = {},
): Promise<MediaResource> {
const scope = mediaScope(source);
const fetcher = () => fetchMetadata(scope, path, albumArt);
return albumArt ? fetcher() : cachedItem(scope, path, "metadata", fetcher);
}
/**
* Metadata of the items of a directory by name, cached by dir and shared for all callers.
* Items changed are patched individually instead of refetching the whole dir again.
*/
export function getDirectoryMetadataMap(path: string, { source }: { source?: string } = {}): Promise<DirMetadataMap> {
const scope = mediaScope(source);
const base = !path || path === "/" ? "" : path.replace(/\/+$/, "");
return cachedDirectory(
scope,
path,
async () => {
const payload = await fetchMetadata(scope, path);
const withMetadata = (payload.items || []).filter((i) => i.metadata);
return new Map(withMetadata.map((i) => [i.name, i.metadata as object]));
},
async (name) => {
const payload = await fetchMetadata(scope, `${base}/${name}`);
return payload.type !== "directory" && payload.metadata ? payload.metadata : null;
},
);
}
// GET /api/media/stream or /public/api/media/stream — audio/video bytes via viewToken (range-based, not download-metered).
export function getStreamURL(source: string, path: string, viewToken: string): string {
const scope = mediaScope(source);
if (!scope.isShare && !source) {
throw new Error("no source provided");
}
if (!viewToken) {
throw new Error("view token required");
}
const params = { file: path, viewToken: viewToken, sessionId: state.sessionId };
const apiPath = scope.isShare
? getPublicApiPath("media/stream", { hash: scope.hash, ...params })
: getApiPath("media/stream", { source, ...params });
return window.origin + apiPath;
}
+8 -4
View File
@@ -7,13 +7,14 @@ import {
notifyDownloadError,
} from "@/utils/appNotifications.js"
import { renew } from "@/utils/auth.js"
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { getApiPath, getPublicApiPath, getParentDir } from '@/utils/url.js'
import { adjustedData, fetchURL } from "./utils.ts"
import { rememberViewToken } from "./viewToken.js"
import { isMediaFile } from "@/utils/mediaFile.js"
import { getObjectProperty } from "@/utils/object.js"
import { getStreamURL } from "./media.ts"
import { getStreamURL, getStreamURLPublic } from "./media.js"
import {
invalidateDirMetadataCache,
invalidateDirMetadataItem,
markDirMetadataItemChanged,
removeDirMetadataItem
@@ -1025,7 +1026,7 @@ export async function moveCopy(
removeDirMetadataItem({ source: item.fromSource }, item.from)
}
if (rename) {
invalidateDirMetadataItem({ source: item.toSource }, item.to)
invalidateDirMetadataCache({ source: item.toSource, path: getParentDir(item.to) })
} else {
markDirMetadataItemChanged({ source: item.toSource }, item.isDir ? `${item.to}/` : item.to)
}
@@ -1146,6 +1147,9 @@ export function getViewURL(source, path, viewToken, shareInfo = null, allowDownl
if (viewToken) {
if (isMediaFile(typeHint)) {
if (shareInfo) {
return getStreamURLPublic(shareInfo, [path], viewToken)
}
return getStreamURL(source, path, viewToken)
}
if (shareInfo) {
@@ -1661,7 +1665,7 @@ export async function moveCopyPublic(
removeDirMetadataItem({ isShare: true, hash }, item.from)
}
if (rename) {
invalidateDirMetadataItem({ isShare: true, hash }, item.to)
invalidateDirMetadataCache({ isShare: true, hash, path: getParentDir(item.to) })
} else {
markDirMetadataItemChanged({ isShare: true, hash }, item.isDir ? `${item.to}/` : item.to)
}
+1
View File
@@ -19,6 +19,7 @@ vi.mock("@/store", () => ({
vi.mock("@/api/media", () => ({
getStreamURL: (_source, path, viewToken) =>
`http://localhost/api/media/stream?file=${encodeURIComponent(path)}&viewToken=${viewToken}`,
getStreamURLPublic: () => "http://localhost/public/stream",
}));
import { getViewURL, getOpenFileURL } from "@/api/resources.js";
+1 -5
View File
@@ -495,12 +495,8 @@ function itemsForPrompt(): MenuItem[] {
}
function showInfoPrompt() {
const item =
isPreview.value && !props.items?.length && (showOverflow.value || !firstSelected.value)
? state.req
: firstSelected.value;
mutations.closeHovers();
mutations.showPrompt({ name: "info", props: { item } });
mutations.showPrompt({ name: "info", props: { item: firstSelected.value } });
}
function navToItem() {
+88 -15
View File
@@ -1,7 +1,26 @@
<template>
<div class="audio-side-panel">
<div class="panel-tabs">
<SlidingTabs v-model="activeTab" :tabs="panelTabs" />
<div class="tab-container">
<input type="radio" id="tab-queue" v-model="activeTab" value="queue" hidden />
<label for="tab-queue" class="tab-btn" :class="{ active: activeTab === 'queue' }" :title="$t('player.QueuePlayback')">
<i class="material-symbols">queue_music</i>
<span>{{ $t('player.queue') }}</span>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span v-if="queueCount > 0">({{ queueCount }})</span>
</label>
<input type="radio" id="tab-lyrics" v-model="activeTab" value="lyrics" hidden />
<label for="tab-lyrics" class="tab-btn" :class="{ active: activeTab === 'lyrics' }" :title="$t('player.lyrics')">
<i class="material-symbols">lyrics</i>
<span>{{ $t('player.lyrics') }}</span>
</label>
<input type="radio" id="tab-visualizer" v-model="activeTab" value="visualizer" hidden />
<label for="tab-visualizer" class="tab-btn" :class="{ active: activeTab === 'visualizer' }" :title="$t('player.visualizer.title')">
<i class="material-symbols">equalizer</i>
<span>{{ $t('player.visualizer.title') }}</span>
</label>
<div class="tab-indicator" :style="indicatorStyle"></div>
</div>
</div>
<div class="panel-content">
@@ -71,7 +90,6 @@
</template>
<script>
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import PlaybackQueue from "@/components/prompts/PlaybackQueue.vue";
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
import { mutations, state } from "@/store";
@@ -101,7 +119,7 @@ const FREQ_LABELS = [
export default {
name: "AudioPanel",
components: { SlidingTabs, PlaybackQueue, FloatingActionButton },
components: { PlaybackQueue, FloatingActionButton },
props: {
lyrics: { type: Array, default: () => [] },
lyricsMeta: { type: String, default: '' },
@@ -165,18 +183,12 @@ export default {
visualizerSettingsLabel() {
return this.$t('player.visualizer.settings');
},
panelTabs() {
return [
{
id: 'queue',
icon: 'queue_music',
label: this.$t('player.queue'),
title: this.$t('player.QueuePlayback'),
suffix: this.queueCount > 0 ? `(${this.queueCount})` : undefined,
},
{ id: 'lyrics', icon: 'lyrics', label: this.$t('player.lyrics') },
{ id: 'visualizer', icon: 'equalizer', label: this.$t('player.visualizer.title') },
];
// tabs in the panel
indicatorStyle() {
const tabCount = 3;
const width = 100 / tabCount;
const tabIndex = ['queue', 'lyrics', 'visualizer'].indexOf(this.activeTab);
return { width: `${width}%`, left: `${tabIndex * width}%` };
},
},
watch: {
@@ -776,6 +788,62 @@ export default {
border-bottom: 1px solid var(--divider);
}
/* Container for the sliding indicator */
.tab-container {
position: relative;
display: flex;
gap: 0.5em;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
padding: 0.5em;
border: none;
background: transparent;
color: var(--textSecondary);
border-radius: var(--borderRadius);
cursor: pointer;
font-size: 0.9rem;
transition: color 0.2s ease, transform 0.2s ease;
position: relative;
z-index: 1;
user-select: none;
width: 100%;
min-width: 0;
white-space: nowrap;
}
.tab-btn > i,
.tab-btn > span + span {
flex-shrink: 0;
}
.tab-btn > span:first-of-type {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-btn.active {
color: white;
}
.tab-indicator {
position: absolute;
top: 0; bottom: 0;
left: 0;
background: var(--primaryColor);
border-radius: var(--borderRadius);
z-index: 0;
transition: left 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
}
.panel-content {
flex: 1;
overflow: hidden;
@@ -906,6 +974,11 @@ export default {
cursor: default;
}
.tab-btn:hover:not(.active) {
color: var(--primaryColor);
transform: scale(1.02);
}
.tab-visualizer {
position: relative;
display: flex;
+6 -33
View File
@@ -87,7 +87,7 @@ import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import { sortedItems } from "@/utils/sort.ts";
import { findScrollParents, findScroller, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
type ItemInfo = FileListItem & { isDir?: boolean; hasDuration?: boolean };
type ItemInfo = Omit<FileListItem, "metadata"> & { metadata?: MediaMetadata; isDir?: boolean; hasDuration?: boolean };
export interface ListEntry {
name: string;
@@ -98,7 +98,7 @@ export interface ListEntry {
size?: number;
modified?: string;
created?: string;
metadata?: MediaMetadata;
metadata?: unknown;
isDirectory?: boolean;
originalItem?: ItemInfo;
}
@@ -330,8 +330,7 @@ onMounted(async () => {
if (currentSource.value !== state.req.source || props.browsePath) {
void withLoading(() => resourcesApi.fetchFiles(sourceToUse, pathToUse).then(fillOptions));
} else {
const pending = fillOptions(initialReq);
if (pending) void withLoading(() => pending);
fillOptions(initialReq);
}
}
attachScrollListener();
@@ -418,22 +417,7 @@ function resetToShare(newHash: string) {
void withLoading(() => resourcesApi.fetchFilesPublic("/", newHash).then(fillOptions));
}
function fillOptions(req: { source?: string; type?: string; items?: FileListItem[] }): Promise<unknown> | undefined {
// if the path points to a file browse its parent and highlight the item
if (req.type && req.type !== "directory" && path.value !== "/") {
const filePath = path.value;
const parent = `${url.removeLastDir(filePath)}/`;
path.value = parent
const showParent = (parentReq: Parameters<typeof fillOptions>[0]) => {
const pending = fillOptions(parentReq);
selectEntry(filePath);
return pending;
};
if (!props.browseSource && (props.browseShare || getters.isShare())) {
return resourcesApi.fetchFilesPublic(parent, props.browseShare || getters.shareHash()).then(showParent);
}
return resourcesApi.fetchFiles(source.value ?? currentSource.value, parent).then(showParent);
}
function fillOptions(req: { source?: string; items?: FileListItem[] }) {
// Sets the current path and resets the current items.
// Use path (the path we're browsing) instead of req.path (which may be relative)
current.value = path.value;
@@ -478,16 +462,6 @@ function fillOptions(req: { source?: string; type?: string; items?: FileListItem
type: "directory",
});
}
return undefined;
}
function selectEntry(itemPath: string) {
const entry = items.value.find((item) => item.path === itemPath);
if (!entry || entry.type === "directory") return;
selected.value = entry.path;
selectedSource.value = entry.source;
selectedType.value = entry.type;
emit("update:selected", { path: entry.path, source: entry.source, type: entry.type, isValid: true });
}
function sortEntries(entries: ListEntry[]) {
@@ -651,9 +625,8 @@ function isContextItem(item: ListEntry) {
}
function attachScrollListener() {
const prompt = root.value?.closest<HTMLElement>(".floating-window > .card-content");
if (!prompt || !root.value) return;
const el = findScrollParents(root.value).find((node) => node !== prompt && prompt.contains(node)) ?? prompt;
const el = root.value?.closest<HTMLElement>(".floating-window > .card-content");
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
scrollContainer = el;
}
+15 -48
View File
@@ -1,28 +1,23 @@
<template>
<div class="card-content" :class="{ 'embedded-content': embedded }">
<div class="card-content">
<div v-if="loading" class="loading-spinner-wrapper">
<LoadingSpinner size="medium" />
</div>
<template v-else>
<div v-if="!pathExists && baseEntries.length > 0 && !isEditingPath" class="warning-banner">
<!-- Warning banner for missing path -->
<div v-if="!pathExists && !isEditingPath" class="warning-banner">
<i class="material-symbols-outlined">warning</i>
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
<button type="button" class="button button--flat button--blue" @click="startPathReassignment">
{{ $t("messages.reassignPath") }}
</button>
</div>
<div v-if="isEditingPath" :class="{ 'embedded-list': embedded }">
<file-list
@update:selected="updateTempPath"
:browseSource="sourceName"
:browsePath="embedded ? currentPath : null"
:hideDestinationSource="true"
:showFiles="true"
></file-list>
<div v-if="isEditingPath">
<file-list @update:selected="updateTempPath" :browseSource="sourceName" :showFiles="true"></file-list>
</div>
<div v-else>
<PathPickerButton
v-if="!embedded"
class="path-picker"
v-model:path="currentPath"
v-model:source="currentSource"
@@ -32,7 +27,7 @@
:placeholder="$t('sidebar.chooseSource')"
@navigate="onPathPickerNavigate"
/>
<div v-if="!embedded" class="settings-items">
<div class="settings-items">
<ActivityViewerButton class="item" :href="activityViewerHref" />
</div>
<!-- Default behavior banner -->
@@ -43,7 +38,9 @@
}}
<HelpTooltipIcon :text="$t('access.defaultBehaviorDescription')" />
</div>
</div>
<!-- Add Form -->
<div class="form-flex-group">
<ExpandDropdown
v-model="addType"
@@ -73,8 +70,8 @@
</button>
</div>
<!-- Cascade Delete Toggle -->
<div v-if="entries.length > 0" class="cascade-toggle-section settings-items">
<ToggleSwitch class="item" v-model="cascadeDelete"
<div v-if="entries.length > 0" class="cascade-toggle-section">
<ToggleSwitch v-model="cascadeDelete"
:name="$t('access.cascadeDelete')"
:description="$t('access.cascadeDeleteDescription')" />
</div>
@@ -94,7 +91,7 @@
</div>
</template>
</div>
<div class="card-actions" :class="{ 'embedded-actions': embedded }">
<div class="card-actions">
<template v-if="isEditingPath">
<button type="button" class="button button--flat button--grey" @click="cancelPathChange" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
{{ $t("general.cancel") }}
@@ -104,7 +101,7 @@
</button>
</template>
<template v-else>
<button v-if="!embedded" type="button" class="button button--flat button--grey" @click="cancelChanges" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
<button type="button" class="button button--flat button--grey" @click="cancelChanges" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button type="button" class="button button--flat" :disabled="!dirty || saving" @click="saveChanges" :aria-label="$t('general.save')" :title="$t('general.save')">
@@ -146,8 +143,7 @@ export default {
props: {
promptId: { type: [String, Number], default: null },
sourceName: { type: String, required: true },
path: { type: String, required: true, default: "/" },
embedded: { type: Boolean, default: false }
path: { type: String, required: true, default: "/" }
},
data() {
return {
@@ -249,10 +245,6 @@ export default {
entry,
}));
},
parentPath() {
const trimmed = this.currentPath.replace(/\/+$/, "");
return trimmed.slice(0, trimmed.lastIndexOf("/")) || "/";
},
activityViewerHref() {
return activityViewerPresets.access(this.currentSource, this.currentPath);
},
@@ -400,7 +392,6 @@ export default {
async saveChanges() {
if (this.saving) return;
this.saving = true;
let failed = false;
try {
for (const op of [...this.pendingDeletes]) {
await accessApi.del(this.currentSource, this.currentPath, {
@@ -420,7 +411,6 @@ export default {
this.pendingAdds.splice(this.pendingAdds.indexOf(op), 1);
}
} catch (e) {
failed = true;
notify.showError(e);
console.error(e);
}
@@ -428,10 +418,7 @@ export default {
// Emit event to refresh access rules list
eventBus.emit('accessRulesChanged');
this.saving = false;
if (!failed && this.embedded) {
notify.showSuccessToast(this.$t("settings.settingsUpdated"));
}
if (!this.dirty && !this.embedded) {
if (!this.dirty) {
mutations.closeTopPrompt(this.promptId ?? undefined);
}
},
@@ -480,26 +467,6 @@ export default {
margin-bottom: 1em;
}
.embedded-content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.embedded-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.embedded-actions {
display: flex;
justify-content: flex-end;
gap: 0.25em;
}
.loading-spinner-wrapper {
display: flex;
align-items: center;
+323 -331
View File
@@ -1,13 +1,18 @@
<template>
<div class="card-content no-buttons prompt-panel info-content">
<div class="info-tabs">
<SlidingTabs :model-value="activeTab" :tabs="tabs" @update:model-value="activeTab = $event as InfoTab" />
<div class="card-content info-content">
<div class="settings-items">
<ActivityViewerButton
v-if="source && !isShare"
:href="activityViewerHref"
/>
</div>
<div class="info-grid" :class="{ 'info-grid--fill': activeTab === 'access' }">
<div class="info-section" v-if="activeTab === 'basic'">
<div class="info-grid">
<!-- Basic Information Section -->
<div class="info-section">
<h3 class="section-title">{{ $t("prompts.basicInfo") }}</h3>
<div class="info-item">
<strong>{{ $t("prompts.displayName") }}</strong>
<span role="group" aria-label="info display name">{{ item.name }}</span>
<span role="group" aria-label="info display name">{{ displayName }}</span>
</div>
<div class="info-item">
<strong>{{ $t("general.size") }}</strong>
@@ -15,7 +20,7 @@
</div>
<div class="info-item">
<strong>{{ $t("general.type") }}</strong>
<span role="group" aria-label="info type">{{ item.type }}</span>
<span role="group" aria-label="info type">{{ type }}</span>
</div>
<div class="info-item" v-if="humanTime">
<strong>{{ $t("files.lastModified") }}</strong>
@@ -33,54 +38,23 @@
<strong>{{ $t("general.path") }}</strong>
<span role="group" aria-label="info path" class="break-word">{{ filePath }}</span>
</div>
<div class="info-item" v-if="item.hidden !== undefined">
<div class="info-item" v-if="hidden !== undefined">
<strong>{{ $t("prompts.hidden") }}</strong>
<span role="group" aria-label="info hidden">{{ item.hidden ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span role="group" aria-label="info hidden">{{ hidden ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="info-item" v-if="item.hasPreview !== undefined">
<div class="info-item" v-if="hasPreview !== undefined">
<strong>{{ $t("prompts.hasPreview") }}</strong>
<span role="group" aria-label="info has preview">{{ item.hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="settings-items" v-if="source && !isShare">
<ActivityViewerButton :href="activityViewerHref" />
<span role="group" aria-label="info has preview">{{ hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
<!-- Media metadata -->
<div class="info-section" v-else-if="activeTab === 'media'">
<div class="info-item" v-for="info in mediaInfo" :key="info.key">
<strong>{{ info.label }}</strong>
<span>{{ info.value }}</span>
</div>
<!-- Lyrics -->
<div class="info-item" v-if="hasLyrics">
<strong>{{ $t("player.lyrics") }}</strong>
<span>
<a class="info-toggle" role="button" tabindex="0" @click="toggleLyrics" @keydown.enter="toggleLyrics">
{{ showLyrics ? $t("general.hide") : $t("general.show") }}
</a>
<span v-if="showLyrics" class="info-multiline">{{ lyricsText }}</span>
</span>
</div>
<!-- Subtitles -->
<div class="info-item" v-for="(track, idx) in subtitleTracks" :key="track.name">
<strong>{{ $t("prompts.subtitle", { n: idx + 1 }) }}</strong>
<span>
{{ subtitleLabel(track) }}
<a class="info-toggle" role="button" tabindex="0" @click="toggleSubtitle(track)" @keydown.enter="toggleSubtitle(track)">
{{ subtitleContent[track.name] !== undefined ? $t("general.hide") : $t("general.show") }}
</a>
<span v-if="subtitleContent[track.name] !== undefined" class="info-multiline">{{ subtitleContent[track.name] }}</span>
</span>
</div>
<div class="info-item" v-if="!showMediaContent">
<span>{{ $t("general.none") }}</span>
</div>
</div>
<div v-else-if="activeTab === 'quota'" class="info-section">
<!-- Storage quota (admin, folders) -->
<div v-if="showQuotaSection" class="info-section">
<h3 class="section-title">{{ $t("quotas.title") }}</h3>
<div class="settings-items">
<SettingsButton
class="info-manage-link"
:name="t('quotas.title')"
:name="$t('quotas.title')"
:description="$t('quotas.openDescription')"
@click="openQuotaPrompt"
/>
@@ -100,13 +74,43 @@
/>
</div>
</div>
<div class="info-section" v-else-if="activeTab === 'additional'">
<!-- Access rules (admin) -->
<div v-if="showAccessSection" class="info-section settings-items">
<h3 class="section-title">{{ $t("access.rules") }}</h3>
<SettingsButton
class="info-manage-link"
:name="$t('access.accessManagement')"
:description="$t('access.manageDescription')"
@click="openAccessPrompt"
/>
<div class="info-item">
<strong>{{ $t("access.hasRules") }}</strong>
<span>{{ hasAccessRules ? $t("general.yes") : $t("general.no") }}</span>
</div>
<div v-if="accessRuleEntries.length" class="access-rules-list">
<div
v-for="entry in accessRuleEntries"
:key="accessEntryKey(entry)"
class="info-item access-rule-entry"
>
<strong>{{ entry.allow ? $t("access.allow") : $t("access.deny") }}</strong>
<span>{{ accessEntryLabel(entry) }}</span>
</div>
</div>
</div>
<!-- Additional Information Section -->
<div class="info-section" v-if="additionalInfo.length > 0">
<h3 class="section-title">{{ $t("prompts.additionalInfo") }}</h3>
<div class="info-item" v-for="info in additionalInfo" :key="info.key">
<strong>{{ info.label }}</strong>
<span>{{ info.value }}</span>
</div>
</div>
<div class="info-section" v-else-if="activeTab === 'checksums'">
<!-- Hash Generator Section -->
<div class="info-section" v-if="!dir">
<h3 class="section-title">{{ $t("prompts.checksums") }}</h3>
<div class="hash-generator">
<div class="hash-select">
<label for="hash-algo">{{ $t("prompts.hashAlgorithm") }}</label>
@@ -124,6 +128,7 @@
</button>
</div>
</div>
<div class="hash-result">
<label for="hash-result">{{ $t("prompts.hashValue") }}</label>
<div class="form-flex-group">
@@ -132,7 +137,7 @@
<button
type="button"
class="button form-button flat-left"
@click="copyHash"
@click="copyToClipboard"
:disabled="!hashResult"
:title="$t('buttons.copyToClipboard')"
:aria-label="$t('buttons.copyToClipboard')"
@@ -143,300 +148,271 @@
</div>
</div>
</div>
<div v-if="accessVisited" v-show="activeTab === 'access'" class="info-section">
<Access embedded :source-name="source" :path="filePath" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
<script>
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp, fromNow } from "@/utils/moment";
import { copyToClipboard } from "@/utils/clipboard";
import { formatDuration } from "@/utils/files";
import { resourcesApi, quotasApi, mediaApi } from "@/api";
import { resourcesApi, quotasApi, accessApi } from "@/api";
import { getters, mutations, state } from "@/store";
import type { FileListItem, SubtitleTrack } from "@/store/types.ts";
import { notify } from "@/notify";
import { activityViewerPresets } from "@/utils/activityViewerLink";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
import ActivityViewerButton from "@/components/settings/ActivityViewerButton.vue";
import SettingsButton from "@/components/settings/SettingsButton.vue";
import QuotaFolderBar from "@/components/prompts/QuotaFolderBar.vue";
import Access from "@/components/prompts/Access.vue";
type InfoTab = "basic" | "media" | "quota" | "access" | "additional" | "checksums";
type HashAlgo = "md5" | "sha1" | "sha256" | "sha512";
interface InfoItem extends FileListItem {
subtitles?: SubtitleTrack[];
token?: string;
hash?: string;
onlyOfficeId?: string;
}
interface QuotaSnapshot {
usedBytes?: number;
reservedBytes?: number;
measurementStatus?: string;
limitBytes?: number;
}
interface InfoRow {
key: string;
label: string;
value: string | number;
}
const props = defineProps<{
item: InfoItem;
promptId?: string | number | null;
}>();
const { t } = useI18n();
const hashAlgoOptions: { value: HashAlgo; label: string }[] = [
{ value: "md5", label: "MD5" },
{ value: "sha1", label: "SHA1" },
{ value: "sha256", label: "SHA256" },
{ value: "sha512", label: "SHA512" },
];
const activeTab = ref<InfoTab>("basic");
const accessVisited = ref(false);
const selectedHashAlgo = ref<HashAlgo>("md5");
const hashResult = ref("");
const generatingHash = ref(false);
const quotaSnapshot = ref<QuotaSnapshot>({});
const quotaExists = ref(false);
const lyricsText = ref("");
const lyricsLoading = ref(false);
const showLyrics = ref(false);
// Subtitle content by track name
const subtitleContent = ref<Record<string, string>>({});
// Listing items carry no subtitles, they're fetched when the tab opens
const mediaDetails = ref<InfoItem | null>(null);
let mediaRequested = false;
const isShare = getters.isShare();
const source = computed(() => props.item.source || "");
const filePath = computed(() => props.item.path || "");
const isDir = computed(() => props.item.type === "directory");
const isAudio = computed(() => !!props.item.type?.startsWith("audio"));
const isVideo = computed(() => !!props.item.type?.startsWith("video"));
const isAdmin = computed(() => getters.isAdmin());
const humanSize = computed(() => getHumanReadableFilesize(props.item.size || 0));
const humanTime = computed(() => formatDate(props.item.modified));
const modTime = computed(() => relativeDate(props.item.modified));
const humanCreatedTime = computed(() => formatDate(props.item.created));
const createdTime = computed(() => relativeDate(props.item.created));
const activityViewerHref = computed(() => activityViewerPresets.files(source.value, filePath.value));
const additionalInfo = computed<InfoRow[]>(() => {
const { token, hash, onlyOfficeId } = props.item;
const rows: [string, string, string | undefined][] = [
["token", t("prompts.token"), token],
["hash", t("general.hash"), hash],
["onlyOfficeId", t("prompts.onlyOfficeId"), onlyOfficeId],
];
return rows.flatMap(([key, label, value]) => (value ? [{ key, label, value }] : []));
});
// the fresh response wins over the (older) listing metadata
const mediaMetadata = computed(() => mediaDetails.value?.metadata || props.item.metadata);
const hasLyrics = computed(() => isAudio.value && !!mediaMetadata.value?.hasLyrics);
const subtitleTracks = computed<SubtitleTrack[]>(() =>
isVideo.value ? props.item.subtitles || mediaDetails.value?.subtitles || [] : []
);
const mediaInfo = computed<InfoRow[]>(() => {
const meta = mediaMetadata.value;
if (!meta || !(isAudio.value || isVideo.value)) return [];
const rows: [string, string, string | number | undefined][] = [
["title", t("prompts.mediaTitle"), meta.title],
["artist", t("prompts.artist"), meta.artist],
["album", t("prompts.album"), meta.album],
["year", t("prompts.year"), meta.year],
["genre", t("prompts.genre"), meta.genre],
["track", t("prompts.track"), meta.track],
["duration", t("files.duration"), meta.duration ? formatDuration(meta.duration) : ""],
["container", t("prompts.container"), meta.container],
["videoCodec", t("prompts.videoCodec"), meta.videoCodec],
["audioCodec", t("prompts.audioCodec"), meta.audioCodec],
];
return rows.flatMap(([key, label, value]) => (value ? [{ key, label, value }] : []));
});
const showMediaContent = computed(
() => mediaInfo.value.length > 0 || hasLyrics.value || subtitleTracks.value.length > 0
);
const showQuotaSection = computed(
() => isAdmin.value && !isShare && isDir.value && !!source.value && !!filePath.value
);
const showAccessSection = computed(() => isAdmin.value && !isShare && !!source.value && !!filePath.value);
const quotaLimitBytes = computed(() => quotaSnapshot.value.limitBytes || 0);
const quotaEnabled = computed(() => quotaExists.value && quotaLimitBytes.value > 0);
const tabs = computed(() => {
const list: { id: InfoTab; label: string }[] = [{ id: "basic", label: t("prompts.basicInfo") }];
if (isAudio.value || isVideo.value) list.push({ id: "media", label: t("prompts.mediaInfo") });
if (showQuotaSection.value) list.push({ id: "quota", label: t("quotas.title") });
if (showAccessSection.value) list.push({ id: "access", label: t("access.rules") });
if (additionalInfo.value.length > 0) list.push({ id: "additional", label: t("prompts.additionalInfo") });
if (!isDir.value) list.push({ id: "checksums", label: t("prompts.checksums") });
return list;
});
watch(activeTab, (tab) => {
if (tab === "access") accessVisited.value = true;
if (tab === "media" && !mediaRequested) {
mediaRequested = true;
void loadMediaDetails();
}
});
onMounted(async () => {
if (showQuotaSection.value) await loadQuota();
});
function formatDate(date?: string) {
return date ? formatTimestamp(date, state.user.locale) : "";
}
function relativeDate(date?: string) {
return date ? fromNow(date, state.user.locale) : "";
}
async function loadMediaDetails() {
const { item } = props;
if (!(isAudio.value || isVideo.value)) return;
if (item.metadata && (!isVideo.value || item.subtitles)) return;
try {
const data = (await mediaApi.getItemMediaMetadata(filePath.value, { source: source.value })) as InfoItem;
if (data && data.type !== "directory") {
mediaDetails.value = data;
export default {
name: "info",
components: {
ExpandDropdown,
ActivityViewerButton,
SettingsButton,
QuotaFolderBar,
},
props: {
promptId: {
type: [String, Number],
default: null,
},
item: {
type: Object,
required: true,
},
},
data() {
return {
selectedHashAlgo: "md5",
hashResult: "",
generatingHash: false,
quotaSnapshot: {},
quotaExists: false,
accessRule: {
denyAll: false,
deny: { users: [], groups: [] },
allow: { users: [], groups: [] },
},
};
},
async mounted() {
if (this.showQuotaSection) {
await this.loadQuota();
}
} catch (err) {
console.warn("Failed to fetch media details:", err);
}
}
// Raw lyrics (sidecar or embedded) are fetched on first show
async function toggleLyrics() {
if (lyricsLoading.value) return;
if (showLyrics.value) {
showLyrics.value = false;
return;
}
if (!lyricsText.value) {
lyricsLoading.value = true;
try {
const { lyrics } = await mediaApi.getLyrics(source.value, filePath.value);
lyricsText.value = lyrics;
} catch (err) {
console.warn("Failed to fetch lyrics:", err);
} finally {
lyricsLoading.value = false;
if (this.showAccessSection) {
await this.loadAccessRules();
}
}
showLyrics.value = !!lyricsText.value;
}
},
computed: {
hashAlgoOptions() {
return [
{ value: "md5", label: "MD5" },
{ value: "sha1", label: "SHA1" },
{ value: "sha256", label: "SHA256" },
{ value: "sha512", label: "SHA512" },
];
},
humanSize() {
return getHumanReadableFilesize(this.item?.size || 0);
},
humanTime() {
if (!this.item?.modified) return "";
return formatTimestamp(this.item.modified, state.user.locale);
},
modTime() {
if (!this.item?.modified) return "";
return fromNow(this.item.modified, state.user.locale);
},
humanCreatedTime() {
if (!this.item?.created) return "";
return formatTimestamp(this.item.created, state.user.locale);
},
createdTime() {
if (!this.item?.created) return "";
return fromNow(this.item.created, state.user.locale);
},
name() {
return this.item?.name || "";
},
type() {
return this.item?.type || "";
},
displayName() {
return this.item?.name || "";
},
dir() {
return this.item?.type === "directory";
},
source() {
return this.item?.source || "";
},
isShare() {
return getters.isShare();
},
filePath() {
return this.item?.path || "";
},
activityViewerHref() {
return activityViewerPresets.files(this.source, this.filePath);
},
hidden() {
return this.item?.hidden;
},
hasPreview() {
return this.item?.hasPreview;
},
additionalInfo() {
const info = [];
function subtitleLabel(track: SubtitleTrack) {
const extras = [track.language, track.codec].filter(Boolean).join(", ");
return track.embedded ? extras : [track.name, extras && `(${extras})`].filter(Boolean).join(" ");
}
if (this.item?.token) {
info.push({ key: "token", label: this.$t("prompts.token"), value: this.item.token });
}
if (this.item?.hash) {
info.push({ key: "hash", label: this.$t("general.hash"), value: this.item.hash });
}
if (this.item?.onlyOfficeId) {
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: this.item.onlyOfficeId });
}
// Raw subtitle content are fetched on first show
async function toggleSubtitle(track: SubtitleTrack) {
if (track.name in subtitleContent.value) {
delete subtitleContent.value[track.name];
return;
}
try {
const content: string = await mediaApi.getSubtitleContent(source.value, filePath.value, track.name, track.embedded);
if (content) {
subtitleContent.value[track.name] = content;
}
} catch (err) {
console.warn("Failed to fetch subtitle:", err);
}
}
return info;
},
isAdmin() {
return getters.isAdmin();
},
showQuotaSection() {
return this.isAdmin && !this.isShare && this.dir && this.source && this.filePath;
},
showAccessSection() {
return this.isAdmin && !this.isShare && this.source && this.filePath;
},
quotaEnabled() {
return this.quotaExists && (this.quotaSnapshot.limitBytes || 0) > 0;
},
quotaLimitBytes() {
return this.quotaSnapshot.limitBytes || 0;
},
accessRuleEntries() {
/** @type {{allow: boolean, type: "user" | "group" | "all", name: string}[]} */
const entries = [];
if (this.accessRule.denyAll) {
entries.push({ allow: false, type: "all", name: this.$t("access.all") });
}
for (const name of this.accessRule.deny?.users || []) {
entries.push({ allow: false, type: "user", name });
}
for (const name of this.accessRule.deny?.groups || []) {
entries.push({ allow: false, type: "group", name });
}
for (const name of this.accessRule.allow?.users || []) {
entries.push({ allow: true, type: "user", name });
}
for (const name of this.accessRule.allow?.groups || []) {
entries.push({ allow: true, type: "group", name });
}
return entries;
},
hasAccessRules() {
return this.accessRuleEntries.length > 0;
},
},
methods: {
async loadQuota() {
try {
const raw = await quotasApi.get(this.source, this.filePath);
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data) return;
this.quotaSnapshot = {
usedBytes: data.usedBytes,
reservedBytes: data.reservedBytes,
measurementStatus: data.measurementStatus,
limitBytes: data.limitBytes,
};
if ((data.limitBytes || 0) > 0) {
this.quotaExists = true;
}
} catch {
// no quota or preview unavailable
}
},
async loadAccessRules() {
try {
const response = await accessApi.get(this.source, this.filePath);
this.accessRule = response;
} catch {
this.accessRule = {
denyAll: false,
deny: { users: [], groups: [] },
allow: { users: [], groups: [] },
};
}
},
openQuotaPrompt() {
mutations.showPrompt({
name: "Quota",
props: {
item: this.item,
source: this.source,
path: this.filePath,
},
});
},
openAccessPrompt() {
mutations.showPrompt({
name: "access",
props: {
sourceName: this.source,
path: this.filePath,
},
});
},
accessEntryKey(entry) {
return `${entry.type}-${entry.name}-${entry.allow}`;
},
accessEntryLabel(entry) {
if (entry.type === "user") {
return `${this.$t("general.user")}: ${entry.name}`;
}
if (entry.type === "group") {
return `${this.$t("general.group")}: ${entry.name}`;
}
return entry.name;
},
async generateHash() {
if (this.generatingHash || !this.item) return;
async function loadQuota() {
try {
const raw = await quotasApi.get(source.value, filePath.value);
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data) return;
const { usedBytes, reservedBytes, measurementStatus, limitBytes } = data;
quotaSnapshot.value = { usedBytes, reservedBytes, measurementStatus, limitBytes };
quotaExists.value = (limitBytes || 0) > 0;
} catch {
// no quota or preview unavailable
}
}
this.hashResult = "";
this.generatingHash = true;
function openQuotaPrompt() {
mutations.showPrompt({
name: "Quota",
props: { item: props.item, source: source.value, path: filePath.value },
});
}
try {
const source = this.item.source;
const path = this.item.path;
async function generateHash() {
if (generatingHash.value) return;
hashResult.value = "";
generatingHash.value = true;
try {
hashResult.value = (await resourcesApi.checksum(source.value, filePath.value, selectedHashAlgo.value)) as string;
} catch (err) {
hashResult.value = t("prompts.errorGeneratingHash");
notify.showError(err instanceof Error ? err.message : "Error generating hash");
} finally {
generatingHash.value = false;
}
}
async function copyHash() {
if (hashResult.value) await copyToClipboard(hashResult.value);
}
const hash = await resourcesApi.checksum(source, path, this.selectedHashAlgo);
this.hashResult = hash;
} catch (err) {
this.hashResult = this.$t("prompts.errorGeneratingHash");
const errorMessage = err instanceof Error ? err.message : "Error generating hash";
notify.showError(errorMessage);
} finally {
this.generatingHash = false;
}
},
async copyToClipboard() {
if (!this.hashResult) return;
await copyToClipboard(this.hashResult);
},
},
};
</script>
<style scoped>
.info-content {
gap: 0.5em;
}
.info-tabs {
flex-shrink: 0;
padding: 0 0.5em 0.5em;
border-bottom: 1px solid var(--divider);
}
.info-toggle {
color: var(--primaryColor);
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
cursor: pointer;
}
.info-toggle:hover {
text-decoration: underline;
}
.info-multiline {
display: block;
margin-top: 0.5em;
max-height: 16em;
overflow: auto;
white-space: pre-wrap;
user-select: text;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.info-manage-link {
@@ -447,18 +423,27 @@ async function copyHash() {
padding: 0.5em;
}
.info-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: start;
gap: 1.5em;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
.access-rules-list {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.info-grid--fill {
grid-template-rows: minmax(0, 1fr);
.access-rule-entry strong {
min-width: 72px;
}
.info-description {
margin-bottom: 1.5em;
color: var(--textSecondary);
line-height: 1.5;
text-align: center;
}
.info-grid {
display: grid;
gap: 1.5em;
flex: 1;
}
.info-section {
@@ -467,8 +452,14 @@ async function copyHash() {
gap: 0.5em;
}
.info-grid--fill > .info-section {
min-height: 0;
.section-title {
font-size: 0.95em;
font-weight: 600;
color: var(--textPrimary);
margin: 0 0 0.75em;
padding-bottom: 0.5em;
padding-top: 0.5em;
border-bottom: 1px solid var(--divider);
}
.info-item {
@@ -524,9 +515,10 @@ async function copyHash() {
font-family: monospace;
}
/* Responsive adjustments */
@media (width <= 768px) {
.info-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: 1fr;
}
.info-item strong {
min-width: 100px;
@@ -73,6 +73,7 @@
</div>
</div>
</div>
<!-- Empty state -->
<div v-else class="empty">
<i class="material-symbols">queue_music</i>
@@ -81,7 +82,33 @@
</div>
<div class="card-actions">
<SlidingTabs icon-only :tabs="modeTabs" :model-value="playbackMode" @update:model-value="setMode" />
<div class="mode-buttons">
<div class="mode-indicator" :style="modeIndicatorStyle"></div>
<input
type="radio"
id="mode-sequential"
name="playback-mode"
value="sequential"
:checked="playbackMode === 'sequential'"
@change="setMode('sequential')"
hidden
/>
<label for="mode-sequential" class="mode-btn" :class="{ active: playbackMode === 'sequential' }" :title="$t('player.PlayAllOncePlayback')">
<i class="material-symbols">playlist_play</i>
</label>
<input
type="radio"
id="mode-shuffle"
name="playback-mode"
value="shuffle"
:checked="playbackMode === 'shuffle'"
@change="setMode('shuffle')"
hidden
/>
<label for="mode-shuffle" class="mode-btn" :class="{ active: playbackMode === 'shuffle' }" :title="$t('player.ShuffleAllPlayback')">
<i class="material-symbols">shuffle</i>
</label>
</div>
<button
type="button"
class="repeat-one-btn"
@@ -113,7 +140,6 @@ import {
formatArtist
} from '@/utils/playbackQueue.js';
import Icon from "@/components/files/Icon.vue";
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
import { resourcesApi } from "@/api";
import { globalVars } from "@/utils/constants";
import { formatDuration, getTypeFromMime, removeExtension } from "@/utils/files.js";
@@ -166,10 +192,14 @@ const loopIcon = computed(() => getLoopIcon(loop.value));
const loopLabel = computed(() => getLoopLabel(loop.value, t));
const isPlaying = computed(() => state.playbackQueue.isPlaying || false);
const isPromptVisible = computed(() => state.prompts.some(prompt => prompt.name === 'PlaybackQueue'));
const modeTabs = computed(() => [
{ id: 'sequential', icon: 'playlist_play', title: t('player.PlayAllOncePlayback') },
{ id: 'shuffle', icon: 'shuffle', title: t('player.ShuffleAllPlayback') },
]);
const modeIndicatorStyle = computed(() => {
const modes = ['sequential', 'shuffle'];
const index = modes.indexOf(playbackMode.value);
if (index === -1) return {};
const width = `${100 / modes.length}%`;
const left = `${index * (100 / modes.length)}%`;
return { left, width };
});
// Only the rendered rows are formatted, with their position in the queue
const visibleQueue = computed(() =>
@@ -272,7 +302,7 @@ function getThumbnailUrl(item: QueueItem) {
return resourcesApi.getPreviewURL(source, path, item.modified);
}
function setMode(mode: string) {
function setMode(mode: 'sequential' | 'shuffle') {
if (mode === playbackMode.value) return;
const listing = state.navigation.listing || state.req?.parentDirItems || [];
cyclePlaybackModes(playbackMode.value, {
@@ -519,6 +549,7 @@ function updatePromptTitle() {
color: white;
}
.mode-btn i,
.repeat-one-btn i {
font-size: 1.5rem;
}
@@ -634,12 +665,35 @@ function updatePromptTitle() {
user-select: none;
}
.mode-buttons {
display: flex;
position: relative;
background: var(--surfaceSecondary);
border-radius: 2em;
overflow: hidden;
padding: 0;
flex-shrink: 0;
}
.mode-indicator {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: var(--primaryColor);
border-radius: inherit;
transition: left 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
z-index: 0;
}
.mode-btn,
.repeat-one-btn {
background: transparent;
border: none;
border-radius: 2em;
border-radius: 0;
padding: 0.35rem 0.75rem;
color: var(--textSecondary);
cursor: pointer;
font-size: inherit;
transition: background 0.2s, color 0.2s, transform 0.2s;
@@ -649,6 +703,28 @@ function updatePromptTitle() {
justify-content: center;
}
.mode-btn {
flex: 1;
border-radius: 0;
color: var(--textSecondary);
z-index: 1;
position: relative;
}
.mode-btn.active {
color: white;
}
.mode-btn:hover:not(.active) {
color: var(--primaryColor);
transform: scale(1.02);
}
.repeat-one-btn {
border-radius: 2em;
color: var(--textSecondary);
}
.repeat-one-btn.active {
background: var(--primaryColor);
color: white;
@@ -1,140 +0,0 @@
<template>
<div
class="sliding-tabs"
:class="{ 'sliding-tabs--icon-only': iconOnly }"
:role="iconOnly ? 'radiogroup' : 'tablist'"
:style="{ '--tab-count': tabs.length, '--tab-index': activeIndex }"
>
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="sliding-tab"
:class="{ active: tab.id === modelValue }"
:role="iconOnly ? 'radio' : 'tab'"
:aria-selected="iconOnly ? undefined : tab.id === modelValue"
:aria-checked="iconOnly ? tab.id === modelValue : undefined"
:aria-label="tab.label ? undefined : tab.title"
:title="tab.title ?? tab.label"
@click="emit('update:modelValue', tab.id)"
>
<i v-if="tab.icon" class="material-symbols">{{ tab.icon }}</i>
<span v-if="tab.label" class="sliding-tab-label">{{ tab.label }}</span>
<span v-if="tab.suffix" class="sliding-tab-suffix">{{ tab.suffix }}</span>
</button>
<div v-if="activeIndex >= 0" class="sliding-tabs-indicator" aria-hidden="true"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
export interface SlidingTab {
id: string;
/** Text of the tab. iconOnly tabs omit it and use only title */
label?: string;
icon?: string;
suffix?: string;
title?: string;
}
const props = withDefaults(
defineProps<{
tabs: SlidingTab[];
modelValue: string;
iconOnly?: boolean;
}>(),
{ iconOnly: false }
);
const emit = defineEmits<{ "update:modelValue": [id: string] }>();
const activeIndex = computed(() => props.tabs.findIndex((tab) => tab.id === props.modelValue));
</script>
<style scoped>
.sliding-tabs {
--tab-gap: 0.5em;
--tab-count: 1; /* set inline, from the tabs */
--tab-index: 0; /* set inline, from the active tab */
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: var(--tab-gap);
}
.sliding-tabs--icon-only {
--tab-gap: 0px;
background: var(--surfaceSecondary);
border-radius: 2em;
overflow: hidden;
}
.sliding-tab {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
min-width: 0;
padding: 0.5em;
border: none;
border-radius: var(--borderRadius);
background: transparent;
color: var(--textSecondary);
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
user-select: none;
transition: color 0.2s ease, transform 0.2s ease;
}
.sliding-tab > i,
.sliding-tab-suffix {
flex-shrink: 0;
}
.sliding-tabs--icon-only .sliding-tab {
padding: 0.35rem 0.75rem;
border-radius: 0;
}
.sliding-tabs--icon-only .sliding-tab > i {
font-size: 1.5rem;
}
.sliding-tab.active {
color: white;
}
.sliding-tab:hover:not(.active) {
color: var(--primaryColor);
transform: scale(1.02);
}
.sliding-tab-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.sliding-tabs-indicator {
position: absolute;
inset: 0 auto 0 0;
z-index: 0;
width: calc((100% - (var(--tab-count) - 1) * var(--tab-gap)) / var(--tab-count));
transform: translateX(calc(var(--tab-index) * (100% + var(--tab-gap))));
border-radius: inherit;
background: var(--primaryColor);
pointer-events: none;
transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.sliding-tabs:not(.sliding-tabs--icon-only) .sliding-tabs-indicator {
border-radius: var(--borderRadius);
}
</style>
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "يجب أن تكون كلمة المرور الجديدة مختلفة عن كلمة المرور الحالية"
},
"prompts": {
"mediaInfo": "معلومات الوسائط",
"mediaTitle": "العنوان",
"subtitle": "الترجمة {n}",
"artist": "الفنان",
"album": "الألبوم",
"year": "السنة",
"genre": "النوع",
"track": "المقطع",
"container": "الحاوية",
"videoCodec": "ترميز الفيديو",
"audioCodec": "ترميز الصوت",
"noTemplate": "قالبك فارغ!",
"newFileTemplate": "قالب الملف الجديد",
"newFileTemplateMessage": "أضف أسماء ملفات جديدة هنا، سيتم استخدامها كقالب لإنشاء ملفات جديدة في قائمة السياق!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "كلمة المرور الخاصة بك مطلوبة لإنشاء رمز المصادقة."
},
"general": {
"show": "إظهار",
"hide": "إخفاء",
"import": "استيراد",
"export": "تصدير",
"name": "الاسم",
@@ -689,6 +676,7 @@
"allow": "السماح",
"allowDeny": "السماح/الرفض",
"userGroup": "المستخدم/المجموعة",
"accessManagement": "إدارة الوصول",
"totalDenied": "مرفوض",
"totalAllowed": "المسموح به",
"all": "رفض الكل",
@@ -696,6 +684,8 @@
"defaultBehavior": "الإجراء الافتراضي{suffix}",
"cascadeDelete": "الحذف التكراري",
"cascadeDeleteDescription": "عند التمكين، سيؤدي حذف قاعدة وصول إلى إزالتها أيضًا من جميع الدلائل الفرعية. يتيح لك ذلك إزالة مستخدم أو مجموعة بالكامل من شجرة الدليل بأكملها.",
"hasRules": "تم تكوين قواعد الوصول",
"manageDescription": "قواعد الوصول المفتوح لهذا المسار.",
"newGroup": "مجموعة جديدة",
"editGroup": "تعديل المجموعة",
"groupName": "اسم المجموعة",
+3 -13
View File
@@ -9,8 +9,6 @@
"noCreateOptions": "Няма налични опции за създаване"
},
"general": {
"show": "Показване",
"hide": "Скриване",
"import": "Импортиране",
"export": "Експортиране",
"to": "до{suffix}",
@@ -415,17 +413,6 @@
"shareDefaultsDescription": "Конфигурирайте опциите по подразбиране за нови споделени ресурси и наложете правилата за потребители, които не са администратори."
},
"prompts": {
"mediaInfo": "Информация за медията",
"mediaTitle": "Заглавие",
"subtitle": "Субтитри {n}",
"artist": "Изпълнител",
"album": "Албум",
"year": "Година",
"genre": "Жанр",
"track": "Песен",
"container": "Контейнер",
"videoCodec": "Видео кодек",
"audioCodec": "Аудио кодек",
"noTemplate": "Вашият шаблон е празен!",
"newFileTemplate": "Шаблон за нов файл",
"newFileTemplateMessage": "Добавете нови имена на файлове тук, те ще се използват като шаблон за създаване на нови файлове в контекстното меню!",
@@ -802,10 +789,13 @@
"allow": "Разрешаване",
"allowDeny": "Разрешаване/Забрана",
"userGroup": "Потребител/Група",
"accessManagement": "Управление на достъпа",
"defaultBehavior": "Действие по подразбиране{suffix}",
"defaultBehaviorDescription": "Това показва какво се случва, когато потребител се опита да получи достъп до път, където няма специфични правила за достъп. 'Разрешаване' означава, че потребителите могат да имат достъп по подразбиране, 'Забрана' означава, че потребителите са блокирани по подразбиране, освен ако не са изрично разрешени.",
"cascadeDelete": "Рекурсивно изтриване",
"cascadeDeleteDescription": "Когато е включено, изтриването на правило за достъп ще го премахне и от всички подпапки. Това ви позволява напълно да премахнете потребител или група от цялото дърво на папките.",
"hasRules": "Конфигурирани правила за достъп",
"manageDescription": "Правила за свободен достъп за този маршрут.",
"newGroup": "Нова група",
"editGroup": "Редактиране на група",
"groupName": "Име на групата",
+3 -13
View File
@@ -227,17 +227,6 @@
"shareDefaultsDescription": "Nakonfigurujte výchozí nastavení sdílených složek pro nové sdílené složky a vynuťte dodržování zásad u uživatelů bez oprávnění správce."
},
"prompts": {
"mediaInfo": "Informace o médiu",
"mediaTitle": "Název",
"subtitle": "Titulky {n}",
"artist": "Interpret",
"album": "Album",
"year": "Rok",
"genre": "Žánr",
"track": "Stopa",
"container": "Kontejner",
"videoCodec": "Video kodek",
"audioCodec": "Audio kodek",
"noTemplate": "Vaše šablona je prázdná!",
"newFileTemplate": "Šablona nového souboru",
"newFileTemplateMessage": "Zde přidejte nové názvy souborů, budou použity jako šablona pro vytváření nových souborů v kontextové nabídce!",
@@ -615,8 +604,6 @@
"orange": "oranžová"
},
"general": {
"show": "Zobrazit",
"hide": "Skrýt",
"import": "Importovat",
"export": "Exportovat",
"name": "Název",
@@ -801,11 +788,14 @@
"allow": "Povolit",
"allowDeny": "Povolit/odmítnout",
"userGroup": "Uživatel/skupina",
"accessManagement": "Správa přístupu",
"all": "Odmítnout vše",
"defaultBehaviorDescription": "To ukazuje, co se stane, když se uživatel pokusí získat přístup k cestě, kde neexistují žádná specifická pravidla přístupu. \"Povolit\" znamená, že uživatelé mají ve výchozím nastavení přístup, \"Odmítnout\" znamená, že uživatelé jsou ve výchozím nastavení blokováni, pokud není výslovně povoleno.",
"defaultBehavior": "Výchozí akce{suffix}",
"cascadeDelete": "Rekurzivní mazání",
"cascadeDeleteDescription": "Pokud je tato možnost povolena, odstraní se pravidlo přístupu také ze všech podadresářů. To umožňuje zcela odstranit uživatele nebo skupinu z celého adresářového stromu.",
"hasRules": "Nastavená pravidla přístupu",
"manageDescription": "Pravidla otevřeného přístupu pro tuto trasu.",
"newGroup": "Nová skupina",
"editGroup": "Upravit skupinu",
"groupName": "Název skupiny",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Das neue Passwort muss sich von Ihrem aktuellen Passwort unterscheiden."
},
"prompts": {
"mediaInfo": "Medieninformationen",
"mediaTitle": "Titel",
"subtitle": "Untertitel {n}",
"artist": "Künstler",
"album": "Album",
"year": "Jahr",
"genre": "Genre",
"track": "Titel",
"container": "Container",
"videoCodec": "Video-Codec",
"audioCodec": "Audio-Codec",
"noTemplate": "Ihre Vorlage ist leer!",
"newFileTemplate": "Vorlage für neue Dateien",
"newFileTemplateMessage": "Fügen Sie hier neue Dateinamen hinzu, diese werden als Vorlage für die Erstellung neuer Dateien im Kontextmenü verwendet!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Ihr Passwort ist erforderlich, um einen Authentifizierungscode zu generieren."
},
"general": {
"show": "Anzeigen",
"hide": "Ausblenden",
"import": "Importieren",
"export": "Exportieren",
"name": "Name",
@@ -689,6 +676,7 @@
"allow": "Zulassen",
"allowDeny": "Zulassen/Verweigern",
"userGroup": "Benutzer/Gruppe",
"accessManagement": "Zugangsverwaltung",
"totalDenied": "Verweigert",
"totalAllowed": "Erlaubt",
"all": "Alle ablehnen",
@@ -696,6 +684,8 @@
"defaultBehavior": "Standard-Aktion{suffix}",
"cascadeDelete": "Rekursives Löschen",
"cascadeDeleteDescription": "Wenn diese Funktion aktiviert ist, wird beim Löschen einer Zugriffsregel diese auch aus allen Unterverzeichnissen entfernt. Auf diese Weise können Sie einen Benutzer oder eine Gruppe vollständig aus dem gesamten Verzeichnisbaum entfernen.",
"hasRules": "Zugriffsregeln konfiguriert",
"manageDescription": "Zugangsregeln für diesen Pfad.",
"newGroup": "Neue Gruppe",
"editGroup": "Gruppe bearbeiten",
"groupName": "Gruppenname",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Ο νέος κωδικός πρόσβασης πρέπει να διαφέρει από τον τρέχοντα κωδικό πρόσβασής σας"
},
"prompts": {
"mediaInfo": "Πληροφορίες πολυμέσων",
"mediaTitle": "Τίτλος",
"subtitle": "Υπότιτλος {n}",
"artist": "Καλλιτέχνης",
"album": "Άλμπουμ",
"year": "Έτος",
"genre": "Είδος",
"track": "Κομμάτι",
"container": "Περιέκτης",
"videoCodec": "Κωδικοποιητής βίντεο",
"audioCodec": "Κωδικοποιητής ήχου",
"noTemplate": "Το πρότυπό σας είναι κενό!",
"newFileTemplate": "Πρότυπο νέου αρχείου",
"newFileTemplateMessage": "Προσθέστε νέα ονόματα αρχείων εδώ, θα χρησιμοποιηθούν ως πρότυπο για τη δημιουργία νέων αρχείων στο μενού περιβάλλοντος!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Ο κωδικός πρόσβασής σας απαιτείται για τη δημιουργία ενός κωδικού ελέγχου ταυτότητας."
},
"general": {
"show": "Εμφάνιση",
"hide": "Απόκρυψη",
"import": "Εισαγωγή",
"export": "Εξαγωγή",
"name": "Όνομα",
@@ -689,6 +676,7 @@
"allow": "Επιτρέψτε το",
"allowDeny": "Επιτρέψτε/απορρίψτε",
"userGroup": "Χρήστης/Ομάδα",
"accessManagement": "Διαχείριση πρόσβασης",
"totalDenied": "Απορρίπτεται",
"totalAllowed": "Επιτρεπόμενο",
"all": "Άρνηση όλων",
@@ -696,6 +684,8 @@
"defaultBehavior": "Προεπιλεγμένη ενέργεια{suffix}",
"cascadeDelete": "Αναδρομική διαγραφή",
"cascadeDeleteDescription": "Όταν είναι ενεργοποιημένη, η διαγραφή ενός κανόνα πρόσβασης θα τον αφαιρέσει επίσης από όλους τους υποκαταλόγους. Αυτό σας επιτρέπει να αφαιρέσετε πλήρως έναν χρήστη ή μια ομάδα από ολόκληρο το δέντρο καταλόγων.",
"hasRules": "Οι κανόνες πρόσβασης έχουν ρυθμιστεί",
"manageDescription": "Κανόνες ανοικτής πρόσβασης για αυτή τη διαδρομή.",
"newGroup": "Νέα ομάδα",
"editGroup": "Επεξεργασία ομάδας",
"groupName": "Όνομα ομάδας",
+3 -13
View File
@@ -9,8 +9,6 @@
"noCreateOptions": "No create options are available"
},
"general": {
"show": "Show",
"hide": "Hide",
"import": "Import",
"export": "Export",
"to": "to{suffix}",
@@ -435,17 +433,6 @@
"deleteFailed": "Failed to delete share"
},
"prompts": {
"mediaInfo": "Media Info",
"mediaTitle": "Title",
"subtitle": "Subtitle {n}",
"artist": "Artist",
"album": "Album",
"year": "Year",
"genre": "Genre",
"track": "Track",
"container": "Container",
"videoCodec": "Video Codec",
"audioCodec": "Audio Codec",
"noTemplate": "Your template is empty!",
"newFileTemplate": "New file template",
"newFileTemplateMessage": "Add new filenames here, they will be used as template for new file creation in the context menu!",
@@ -875,12 +862,15 @@
"refineSearch": "Showing first {count} matches. Refine your search to see more.",
"all": "Deny All",
"rules": "Access rules",
"hasRules": "Access rules configured",
"manageDescription": "Open access rules for this path.",
"deny": "Deny",
"totalDenied": "Denied",
"totalAllowed": "Allowed",
"allow": "Allow",
"allowDeny": "Allow/Deny",
"userGroup": "User/Group",
"accessManagement": "Access Management",
"defaultBehavior": "Default action{suffix}",
"defaultBehaviorDescription": "This shows what happens when a user tries to access a path where no specific access rules exist. 'Allow' means users can access by default, 'Deny' means users are blocked by default unless explicitly allowed.",
"cascadeDelete": "Recursive Delete",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "La nueva contraseña debe ser diferente de tu contraseña actual"
},
"prompts": {
"mediaInfo": "Información multimedia",
"mediaTitle": "Título",
"subtitle": "Subtítulo {n}",
"artist": "Artista",
"album": "Álbum",
"year": "Año",
"genre": "Género",
"track": "Pista",
"container": "Contenedor",
"videoCodec": "Códec de vídeo",
"audioCodec": "Códec de audio",
"noTemplate": "¡Tu plantilla está vacía!",
"newFileTemplate": "Plantilla para nuevos archivos",
"newFileTemplateMessage": "¡Añade nuevos nombres de archivo aquí, se usarán como plantilla para crear nuevos archivos en el menú contextual!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Su contraseña es necesaria para generar un código autenticador."
},
"general": {
"show": "Mostrar",
"hide": "Ocultar",
"import": "Importar",
"export": "Exportar",
"name": "Nombre{suffix}",
@@ -689,6 +676,7 @@
"allow": "Permitir",
"allowDeny": "Permitir/Bloquear",
"userGroup": "Usuario/Grupo",
"accessManagement": "Gestión de accesos",
"totalDenied": "Bloqueados",
"totalAllowed": "Permitidos",
"all": "Bloquear todo",
@@ -696,6 +684,8 @@
"defaultBehavior": "Regla por defecto {suffix}",
"cascadeDelete": "Eliminación en cascada",
"cascadeDeleteDescription": "Si eliminas una regla de acceso con esta opción, también la eliminará para todo el contenido (archivos y carpetas), lo cual nos permite eliminar por completo un usuario o grupo de todo el árbol de directorios",
"hasRules": "Normas de acceso configuradas",
"manageDescription": "Abrir las reglas de acceso de esta ruta.",
"newGroup": "Nuevo grupo",
"editGroup": "Editar grupo",
"groupName": "Nombre del grupo",
+3 -13
View File
@@ -9,8 +9,6 @@
"noCreateOptions": "Ez dago sortzeko aukerarik erabilgarri"
},
"general": {
"show": "Erakutsi",
"hide": "Ezkutatu",
"import": "Inportatu",
"export": "Esportatu",
"to": "-{suffix}",
@@ -435,17 +433,6 @@
"deleteFailed": "Ezin izan da partekatzea ezabatu"
},
"prompts": {
"mediaInfo": "Media informazioa",
"mediaTitle": "Izenburua",
"subtitle": "Azpititulua {n}",
"artist": "Artista",
"album": "Albuma",
"year": "Urtea",
"genre": "Generoa",
"track": "Pista",
"container": "Edukiontzia",
"videoCodec": "Bideo kodeka",
"audioCodec": "Audio kodeka",
"noTemplate": "Txantiloia hutsik dago!",
"newFileTemplate": "Fitxategi berrien txantiloia",
"newFileTemplateMessage": "Gehitu fitxategi-izen berriak hemen; laster-menuan fitxategi berriak sortzeko txantiloi gisa erabiliko dira!",
@@ -835,12 +822,15 @@
"refineSearch": "Lehen {count} emaitzak erakusten. Zehaztu bilaketa gehiago ikusteko.",
"all": "Ukatu denak",
"rules": "Sarbide-arauak",
"hasRules": "Sarbide-arauak konfiguratuta",
"manageDescription": "Ireki bide honen sarbide-arauak.",
"deny": "Ukatu",
"totalDenied": "Ukatuak",
"totalAllowed": "Baimenduak",
"allow": "Baimendu",
"allowDeny": "Baimendu/Ukatu",
"userGroup": "Erabiltzailea/Taldea",
"accessManagement": "Sarbideen kudeaketa",
"defaultBehavior": "Ekintza lehenetsia{suffix}",
"defaultBehaviorDescription": "Hemen ikusten da zer gertatzen den erabiltzaile bat sarbide-arau zehatzik ez duen bide batera sartzen saiatzen denean. 'Baimendu' aukerak esan nahi du erabiltzaileak berez sar daitezkeela; 'Ukatu' aukerak, berriz, erabiltzaileak berez blokeatuta daudela, esplizituki baimendu ezean.",
"cascadeDelete": "Ezabatze errekurtsiboa",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Le nouveau mot de passe doit être différent de votre mot de passe actuel"
},
"prompts": {
"mediaInfo": "Infos média",
"mediaTitle": "Titre",
"subtitle": "Sous-titre {n}",
"artist": "Artiste",
"album": "Album",
"year": "Année",
"genre": "Genre",
"track": "Piste",
"container": "Conteneur",
"videoCodec": "Codec vidéo",
"audioCodec": "Codec audio",
"noTemplate": "Votre modèle est vide !",
"newFileTemplate": "Modèle de nouveau fichier",
"newFileTemplateMessage": "Ajoutez ici de nouveaux noms de fichiers, ils seront utilisés comme modèle pour la création de nouveaux fichiers dans le menu contextuel !",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Votre mot de passe est nécessaire pour générer un code d'authentification."
},
"general": {
"show": "Afficher",
"hide": "Masquer",
"import": "Importer",
"export": "Exporter",
"name": "Nom",
@@ -689,6 +676,7 @@
"allow": "Autoriser",
"allowDeny": "Autoriser/refuser",
"userGroup": "Utilisateur/Groupe",
"accessManagement": "Gestion de l'accès",
"totalDenied": "Refusé",
"totalAllowed": "Autorisé",
"all": "Refuser tout",
@@ -696,6 +684,8 @@
"defaultBehavior": "Action par défaut{suffix}",
"cascadeDelete": "Suppression récursive",
"cascadeDeleteDescription": "Lorsque cette option est activée, la suppression d'une règle d'accès s'applique également à tous les sous-répertoires. Cela vous permet de supprimer complètement un utilisateur ou un groupe de toute l'arborescence des répertoires.",
"hasRules": "Règles d'accès configurées",
"manageDescription": "Règles d'accès libre pour ce chemin.",
"newGroup": "Nouveau groupe",
"editGroup": "Modifier le groupe",
"groupName": "Nom du groupe",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "הסיסמה החדשה חייבת להיות שונה מהסיסמה הנוכחית שלך"
},
"prompts": {
"mediaInfo": "מידע מדיה",
"mediaTitle": "כותרת",
"subtitle": "כתובית {n}",
"artist": "אמן",
"album": "אלבום",
"year": "שנה",
"genre": "ז'אנר",
"track": "רצועה",
"container": "מכל",
"videoCodec": "קודק וידאו",
"audioCodec": "קודק אודיו",
"noTemplate": "התבנית שלך ריקה!",
"newFileTemplate": "תבנית קובץ חדש",
"newFileTemplateMessage": "הוסף שמות קבצים חדשים כאן, הם ישמשו כתבנית ליצירת קבצים חדשים בתפריט ההקשר!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "יש להזין את הסיסמה שלך כדי ליצור קוד אימות."
},
"general": {
"show": "הצג",
"hide": "הסתר",
"import": "ייבוא",
"export": "ייצוא",
"name": "שם",
@@ -689,6 +676,7 @@
"allow": "אפשר",
"allowDeny": "אפשר/דחה",
"userGroup": "משתמש/קבוצה",
"accessManagement": "ניהול גישה",
"totalDenied": "נדחה",
"totalAllowed": "מותר",
"all": "דחה הכל",
@@ -696,6 +684,8 @@
"defaultBehavior": "פעולה ברירת מחדל{suffix}",
"cascadeDelete": "מחיקה רקורסיבית",
"cascadeDeleteDescription": "כאשר אפשרות זו מופעלת, מחיקת כלל גישה תסיר אותו גם מכל הספריות המשנה. כך תוכל להסיר משתמש או קבוצה לחלוטין מכל עץ הספריות.",
"hasRules": "כללי הגישה הוגדרו",
"manageDescription": "כללי הגישה הפתוחה לנתיב זה.",
"newGroup": "קבוצה חדשה",
"editGroup": "עריכת קבוצה",
"groupName": "שם הקבוצה",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Az új jelszónak különböznie kell a jelenlegi jelszótól"
},
"prompts": {
"mediaInfo": "Média információ",
"mediaTitle": "Cím",
"subtitle": "{n}. felirat",
"artist": "Előadó",
"album": "Album",
"year": "Év",
"genre": "Műfaj",
"track": "Szám",
"container": "Konténer",
"videoCodec": "Videó kodek",
"audioCodec": "Audió kodek",
"noTemplate": "A sablon üres!",
"newFileTemplate": "Új fájl sablon",
"newFileTemplateMessage": "Adjon hozzá új fájlneveket itt, ezek sablonként szolgálnak az új fájlok létrehozásához a helyi menüben!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "A hitelesítési kód generálásához a jelszóra van szükség."
},
"general": {
"show": "Mutat",
"hide": "Elrejt",
"import": "Importálás",
"export": "Exportálás",
"name": "Név",
@@ -689,6 +676,7 @@
"allow": "Engedélyezze a",
"allowDeny": "Engedélyezés/Tiltás",
"userGroup": "Felhasználó/csoport",
"accessManagement": "Hozzáférés-kezelés",
"totalDenied": "Elutasítva",
"totalAllowed": "Engedélyezett",
"all": "Mindent megtagadni",
@@ -696,6 +684,8 @@
"defaultBehavior": "Alapértelmezett cselekvés{suffix}",
"cascadeDelete": "Rekurzív törlés",
"cascadeDeleteDescription": "Ha engedélyezve van, egy hozzáférési szabály törlése az összes alkönyvtárból is eltávolítja azt. Ez lehetővé teszi egy felhasználó vagy csoport teljes eltávolítását a teljes könyvtárfából.",
"hasRules": "A hozzáférési szabályok beállítása",
"manageDescription": "A hozzáférésre vonatkozó szabályok ezen az útvonalon.",
"newGroup": "Új csoport",
"editGroup": "Csoport szerkesztése",
"groupName": "Csoport neve",
+3 -13
View File
@@ -9,8 +9,6 @@
"noCreateOptions": "Non sono disponibili opzioni di creazione"
},
"general": {
"show": "Mostra",
"hide": "Nascondi",
"import": "Importa",
"export": "Esporta",
"divider": "Divisore{suffix}",
@@ -415,17 +413,6 @@
"shareDefaultsDescription": "Configurare le opzioni predefinite per le nuove condivisioni e applicare le politiche agli utenti non amministratori."
},
"prompts": {
"mediaInfo": "Info multimediali",
"mediaTitle": "Titolo",
"subtitle": "Sottotitolo {n}",
"artist": "Artista",
"album": "Album",
"year": "Anno",
"genre": "Genere",
"track": "Traccia",
"container": "Contenitore",
"videoCodec": "Codec video",
"audioCodec": "Codec audio",
"noTemplate": "Il tuo modello è vuoto!",
"newFileTemplate": "Modello nuovo file",
"newFileTemplateMessage": "Aggiungi qui nuovi nomi di file, verranno utilizzati come modello per la creazione di nuovi file nel menu contestuale!",
@@ -791,10 +778,13 @@
"allow": "Consenti",
"allowDeny": "Consenti/Nega",
"userGroup": "Utente/Gruppo",
"accessManagement": "Gestione Accessi",
"defaultBehavior": "Azione predefinita{suffix}",
"defaultBehaviorDescription": "Cosa succede quando non esistono regole specifiche. 'Consenti' permette l'accesso, 'Nega' blocca tutto tranne gli esplicitamente autorizzati.",
"cascadeDelete": "Eliminazione Ricorsiva",
"cascadeDeleteDescription": "Se abilitato, l'eliminazione di una regola rimuoverà anche quelle nelle sottodirectory.",
"hasRules": "Regole di accesso configurate",
"manageDescription": "Regole di libero accesso per questo percorso.",
"newGroup": "Nuovo gruppo",
"editGroup": "Modifica gruppo",
"groupName": "Nome del gruppo",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "新しいパスワードは、現在のパスワードとは異なるものにしてください"
},
"prompts": {
"mediaInfo": "メディア情報",
"mediaTitle": "タイトル",
"subtitle": "字幕 {n}",
"artist": "アーティスト",
"album": "アルバム",
"year": "年",
"genre": "ジャンル",
"track": "トラック",
"container": "コンテナ",
"videoCodec": "ビデオコーデック",
"audioCodec": "オーディオコーデック",
"noTemplate": "テンプレートが空です!",
"newFileTemplate": "新規ファイルテンプレート",
"newFileTemplateMessage": "新しいファイル名をここに追加すると、コンテキストメニューでの新規ファイル作成のテンプレートとして使用されます!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "認証コードを生成するにはパスワードが必要です。"
},
"general": {
"show": "表示",
"hide": "非表示",
"import": "インポート",
"export": "エクスポート",
"name": "名前",
@@ -689,6 +676,7 @@
"allow": "許可",
"allowDeny": "許可/拒否",
"userGroup": "ユーザー/グループ",
"accessManagement": "アクセス管理",
"totalDenied": "拒否済み",
"totalAllowed": "許可済み",
"all": "すべて拒否",
@@ -696,6 +684,8 @@
"defaultBehavior": "デフォルト動作{suffix}",
"cascadeDelete": "再帰的削除",
"cascadeDeleteDescription": "有効な場合、アクセスルールを削除すると、すべてのサブディレクトリからも削除される。これにより、ディレクトリツリー全体からユーザーまたはグループを 完全に削除することができる。",
"hasRules": "アクセスルールが設定されました",
"manageDescription": "この経路に関するオープンアクセスに関する規則。",
"newGroup": "新規グループ",
"editGroup": "グループを編集",
"groupName": "グループ名",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "새 비밀번호는 현재 비밀번호와 달라야 합니다."
},
"prompts": {
"mediaInfo": "미디어 정보",
"mediaTitle": "제목",
"subtitle": "자막 {n}",
"artist": "아티스트",
"album": "앨범",
"year": "연도",
"genre": "장르",
"track": "트랙",
"container": "컨테이너",
"videoCodec": "비디오 코덱",
"audioCodec": "오디오 코덱",
"noTemplate": "템플릿이 비어 있습니다!",
"newFileTemplate": "새 파일 템플릿",
"newFileTemplateMessage": "여기에 새 파일 이름을 추가하면 컨텍스트 메뉴에서 새 파일 생성 템플릿으로 사용됩니다!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "인증 코드를 생성하려면 비밀번호가 필요합니다."
},
"general": {
"show": "표시",
"hide": "숨기기",
"import": "가져오기",
"export": "내보내기",
"name": "이름",
@@ -689,6 +676,7 @@
"allow": "허용",
"allowDeny": "허용/거부",
"userGroup": "사용자/그룹",
"accessManagement": "액세스 관리",
"totalDenied": "거부됨",
"totalAllowed": "허용됨",
"all": "모두 거부",
@@ -696,6 +684,8 @@
"defaultBehavior": "기본 작업{suffix}",
"cascadeDelete": "재귀적 삭제",
"cascadeDeleteDescription": "이 기능을 활성화하면 액세스 규칙을 삭제하면 모든 하위 디렉터리에서도 해당 규칙이 제거됩니다. 이렇게 하면 전체 디렉터리 트리에서 사용자 또는 그룹을 완전히 제거할 수 있습니다.",
"hasRules": "액세스 규칙이 구성되었습니다.",
"manageDescription": "이 경로에 적용되는 오픈 액세스 규칙.",
"newGroup": "새 그룹",
"editGroup": "그룹 편집",
"groupName": "그룹 이름",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Het nieuwe wachtwoord moet anders zijn dan je huidige wachtwoord"
},
"prompts": {
"mediaInfo": "Media-info",
"mediaTitle": "Titel",
"subtitle": "Ondertitel {n}",
"artist": "Artiest",
"album": "Album",
"year": "Jaar",
"genre": "Genre",
"track": "Nummer",
"container": "Container",
"videoCodec": "Videocodec",
"audioCodec": "Audiocodec",
"noTemplate": "Uw sjabloon is leeg!",
"newFileTemplate": "Sjabloon voor nieuw bestand",
"newFileTemplateMessage": "Voeg hier nieuwe bestandsnamen toe, ze worden gebruikt als sjabloon voor het aanmaken van nieuwe bestanden in het contextmenu!",
@@ -615,8 +604,6 @@
"accountOptions": "Accountinstellingen"
},
"general": {
"show": "Tonen",
"hide": "Verbergen",
"import": "Importeren",
"export": "Exporteren",
"name": "Naam",
@@ -801,11 +788,14 @@
"allow": "Sta toe.",
"allowDeny": "Toestaan/weigeren",
"userGroup": "Gebruiker/Groep",
"accessManagement": "Toegangsbeheer",
"all": "Alles weigeren",
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen.",
"defaultBehavior": "Standaardactie{suffix}",
"cascadeDelete": "Recursief verwijderen",
"cascadeDeleteDescription": "Indien ingeschakeld, zal het verwijderen van een toegangsregel deze ook verwijderen uit alle submappen. Hierdoor kun je een gebruiker of groep volledig verwijderen uit de hele mappenstructuur.",
"hasRules": "Toegangsregels geconfigureerd",
"manageDescription": "Regels voor open toegang voor dit pad.",
"newGroup": "Nieuwe groep",
"editGroup": "Groep bewerken",
"groupName": "Groepsnaam",
+3 -13
View File
@@ -9,8 +9,6 @@
"noCreateOptions": "Er zijn geen opties beschikbaar"
},
"general": {
"show": "Tonen",
"hide": "Verbergen",
"import": "Importeren",
"export": "Exporteren",
"to": "naar{suffix}",
@@ -435,17 +433,6 @@
"deleteFailed": "Share verwijderen is mislukt"
},
"prompts": {
"mediaInfo": "Media-info",
"mediaTitle": "Titel",
"subtitle": "Ondertitel {n}",
"artist": "Artiest",
"album": "Album",
"year": "Jaar",
"genre": "Genre",
"track": "Nummer",
"container": "Container",
"videoCodec": "Videocodec",
"audioCodec": "Audiocodec",
"noTemplate": "Uw sjabloon is leeg!",
"newFileTemplate": "Sjabloon voor nieuw bestand",
"newFileTemplateMessage": "Voeg hier nieuwe bestandsnamen toe, ze worden gebruikt als sjabloon voor het aanmaken van nieuwe bestanden in het contextmenu!",
@@ -822,12 +809,15 @@
"groupsOidcNote": "Leden van OIDC-gesynchroniseerde groepen worden bij elke aanmelding van die gebruikers opnieuw ingesteld vanuit de identiteitsprovider.",
"all": "Alles weigeren",
"rules": "Toegangsregels",
"hasRules": "Toegangsregels geconfigureerd",
"manageDescription": "Regels inzake open toegang voor dit traject.",
"deny": "Weigeren",
"totalDenied": "Geweigerd",
"totalAllowed": "Toegestaan",
"allow": "Toestaan",
"allowDeny": "Toestaan/weigeren",
"userGroup": "Gebruiker/Groep",
"accessManagement": "Toegangsbeheer",
"defaultBehavior": "Standaardactie{suffix}",
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen.",
"cascadeDelete": "Recursief verwijderen",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Nowe hasło musi różnić się od obecnego hasła"
},
"prompts": {
"mediaInfo": "Informacje o mediach",
"mediaTitle": "Tytuł",
"subtitle": "Napisy {n}",
"artist": "Wykonawca",
"album": "Album",
"year": "Rok",
"genre": "Gatunek",
"track": "Ścieżka",
"container": "Kontener",
"videoCodec": "Kodek wideo",
"audioCodec": "Kodek audio",
"noTemplate": "Twój szablon jest pusty!",
"newFileTemplate": "Szablon nowego pliku",
"newFileTemplateMessage": "Dodaj tutaj nowe nazwy plików, będą one używane jako szablon do tworzenia nowych plików w menu kontekstowym!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Hasło jest wymagane do wygenerowania kodu uwierzytelniającego."
},
"general": {
"show": "Pokaż",
"hide": "Ukryj",
"import": "Importuj",
"export": "Eksportuj",
"name": "Nazwa",
@@ -689,6 +676,7 @@
"allow": "Zezwalaj",
"allowDeny": "Zezwól/Odmów",
"userGroup": "Użytkownik/Grupa",
"accessManagement": "Zarządzanie dostępem",
"totalDenied": "Odmowa",
"totalAllowed": "Dozwolone",
"all": "Odmów wszystkim",
@@ -696,6 +684,8 @@
"defaultBehavior": "Działanie domyślne{suffix}",
"cascadeDelete": "Usuwanie rekursywne",
"cascadeDeleteDescription": "Po włączeniu tej opcji usunięcie reguły dostępu spowoduje również usunięcie jej ze wszystkich podkatalogów. Pozwala to na całkowite usunięcie użytkownika lub grupy z całego drzewa katalogów.",
"hasRules": "Skonfigurowane reguły dostępu",
"manageDescription": "Zasady otwartego dostępu dla tej ścieżki.",
"newGroup": "Nowa grupa",
"editGroup": "Edytuj grupę",
"groupName": "Nazwa grupy",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "A nova senha deve ser diferente da sua senha atual"
},
"prompts": {
"mediaInfo": "Informação do meio",
"mediaTitle": "Título",
"subtitle": "Legenda {n}",
"artist": "Artista",
"album": "Álbum",
"year": "Ano",
"genre": "Género",
"track": "Faixa",
"container": "Contentor",
"videoCodec": "Codec de vídeo",
"audioCodec": "Codec de áudio",
"noTemplate": "Seu modelo está vazio!",
"newFileTemplate": "Modelo de novo arquivo",
"newFileTemplateMessage": "Adicione novos nomes de arquivos aqui, eles serão usados como modelo para criar novos arquivos no menu de contexto!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Sua senha é necessária para gerar um código de autenticação."
},
"general": {
"show": "Mostrar",
"hide": "Ocultar",
"import": "Importar",
"export": "Exportar",
"name": "Nome",
@@ -689,6 +676,7 @@
"allow": "Permitir",
"allowDeny": "Permitir/Negar",
"userGroup": "Usuário/Grupo",
"accessManagement": "Gerenciamento de acesso",
"totalDenied": "Negado",
"totalAllowed": "Permitido",
"all": "Negar tudo",
@@ -696,6 +684,8 @@
"defaultBehavior": "Ação padrão{suffix}",
"cascadeDelete": "Exclusão recursiva",
"cascadeDeleteDescription": "Quando ativada, a exclusão de uma regra de acesso também a removerá de todos os subdiretórios. Isso permite remover completamente um usuário ou grupo de toda a árvore de diretórios.",
"hasRules": "Regras de acesso configuradas",
"manageDescription": "Regras de acesso aberto para este caminho.",
"newGroup": "Novo grupo",
"editGroup": "Editar grupo",
"groupName": "Nome do grupo",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "A nova palavra-passe tem de ser diferente da sua palavra-passe atual"
},
"prompts": {
"mediaInfo": "Informação do meio",
"mediaTitle": "Título",
"subtitle": "Legenda {n}",
"artist": "Artista",
"album": "Álbum",
"year": "Ano",
"genre": "Género",
"track": "Faixa",
"container": "Contentor",
"videoCodec": "Codec de vídeo",
"audioCodec": "Codec de áudio",
"noTemplate": "O seu modelo está vazio!",
"newFileTemplate": "Modelo de novo ficheiro",
"newFileTemplateMessage": "Adicione novos nomes de ficheiros aqui, eles serão usados como modelo para criar novos ficheiros no menu de contexto!",
@@ -615,8 +604,6 @@
"accountOptions": "Opções da conta"
},
"general": {
"show": "Mostrar",
"hide": "Ocultar",
"import": "Importar",
"export": "Exportar",
"name": "Nome",
@@ -801,11 +788,14 @@
"allow": "Permitir",
"allowDeny": "Permitir/Negar",
"userGroup": "Utilizador/Grupo",
"accessManagement": "Gestão do acesso",
"all": "Recusar tudo",
"defaultBehaviorDescription": "Isto mostra o que acontece quando um utilizador tenta aceder a um caminho onde não existem regras de acesso específicas. \"Permitir\" significa que os utilizadores podem aceder por predefinição, \"Negar\" significa que os utilizadores são bloqueados por predefinição, a menos que sejam explicitamente autorizados.",
"defaultBehavior": "Ação por defeito{suffix}",
"cascadeDelete": "Eliminação recursiva",
"cascadeDeleteDescription": "Quando activada, a eliminação de uma regra de acesso também a removerá de todos os subdirectórios. Isto permite-lhe remover completamente um utilizador ou grupo de toda a árvore de diretórios.",
"hasRules": "Regras de acesso configuradas",
"manageDescription": "Regras de acesso livre para este percurso.",
"newGroup": "Novo grupo",
"editGroup": "Editar grupo",
"groupName": "Nome do grupo",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Noua parolă trebuie să fie diferită de parola actuală"
},
"prompts": {
"mediaInfo": "Informații media",
"mediaTitle": "Titlu",
"subtitle": "Subtitrare {n}",
"artist": "Artist",
"album": "Album",
"year": "An",
"genre": "Gen",
"track": "Piesă",
"container": "Container",
"videoCodec": "Codec video",
"audioCodec": "Codec audio",
"noTemplate": "Șablonul tău este gol!",
"newFileTemplate": "Șablon fișier nou",
"newFileTemplateMessage": "Adăugați nume de fișiere noi aici, acestea vor fi folosite ca șablon pentru crearea de fișiere noi în meniul contextual!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Parola dvs. este necesară pentru a genera un cod de autentificare."
},
"general": {
"show": "Afișează",
"hide": "Ascunde",
"import": "Import",
"export": "Export",
"name": "Nume și prenume",
@@ -689,6 +676,7 @@
"allow": "Permiteți",
"allowDeny": "Permiteți/Negați",
"userGroup": "Utilizator/grup",
"accessManagement": "Gestionarea accesului",
"totalDenied": "Refuzat",
"totalAllowed": "Permise",
"all": "Refuzați toate",
@@ -696,6 +684,8 @@
"defaultBehavior": "Acțiune implicită{suffix}",
"cascadeDelete": "Ștergere recursivă",
"cascadeDeleteDescription": "Atunci când este activată, ștergerea unei reguli de acces o va elimina și din toate subdirectoarele. Acest lucru vă permite să eliminați complet un utilizator sau un grup din întregul arbore de directoare.",
"hasRules": "Reguli de acces configurate",
"manageDescription": "Reguli de acces liber pentru această cale.",
"newGroup": "Grup nou",
"editGroup": "Editează grupul",
"groupName": "Numele grupului",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Новый пароль должен отличаться от вашего текущего пароля"
},
"prompts": {
"mediaInfo": "Информация о медиа",
"mediaTitle": "Название",
"subtitle": "Субтитры {n}",
"artist": "Исполнитель",
"album": "Альбом",
"year": "Год",
"genre": "Жанр",
"track": "Дорожка",
"container": "Контейнер",
"videoCodec": "Видеокодек",
"audioCodec": "Аудиокодек",
"noTemplate": "Ваш шаблон пуст!",
"newFileTemplate": "Шаблон нового файла",
"newFileTemplateMessage": "Добавьте новые имена файлов здесь, они будут использоваться как шаблон для создания новых файлов в контекстном меню!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Ваш пароль необходим для генерации кода аутентификатора."
},
"general": {
"show": "Показать",
"hide": "Скрыть",
"import": "Импорт",
"export": "Экспорт",
"name": "Имя",
@@ -689,6 +676,7 @@
"allow": "Разрешить",
"allowDeny": "Разрешить/запретить",
"userGroup": "Пользователь/группа",
"accessManagement": "Управление доступом",
"totalDenied": "Отказано",
"totalAllowed": "Разрешено",
"all": "Запретить все",
@@ -696,6 +684,8 @@
"defaultBehavior": "Действие по умолчанию{suffix}",
"cascadeDelete": "Рекурсивное удаление",
"cascadeDeleteDescription": "Если эта функция включена, удаление правила доступа также удалит его из всех подкаталогов. Это позволяет полностью удалить пользователя или группу из всего дерева каталогов.",
"hasRules": "Настроены правила доступа",
"manageDescription": "Правила открытого доступа для данного маршрута.",
"newGroup": "Новая группа",
"editGroup": "Редактировать группу",
"groupName": "Название группы",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Nové heslo sa nesmie zhodovať s vaším súčasným heslom"
},
"prompts": {
"mediaInfo": "Informácie o médiu",
"mediaTitle": "Názov",
"subtitle": "Titulky {n}",
"artist": "Interpret",
"album": "Album",
"year": "Rok",
"genre": "Žáner",
"track": "Stopa",
"container": "Kontajner",
"videoCodec": "Video kodek",
"audioCodec": "Audio kodek",
"noTemplate": "Vaša šablóna je prázdna!",
"newFileTemplate": "Šablóna nového súboru",
"newFileTemplateMessage": "Tu pridajte nové názvy súborov, budú sa používať ako šablóna na vytváranie nových súborov v kontextovej ponuke!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Na vygenerovanie autentifikačného kódu je potrebné vaše heslo."
},
"general": {
"show": "Zobraziť",
"hide": "Skryť",
"import": "Importovať",
"export": "Exportovať",
"name": "Názov",
@@ -689,6 +676,7 @@
"allow": "Povoľte",
"allowDeny": "Povoliť/odmietnuť",
"userGroup": "Používateľ/skupina",
"accessManagement": "Správa prístupu",
"totalDenied": "Odmietnuté",
"totalAllowed": "Povolené",
"all": "Odmietnuť všetko",
@@ -696,6 +684,8 @@
"defaultBehavior": "Predvolená akcia{suffix}",
"cascadeDelete": "Rekurzívne vymazanie",
"cascadeDeleteDescription": "Ak je táto možnosť povolená, odstránením pravidla prístupu sa odstráni aj zo všetkých podadresárov. To umožňuje úplne odstrániť používateľa alebo skupinu z celého stromu adresárov.",
"hasRules": "Nastavené pravidlá prístupu",
"manageDescription": "Pravidlá otvoreného prístupu pre túto trasu.",
"newGroup": "Nová skupina",
"editGroup": "Upraviť skupinu",
"groupName": "Názov skupiny",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Det nya lösenordet måste skilja sig från ditt nuvarande lösenord"
},
"prompts": {
"mediaInfo": "Mediainformation",
"mediaTitle": "Titel",
"subtitle": "Undertext {n}",
"artist": "Artist",
"album": "Album",
"year": "År",
"genre": "Genre",
"track": "Spår",
"container": "Behållare",
"videoCodec": "Videokodek",
"audioCodec": "Ljudkodek",
"noTemplate": "Din mall är tom!",
"newFileTemplate": "Mall för ny fil",
"newFileTemplateMessage": "Lägg till nya filnamn här, de kommer att användas som mall för att skapa nya filer i snabbmenyn!",
@@ -615,8 +604,6 @@
"accountOptions": "Kontoalternativ"
},
"general": {
"show": "Visa",
"hide": "Dölj",
"import": "Importera",
"export": "Exportera",
"name": "Namn",
@@ -801,11 +788,14 @@
"allow": "Tillåt",
"allowDeny": "Tillåt/avslå",
"userGroup": "Användare/Grupp",
"accessManagement": "Hantering av åtkomst",
"all": "Avvisa alla",
"defaultBehaviorDescription": "Detta visar vad som händer när en användare försöker komma åt en sökväg där det inte finns några specifika åtkomstregler. \"Allow\" innebär att användare kan komma åt som standard, \"Deny\" innebär att användare blockeras som standard om de inte uttryckligen tillåts.",
"defaultBehavior": "Standardåtgärd{suffix}",
"cascadeDelete": "Rekursiv radering",
"cascadeDeleteDescription": "Om funktionen är aktiverad tas en åtkomstregel även bort från alla underkataloger. På så sätt kan du helt ta bort en användare eller grupp från hela katalogträdet.",
"hasRules": "Konfigurerade åtkomstregler",
"manageDescription": "Regler för fri åtkomst för denna väg.",
"newGroup": "Ny grupp",
"editGroup": "Redigera grupp",
"groupName": "Gruppnamn",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Yeni şifreniz, mevcut şifrenizden farklı olmalıdır"
},
"prompts": {
"mediaInfo": "Medya Bilgisi",
"mediaTitle": "Başlık",
"subtitle": "Altyazı {n}",
"artist": "Sanatçı",
"album": "Albüm",
"year": "Yıl",
"genre": "Tür",
"track": "Parça",
"container": "Kapsayıcı",
"videoCodec": "Video Kodeği",
"audioCodec": "Ses Kodeği",
"noTemplate": "Şablonunuz boş!",
"newFileTemplate": "Yeni dosya şablonu",
"newFileTemplateMessage": "Buraya yeni dosya adları ekleyin, bunlar bağlam menüsünde yeni dosya oluşturmak için şablon olarak kullanılacaktır!",
@@ -505,8 +494,6 @@
"passwordRequiredForGeneration": "Kimlik doğrulayıcı kodu oluşturmak için parolanız gereklidir."
},
"general": {
"show": "Göster",
"hide": "Gizle",
"import": "İçe Aktar",
"export": "Dışa Aktar",
"name": "İsim{suffix}",
@@ -689,6 +676,7 @@
"allow": "İzin ver",
"allowDeny": "İzin Ver / Reddet",
"userGroup": "Kullanıcı/Grup",
"accessManagement": "Erişim Yönetimi",
"totalDenied": "Reddedildi",
"totalAllowed": "İzin verildi",
"all": "Tümünü Reddet",
@@ -696,6 +684,8 @@
"defaultBehavior": "Varsayılan eylem{suffix}",
"cascadeDelete": "Yinelemeli Silme",
"cascadeDeleteDescription": "Etkinleştirildiğinde, bir erişim kuralı silindiğinde tüm alt dizinlerden de kaldırılır. Bu, bir kullanıcıyı veya grubu tüm dizin ağacından tamamen kaldırmanıza olanak tanır.",
"hasRules": "Erişim kuralları yapılandırıldı",
"manageDescription": "Bu yol için açık erişim kuralları.",
"newGroup": "Yeni grup",
"editGroup": "Grubu düzenle",
"groupName": "Grup adı",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "Новий пароль повинен відрізнятися від вашого поточного пароля"
},
"prompts": {
"mediaInfo": "Інформація про медіа",
"mediaTitle": "Назва",
"subtitle": "Субтитри {n}",
"artist": "Виконавець",
"album": "Альбом",
"year": "Рік",
"genre": "Жанр",
"track": "Доріжка",
"container": "Контейнер",
"videoCodec": "Відеокодек",
"audioCodec": "Аудіокодек",
"noTemplate": "Ваш шаблон порожній!",
"newFileTemplate": "Шаблон нового файлу",
"newFileTemplateMessage": "Додайте нові імена файлів тут, вони будуть використовуватися як шаблон для створення нових файлів у контекстному меню!",
@@ -615,8 +604,6 @@
"accountOptions": "Параметри облікового запису"
},
"general": {
"show": "Показати",
"hide": "Приховати",
"import": "Імпорт",
"export": "Експорт",
"name": "Ім'я",
@@ -801,11 +788,14 @@
"allow": "Дозвольте",
"allowDeny": "Дозволити/Заборонити",
"userGroup": "Користувач/група",
"accessManagement": "Керування доступом",
"all": "Заперечувати все.",
"defaultBehaviorDescription": "Тут показано, що відбувається, коли користувач намагається отримати доступ до шляху, для якого не існує певних правил доступу. \"Дозволити\" означає, що користувачі можуть отримати доступ за замовчуванням, \"Заборонити\" означає, що користувачі за замовчуванням заблоковані, якщо тільки це не дозволено явно.",
"defaultBehavior": "Дія за замовчуванням{suffix}",
"cascadeDelete": "Рекурсивне видалення",
"cascadeDeleteDescription": "Якщо увімкнено, видалення правила доступу також видалить його з усіх підкаталогів. Це дозволяє повністю видалити користувача або групу з усього дерева каталогів.",
"hasRules": "Налаштовано правила доступу",
"manageDescription": "Правила відкритого доступу для цього шляху.",
"newGroup": "Нова група",
"editGroup": "Редагувати групу",
"groupName": "Назва групи",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "新密码必须与您当前的密码不同"
},
"prompts": {
"mediaInfo": "媒体信息",
"mediaTitle": "标题",
"subtitle": "字幕 {n}",
"artist": "艺术家",
"album": "专辑",
"year": "年份",
"genre": "流派",
"track": "音轨",
"container": "容器",
"videoCodec": "视频编解码器",
"audioCodec": "音频编解码器",
"noTemplate": "您的模板为空!",
"newFileTemplate": "新建文件模板",
"newFileTemplateMessage": "在此处添加新文件名,它们将用作上下文菜单中新建文件的模板!",
@@ -615,8 +604,6 @@
"accountOptions": "账户选项"
},
"general": {
"show": "显示",
"hide": "隐藏",
"import": "导入",
"export": "导出",
"name": "名称",
@@ -801,11 +788,14 @@
"allow": "允许",
"allowDeny": "允许/拒绝",
"userGroup": "用户/组",
"accessManagement": "访问管理",
"all": "全部拒绝",
"defaultBehaviorDescription": "这显示了当用户试图访问没有特定访问规则的路径时会发生什么。允许 \"表示默认情况下用户可以访问,\"拒绝 \"表示除非明确允许,否则默认情况下用户将被阻止。",
"defaultBehavior": "默认操作{suffix}",
"cascadeDelete": "递归删除",
"cascadeDeleteDescription": "启用后,删除访问规则也会从所有子目录中删除该规则。这样就可以从整个目录树中完全删除用户或组。",
"hasRules": "已配置访问规则",
"manageDescription": "此路径的开放访问规则。",
"newGroup": "新建群组",
"editGroup": "编辑群组",
"groupName": "群组名称",
+3 -13
View File
@@ -157,17 +157,6 @@
"requirePasswordChangeMustDiffer": "新密碼必須與您目前的密碼不同"
},
"prompts": {
"mediaInfo": "媒體資訊",
"mediaTitle": "標題",
"subtitle": "字幕 {n}",
"artist": "藝術家",
"album": "專輯",
"year": "年份",
"genre": "類型",
"track": "音軌",
"container": "容器",
"videoCodec": "視訊編解碼器",
"audioCodec": "音訊編解碼器",
"noTemplate": "您的模板為空!",
"newFileTemplate": "新建檔案模板",
"newFileTemplateMessage": "在此處添加新檔案名稱,它們將用作上下文選單中新建檔案的模板!",
@@ -615,8 +604,6 @@
"orange": "橘色"
},
"general": {
"show": "顯示",
"hide": "隱藏",
"import": "匯入",
"export": "匯出",
"name": "名稱{suffix}",
@@ -801,11 +788,14 @@
"allow": "允許",
"allowDeny": "允許/拒絕",
"userGroup": "使用者/群組",
"accessManagement": "存取管理",
"all": "全部拒絕",
"defaultBehaviorDescription": "這顯示使用者嘗試存取沒有特定存取規則的路徑時會發生的情況。「允許」表示使用者預設可以存取,「拒絕」表示使用者預設會被阻擋,除非明確允許。",
"defaultBehavior": "預設動作{suffix}",
"cascadeDelete": "遞迴刪除",
"cascadeDeleteDescription": "啟用時,刪除存取規則也會從所有子目錄中移除該規則。這可讓您從整個目錄樹中完全移除使用者或群組。",
"hasRules": "已設定存取規則",
"manageDescription": "此路徑的開放存取規則。",
"newGroup": "新增群組",
"editGroup": "編輯群組",
"groupName": "群組名稱",
+6 -5
View File
@@ -32,7 +32,6 @@ import { rawList, withEntry, withoutEntry } from "@/utils/listing/selection.ts";
import type {
DisplayPreference,
FileListItem,
MediaMetadata,
Prompt,
ReqObject,
RouteObject,
@@ -1007,12 +1006,14 @@ export const mutations = {
state.req = value;
setActiveViewGrantScope(value?.source ?? "");
},
patchListingMetadata: (items: FileListItem[] | undefined, metadataMap: Map<string, MediaMetadata> | undefined, mergeForPath: string | null = null) => {
patchListingMetadata: (items: FileListItem[] | undefined, metadataMap: Map<string, unknown> | undefined, mergeForPath: string | null = null) => {
if (!items?.length || !metadataMap?.size) {
return;
}
const resolveMetadata = (item: FileListItem, metadata: MediaMetadata | undefined) =>
item.path === mergeForPath ? { ...metadata, ...item.metadata } : metadata;
const resolveMetadata = (item: FileListItem, metadata: unknown) =>
item.path === mergeForPath
? { ...(metadata as Record<string, unknown>), ...(item.metadata as Record<string, unknown>) }
: metadata;
// state.req.items is markRaw so editing an item would not re-render its row.
// For that, we swap the changed items for copies and replace the array
if (items === state.req?.items) {
@@ -1054,7 +1055,7 @@ export const mutations = {
}
navigationSortCache = null;
},
patchRequestMetadata: (metadataItems: Array<{ name: string; metadata: MediaMetadata }> | undefined) => {
patchRequestMetadata: (metadataItems: Array<{ name: string; metadata: unknown }> | undefined) => {
if (!state.req?.items || !metadataItems?.length) {
return;
}
+5 -15
View File
@@ -15,18 +15,7 @@ export interface FileListItem {
isShared?: boolean;
pinned?: boolean;
hidden?: boolean;
metadata?: MediaMetadata;
}
/** from backend utils.SubtitleTrack. */
export interface SubtitleTrack {
name: string;
language?: string;
srclang?: string;
title?: string;
index?: number;
codec?: string;
embedded: boolean;
metadata?: unknown;
}
/** from backend iteminfo.MediaMetadata. */
@@ -46,8 +35,9 @@ export interface MediaMetadata {
}
/** List items have their position in `req.items` (by mutations.replaceRequest) */
export interface fileListItemIndex extends FileListItem {
export interface fileListItemIndex extends Omit<FileListItem, "metadata"> {
index: number;
metadata?: MediaMetadata;
}
export interface ReqObject {
@@ -70,13 +60,13 @@ export interface ReqObject {
modified?: string;
created?: string;
hasPreview?: boolean;
subtitles?: SubtitleTrack[];
subtitles?: unknown[];
viewToken?: string;
parentDirItems?: FileListItem[];
onlyOfficeId?: string;
hasUpdate?: boolean;
isDir?: boolean;
metadata?: MediaMetadata;
metadata?: unknown;
// Directory listing properties
listing?: FileListItem[];
+156 -199
View File
@@ -1,49 +1,58 @@
import { state } from "@/store/index.ts";
/** Which source (or share) a path belongs to */
export interface MetadataScope {
/** Which source (or share) a folder belongs to */
export interface DirMetadataScope {
isShare?: boolean;
source?: string;
hash?: string;
}
export interface DirMetadataKey extends DirMetadataScope {
path: string;
}
/** metadata by file name */
export type DirMetadataMap = Map<string, object>;
type Change = "removed" | "changed";
interface Entry {
scope: string;
/** normalized path of the folder or file */
path: string;
promise: Promise<unknown>;
time: number;
/** items in a folder entry, they count against MAX_FOLDER_ITEMS */
size: number;
/** folder entries only: items deleted or uploaded since it was fetched */
changes?: Map<string, Change>;
/** Per-item changes recorded since a folder was cached */
export interface PendingDirChanges {
/** deleted or moved away */
removed: Set<string>;
/** uploaded, copied or moved */
dirty: Set<string>;
/** names without extension */
stems: Set<string>;
}
const TTL_MS = 600000;
const MAX_FILE_ENTRIES = 30;
const MAX_FOLDER_ITEMS = 5000;
export interface DirMetadataEntry extends PendingDirChanges {
key: string;
promise: Promise<DirMetadataMap>;
size: number;
time: number;
settled: boolean;
pendingSince: number;
}
const TTL_MS = 300000;
const PENDING_REUSE_MS = 120000;
const MAX_TOTAL_ITEMS = 5000;
const MAX_PENDING_CHANGES = 200;
const DIR = "dir";
// folders and single files, least recently used first
const cache = new Map<string, Entry>();
// least recently used first
const cacheMap = new Map<string, DirMetadataEntry>();
let cacheUser: string | null = null;
// Entries are not keyed by user, the cache is dropped whenever the signed-in user changes,
// Entries are not keyed by user, the cache is dropped whenever the signed-in user logs out.
// one account's metadata is never served to the other.
function checkUser(): void {
function cache(): Map<string, DirMetadataEntry> {
const user = state.user?.username || "";
if (user !== cacheUser) {
cache.clear();
cacheMap.clear();
cacheUser = user;
}
return cacheMap;
}
function scopePart({ isShare, source, hash }: MetadataScope): string {
function scopePart({ isShare, source, hash }: DirMetadataScope): string {
return isShare ? `share:${hash}` : `src:${source}`;
}
@@ -52,8 +61,8 @@ function normalizePath(path: string | undefined): string {
return collapsed.length > 1 ? collapsed.replace(/\/$/, "") : "/";
}
function keyOf(scope: MetadataScope, path: string, kind: string): string {
return `${scopePart(scope)}|${normalizePath(path)}|${kind}`;
function getCacheKey({ isShare, source, hash, path }: DirMetadataKey): string {
return `${scopePart({ isShare, source, hash })}|${normalizePath(path)}`;
}
function splitItemPath(itemPath: string | undefined): { dir: string; name: string; isDir: boolean } {
@@ -68,241 +77,189 @@ function splitItemPath(itemPath: string | undefined): { dir: string; name: strin
}
/** file name without its last extension, so "song.mp3" and "song.lrc" match */
function stemOf(name: string): string {
export function stemOf(name: string): string {
const dot = name.lastIndexOf(".");
return (dot > 0 ? name.slice(0, dot) : name).toLowerCase();
}
function trim(): void {
let files = 0;
let folderItems = 0;
for (const { changes, size } of cache.values()) {
if (changes) {
folderItems += size;
} else {
files++;
}
function trimToLimit(): void {
const entries = cache();
let total = 0;
for (const entry of entries.values()) {
total += entry.size;
}
for (const [key, entry] of cache) {
if (files <= MAX_FILE_ENTRIES && folderItems <= MAX_FOLDER_ITEMS) {
for (const [key, entry] of entries) {
if (total <= MAX_TOTAL_ITEMS) {
break;
}
if (entry.changes ? folderItems > MAX_FOLDER_ITEMS : files > MAX_FILE_ENTRIES) {
cache.delete(key);
if (entry.changes) {
folderItems -= entry.size;
} else {
files--;
}
}
total -= entry.size;
entries.delete(key);
}
}
/** @param fresh true for a new fetch, the TTL then counts from when its result arrived */
function track(key: string, entry: Entry, fresh: boolean): void {
/** @param startsCache true for folder fetc. the TTL then counts from when its result arrived */
function watchEntry(entry: DirMetadataEntry, startsCache = false): void {
const { promise } = entry;
entry.settled = false;
entry.pendingSince = Date.now();
promise.then(
(payload) => {
if (cache.get(key) !== entry || entry.promise !== promise) {
(map) => {
if (entry.promise !== promise) {
return;
}
// An empty folder answer is a valid answer, dropping it made every consumer
// (listing, preview, next/previous) request the whole folder again. It counts as one item.
if (!entry.changes && !payload) {
cache.delete(key);
return;
entry.settled = true;
if (cache().get(entry.key) === entry) {
if (startsCache) {
entry.time = Date.now();
}
entry.size = map.size;
if (map.size === 0) {
cache().delete(entry.key);
} else {
trimToLimit();
}
}
if (fresh) {
entry.time = Date.now();
}
entry.size = payload instanceof Map ? Math.max(1, payload.size) : 1;
trim();
},
() => {
if (cache.get(key) === entry && entry.promise === promise) {
cache.delete(key);
if (entry.promise !== promise) {
return;
}
entry.settled = true;
if (cache().get(entry.key) === entry) {
cache().delete(entry.key);
}
},
);
}
/** Looking an entry up counts as using it, so the ones you keep returning to are removed last */
function lookup(key: string): Entry | null {
const entry = cache.get(key);
/** Looking an entry up counts as using it, so the folders you keep returning to are removed last */
export function getDirMetadataEntry(opts: DirMetadataKey): DirMetadataEntry | null {
const entries = cache();
const key = getCacheKey(opts);
const entry = entries.get(key);
if (!entry) {
return null;
}
cache.delete(key);
entries.delete(key);
if (Date.now() - entry.time > TTL_MS) {
return null;
}
cache.set(key, entry);
if (!entry.settled && Date.now() - entry.pendingSince > PENDING_REUSE_MS) {
return null;
}
entries.set(key, entry);
return entry;
}
function add(key: string, scope: MetadataScope, path: string, promise: Promise<unknown>, changes?: Map<string, Change>): Entry {
const entry: Entry = { scope: scopePart(scope), path: normalizePath(path), promise, time: Date.now(), size: 0, changes };
cache.set(key, entry);
track(key, entry, true);
return entry;
/** Stores a pending or resolved promise for this folder. */
export function setCachedDirMetadata(opts: DirMetadataKey, promise: Promise<DirMetadataMap>): void {
const entries = cache();
const key = getCacheKey(opts);
entries.delete(key);
const entry: DirMetadataEntry = { key, promise, size: 0, time: Date.now(), settled: false, pendingSince: Date.now(), removed: new Set(), dirty: new Set(), stems: new Set() };
entries.set(key, entry);
watchEntry(entry, true);
}
/**
* Runs once per file and kind within the TTL, so the info prompt, the preview and repeated opens share one request. Failed or empty results are not kept.
* Entries go when the file, or a sidecar with its name, changes.
* Sends the per-item changes to the caller and starts clean.
* @returns the changes, or null when are none
*/
export function cachedItem<T>(scope: MetadataScope, path: string, kind: string, fetcher: () => Promise<T>): Promise<T> {
checkUser();
const key = keyOf(scope, path, kind);
const hit = lookup(key);
return (hit ?? add(key, scope, path, fetcher())).promise as Promise<T>;
export function takePendingDirChanges(entry: DirMetadataEntry): PendingDirChanges | null {
if (entry.removed.size + entry.dirty.size + entry.stems.size === 0) {
return null;
}
const pending = { removed: entry.removed, dirty: entry.dirty, stems: entry.stems };
entry.removed = new Set();
entry.dirty = new Set();
entry.stems = new Set();
return pending;
}
// Applies the per-item changes recorded since the folder was cached, fetching only the changed items.
async function applyChanges(
base: Promise<DirMetadataMap>,
changes: Map<string, Change>,
fetchAll: () => Promise<DirMetadataMap>,
fetchOne: (name: string) => Promise<object | null>,
): Promise<DirMetadataMap> {
const map = new Map(await base);
const toRefresh = new Set<string>();
for (const [name, change] of changes) {
if (change === "removed") {
map.delete(name);
} else {
toRefresh.add(name);
}
}
const stems = new Set([...changes.keys()].map(stemOf));
for (const name of map.keys()) {
if (stems.has(stemOf(name))) {
toRefresh.add(name);
}
}
// returns the names that failed, others are applied
const refresh = async (names: string[]): Promise<string[]> => {
const failed: string[] = [];
await Promise.all(
names.map(async (name) => {
try {
const metadata = await fetchOne(name);
if (metadata) {
map.set(name, metadata);
} else {
map.delete(name);
}
} catch {
failed.push(name);
}
}),
);
return failed;
};
const failed = await refresh([...toRefresh]);
if (failed.length && (await refresh(failed)).length) {
return fetchAll();
}
return map;
/** Replaces the promise of an existing entry (after pending changes were applied). */
export function setDirMetadataEntryPromise(entry: DirMetadataEntry, promise: Promise<DirMetadataMap>): void {
entry.promise = promise;
watchEntry(entry);
}
/**
* Metadata of a folder's items by name, cached and shared for all the callers to avoid unnecessary
* requests. Items changed (deleted, uploaded, moved) are patched with `fetchOne` instead of
* refetching the whole folder with `fetchAll`.
*/
export function cachedDirectory(
scope: MetadataScope,
path: string,
fetchAll: () => Promise<DirMetadataMap>,
fetchOne: (name: string) => Promise<object | null>,
): Promise<DirMetadataMap> {
checkUser();
const key = keyOf(scope, path, DIR);
const entry = lookup(key) ?? add(key, scope, path, fetchAll(), new Map());
const { changes } = entry;
if (changes?.size) {
entry.changes = new Map();
entry.promise = applyChanges(entry.promise as Promise<DirMetadataMap>, changes, fetchAll, fetchOne);
track(key, entry, false);
}
return entry.promise as Promise<DirMetadataMap>;
}
function drop(match: (entry: Entry) => boolean): void {
checkUser();
for (const [key, entry] of cache) {
if (match(entry)) {
cache.delete(key);
}
}
}
/** an item and everything cached below it, for folders and files alike */
function dropSubtree(scope: MetadataScope, itemPath: string): void {
const scopeKey = scopePart(scope);
function dropSubtree(scope: DirMetadataScope, itemPath: string): void {
const entries = cache();
const prefix = `${scopePart(scope)}|`;
const root = normalizePath(itemPath);
const prefix = root === "/" ? "/" : `${root}/`;
drop((entry) => entry.scope === scopeKey && (entry.path === root || entry.path.startsWith(prefix)));
}
/** cached entries go when the file or a sidecar sharing its stem changes */
function dropFilesByStem(scope: MetadataScope, dir: string, stem: string): void {
const scopeKey = scopePart(scope);
const dirPath = normalizePath(dir);
drop((entry) => {
if (entry.changes || entry.scope !== scopeKey) {
return false;
for (const key of [...entries.keys()]) {
if (!key.startsWith(prefix)) {
continue;
}
const parts = splitItemPath(entry.path);
const entryStem = stemOf(parts.name);
// "movie.en.srt" (stem "movie.en") changes the subtitles of "movie.mkv" (stem "movie")
return parts.dir === dirPath && (entryStem === stem || stem.startsWith(`${entryStem}.`));
});
const pathPart = key.slice(prefix.length);
if (pathPart === root || pathPart.startsWith(`${root}/`)) {
entries.delete(key);
}
}
}
function recordChange(scope: MetadataScope, itemPath: string, change: Change): void {
function recordItemChange(scope: DirMetadataScope, itemPath: string, kind: "removed" | "changed"): void {
const { dir, name, isDir } = splitItemPath(itemPath);
if (!name) {
return;
}
dropSubtree(scope, itemPath);
if (change === "changed" && isDir) {
if (kind === "changed" && isDir) {
return; // folders have no media metadata of their own
}
dropFilesByStem(scope, dir, stemOf(name));
const key = keyOf(scope, dir, DIR);
const changes = cache.get(key)?.changes;
if (!changes) {
const key = getCacheKey({ ...scope, path: dir });
const entry = cache().get(key);
if (!entry) {
return;
}
changes.set(name, change);
if (changes.size > MAX_PENDING_CHANGES) {
cache.delete(key);
if (kind === "removed") {
entry.removed.add(name);
entry.dirty.delete(name);
} else {
entry.dirty.add(name);
entry.removed.delete(name);
}
entry.stems.add(stemOf(name));
if (entry.removed.size + entry.dirty.size > MAX_PENDING_CHANGES) {
cache().delete(key);
}
}
/** An item was deleted or moved away: drop only its entry */
export function removeDirMetadataItem(scope: MetadataScope, itemPath: string): void {
recordChange(scope, itemPath, "removed");
/** An item was deleted or moved: drop only its entry */
export function removeDirMetadataItem(scope: DirMetadataScope, itemPath: string): void {
recordItemChange(scope, itemPath, "removed");
}
/** An item was uploaded, copied or moved: refresh only its entry */
export function markDirMetadataItemChanged(scope: MetadataScope, itemPath: string): void {
recordChange(scope, itemPath, "changed");
export function markDirMetadataItemChanged(scope: DirMetadataScope, itemPath: string): void {
recordItemChange(scope, itemPath, "changed");
}
/** drops its folder's listing and everything cached below the item */
export function invalidateDirMetadataItem(scope: MetadataScope, itemPath: string): void {
const { dir, name } = splitItemPath(itemPath);
export function invalidateDirMetadataItem(scope: DirMetadataScope, itemPath: string): void {
dropSubtree(scope, itemPath);
if (name) {
dropFilesByStem(scope, dir, stemOf(name));
}
cache.delete(keyOf(scope, dir, DIR));
cache().delete(getCacheKey({ ...scope, path: splitItemPath(itemPath).dir }));
}
/** Drops everything cached, so the next calls get fresh data */
export function invalidateDirMetadataCache(): void {
cache.clear();
/**
* Invalidates cached dir metadata so the next call gets fresh data: one folder with `path`, a whole
* source or share without it, everything with no options.
*/
export function invalidateDirMetadataCache(opts: DirMetadataScope & { path?: string } = {}): void {
const entries = cache();
const { source, hash, path } = opts;
const isShare = opts.isShare ?? (hash !== undefined && source === undefined);
if (path !== undefined) {
entries.delete(getCacheKey({ isShare, source, hash, path }));
return;
}
if (source === undefined && hash === undefined) {
entries.clear();
return;
}
const prefix = `${scopePart({ isShare, source, hash })}|`;
for (const key of [...entries.keys()]) {
if (key.startsWith(prefix)) {
entries.delete(key);
}
}
}
+1 -2
View File
@@ -114,6 +114,7 @@
"markdown",
"memory",
"menu_book",
"metadata",
"monitoring",
"more_horiz",
"more_vert",
@@ -129,14 +130,12 @@
"palette",
"passkey",
"pause",
"paused",
"person",
"photo",
"php",
"picture_as_pdf",
"play_arrow",
"play_circle",
"playing",
"playlist_play",
"progress_activity",
"push_pin",
+7 -4
View File
@@ -308,10 +308,13 @@ export default {
? getters.isShare() && state.shareInfo?.hash === currentHash && state.req?.path === path
: !getters.isShare() && state.req?.source === currentSource && state.req?.path === path;
try {
const metaMap = await mediaApi.getDirectoryMetadataMap(
isShare ? state.shareInfo.subPath : path,
{ source: currentSource },
);
const metaMap = isShare
? await mediaApi.getDirectoryMetadataMap(state.shareInfo.subPath, {
isShare: true,
hash: state.shareInfo.hash,
password: this.sharePassword,
})
: await mediaApi.getDirectoryMetadataMap(path, { source: currentSource });
if (!isCurrentListing()) {
return;
}
+37 -10
View File
@@ -363,7 +363,14 @@ export default {
async attachDirMediaMetadata(listing, dirPath) {
if (!listing?.length) return;
try {
const metaMap = await mediaApi.getDirectoryMetadataMap(dirPath, { source: state.req.source });
const isShare = getters.isShare();
const metaMap = isShare
? await mediaApi.getDirectoryMetadataMap(dirPath, {
isShare: true,
hash: state.shareInfo.hash,
password: localStorage.getItem(`sharepass:${state.shareInfo.hash}`) || '',
})
: await mediaApi.getDirectoryMetadataMap(dirPath, { source: state.req.source });
if (this.listing !== listing) return;
mutations.patchListingMetadata(listing, metaMap, state.req.path);
mutations.setupNavigation({
@@ -473,8 +480,15 @@ export default {
this.lyricsFetchedForPath = state.req.path;
if (state.req.metadata?.hasLyrics) {
try {
const { lyrics: raw, format } = await mediaApi.getLyrics(state.req.source, state.req.path);
this.lyrics = parseLyrics(raw, format);
if (getters.isShare()) {
const hash = state.shareInfo.hash;
const password = localStorage.getItem(`sharepass:${hash}`) || "";
const { lyrics: raw, format } = await mediaApi.getLyricsPublic(state.req.path, hash, password);
this.lyrics = parseLyrics(raw, format);
} else {
const { lyrics: raw, format } = await mediaApi.getLyrics(state.req.source, state.req.path);
this.lyrics = parseLyrics(raw, format);
}
} catch (err) {
console.warn("Failed to fetch lyrics:", err);
this.lyrics = [];
@@ -501,14 +515,27 @@ export default {
return;
}
const req = state.req;
if (!getters.isShare() && !getters.isLoggedIn()) {
return;
}
try {
const enriched = await mediaApi.getItemMediaMetadata(req.path, {
source: req.source,
albumArt: true,
});
let enriched;
if (getters.isShare()) {
const pwd =
localStorage.getItem(`sharepass:${state.shareInfo.hash}`) || "";
enriched = await mediaApi.fetchDirectoryMediaMetadataPublic(
req.path,
state.shareInfo.hash,
pwd,
true,
);
} else {
if (!getters.isLoggedIn()) {
return;
}
enriched = await mediaApi.fetchDirectoryMediaMetadata(
req.source,
req.path,
true,
);
}
if (state.req.path !== expectedPath) {
return;
}
+46 -34
View File
@@ -24,7 +24,7 @@
<div class="audio-left-column">
<!-- Album art with a generic icon if no image/metadata -->
<div class="album-art-container"
:class="{ 'no-artwork': !artworkUrl }"
:class="{ 'no-artwork': !albumArtUrl }"
:style="{
width: `${displayArtSize}em`,
maxWidth: '100%',
@@ -33,7 +33,7 @@
@mouseenter="onAlbumArtHover"
@mouseleave="onAlbumArtLeave"
@wheel="onAlbumArtScroll">
<img class="no-select album-art" v-if="artworkUrl" :src="artworkUrl" :alt="metadata.album || 'Album art'" />
<img class="no-select album-art" v-if="albumArtUrl" :src="albumArtUrl" :alt="metadata.album || 'Album art'" />
<div v-else class="album-art-fallback">
<i class="material-symbols">music_note</i>
</div>
@@ -138,8 +138,9 @@
<video
ref="videoElement"
:type="req.type"
preload="none"
:src="nativeVideoSrc"
:poster="artworkUrl"
:poster="posterUrl"
:autoplay="videoElementAutoplay"
@play="handlePlay"
@error="onVideoPlaybackError"
@@ -798,8 +799,7 @@ export default {
&& getters.previewPerms().video
);
},
artworkUrl() {
if (this.albumArtUrl) return this.albumArtUrl;
posterUrl() {
if (!this.scrubPreviewEnabled) return undefined;
return getters.isShare()
? getPreviewURLPublic(this.req.path, 'original')
@@ -988,7 +988,7 @@ export default {
playsinline: true,
clickToPlay: false, // we manage this ourselves with the gestures, plyr has a issue where this doesn't work in mobile.
resetOnEnd: false,
preload: 'none',
preload: this.previewType === 'video' ? 'none' : 'metadata',
fullscreen: {
enabled: true,
fallback: true,
@@ -1015,10 +1015,12 @@ export default {
// Record the mounted file before any async prepare step so route-leave
// detection works while Plyr is still waiting on subtitles/metadata.
this.mountedPreviewKey = resolvePipMediaKey(this.req?.source, this.req?.path);
this.loadMediaMetadata();
if (this.previewType === 'video' || this.previewType === 'audio') {
await this.preparePlyrMount();
}
if (this.previewType === "audio") {
this.loadAudioMetadata();
}
document.addEventListener('keydown', this.handleKeydown);
this.pagehideHandler = (event) => {
if (event.persisted) return;
@@ -1076,8 +1078,8 @@ export default {
const fallbackUrl = fallbackIcon.includes('?')
? `${fallbackIcon}&t=${timestamp}`
: `${fallbackIcon}?t=${timestamp}`;
// Video and audio uses the embedded artwork (if any), otherwise will fallback to the thumbnail/preview url.
const artworkSrc = this.artworkUrl;
// Video uses the same thumbnail as the poster; audio uses embedded album art (if any).
const artworkSrc = this.previewType === 'video' ? this.posterUrl : this.albumArtUrl;
const metadata = {
title: this.metadata?.title || this.fileName,
artist: this.metadata?.artist || globalVars.name || "Filebrowser Quantum",
@@ -1237,7 +1239,6 @@ export default {
return;
}
this.plyrTeardownDone = true;
this.cleanupAlbumArt();
if (this.scrubPreviewCleanup) {
this.scrubPreviewCleanup();
@@ -1301,6 +1302,7 @@ export default {
this.clearRewindForwardFeedback();
this.cleanupAudioVisualizer();
this.clearMediaSession();
this.cleanupAlbumArt();
this.player.off();
if (leavingInPip && media) {
registerPipSession(media, {
@@ -1636,25 +1638,39 @@ export default {
this.albumArtSize = Math.max(10, Math.min(50, newSize));
},
// Load metadata from the backend response
loadMediaMetadata() {
const meta = this.req.metadata;
this.metadata = {
title: meta?.title || this.fileName, // Fallback to filename
artist: meta?.artist || null,
album: meta?.album || null,
year: meta?.year || null,
};
if (!meta?.albumArt) return;
// Base64 encoded art
try {
const bytes = Uint8Array.from(atob(meta.albumArt), (c) => c.charCodeAt(0));
this.albumArtUrl = URL.createObjectURL(new Blob([bytes], { type: 'image/jpeg' }));
} catch (error) {
console.error("Failed to decode album art:", error);
this.albumArtUrl = null;
loadAudioMetadata() {
if (this.previewType !== "audio") return;
// Check if metadata is already provided by the backend
if (this.req.metadata) {
this.metadata = {
title: this.req.metadata.title || this.fileName, // Fallback to filename
artist: this.req.metadata.artist || null,
album: this.req.metadata.album || null,
year: this.req.metadata.year || null
};
// Handle base64 encoded album art
if (this.req.metadata.albumArt) {
try {
const byteCharacters = atob(this.req.metadata.albumArt);
const byteArray = Uint8Array.from(byteCharacters, (c) => c.charCodeAt(0));
const blob = new Blob([byteArray], { type: 'image/jpeg' });
this.albumArtUrl = URL.createObjectURL(blob);
} catch (error) {
console.error("Failed to decode album art:", error);
this.albumArtUrl = null;
}
}
} else {
this.metadata = {
title: this.fileName,
artist: null,
album: null,
year: null,
};
}
},
cleanupAlbumArt() {
if (this.previewType !== "audio") return;
if (typeof this.albumArtUrl === 'string' && this.albumArtUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.albumArtUrl);
}
@@ -2874,9 +2890,12 @@ export default {
return !!el.closest(
'.plyr__controls, .plyr__control, .plyr__menu__container, .plyr__menu, ' +
'[data-plyr="seek"], .plyr__progress, [data-plyr="volume"], .plyr__volume, ' +
'.audio-side-panel .sliding-tab, ' +
'.audio-side-panel .tab-btn, ' +
'.audio-side-panel .fab-button, ' +
'.audio-side-panel .lyric-line, ' +
'.audio-side-panel input[type="radio"], ' +
'.audio-side-panel label[for^="tab-"], ' +
'.audio-side-panel .mode-btn, ' +
'.audio-side-panel .repeat-one-btn, ' +
'.audio-side-panel .queue-item, ' +
'.audio-side-panel .clear-queue-btn, ' +
@@ -4124,13 +4143,6 @@ export default {
margin: 0;
}
/* this is just a workaround, the own plyr poster was hiding the video poster and showing black
when it didn't loaded (somehow happens); but summarizing, this a bug of plyr, it also loads the poster image 2 times
cuz it sets the URL on the html video tags and in their own */
.plyr__poster {
display: none !important;
}
/* Scrub preview popup (mounted on Plyr container / fullscreen root while scrubbing) */
.fb-scrub-preview {
position: fixed;