mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b6f0cbe4 | ||
|
|
7086b69cab | ||
|
|
39300aa492 | ||
|
|
a5799bc2d1 | ||
|
|
56ee77961e | ||
|
|
82fc156042 | ||
|
|
6f7f472bcd | ||
|
|
9a51bd0fac | ||
|
|
5d5af61886 | ||
|
|
6734f73847 | ||
|
|
45312736b1 | ||
|
|
f37c25f284 | ||
|
|
5cc183c447 | ||
|
|
b46ffd410f | ||
|
|
7cfd47edd7 | ||
|
|
3f4387bec3 | ||
|
|
49ba28208e | ||
|
|
481dbf41b6 | ||
|
|
25722e484d | ||
|
|
6cc198371d | ||
|
|
5302534975 | ||
|
|
b7474d6b8f | ||
|
|
6bdaeb43b3 |
No files matched your search
@@ -30,7 +30,6 @@
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
|
||||
@@ -12,6 +12,11 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Transcoding for media playback with quality presets -- requires ffmpeg.
|
||||
- Hardware Acceleration device selector (Software / detected GPU devices)
|
||||
- More operations use native api operations rather than ffmpeg, improving performance in many cases. Added temporary config option to force ffmpeg to be used
|
||||
- HLS keyframe indexes for MP4/MOV/M4V/MKV/WebM are read from the container index with go-media instead of demuxing every packet with ffprobe; ffprobe still runs when go-media cannot read the file or reports a keyframe gap that would rule out stream copy.
|
||||
- HEIC/AVIF rotation (irot) for the ffmpeg decode fallback is read with go-media instead of a full `ffmpeg -f null` decode.
|
||||
- AVIF images get previews through the HEIC preview path (go-media decode, ffmpeg fallback) when HEIC previews are enabled.
|
||||
- WMV/ASF video thumbnails use go-media for WMV3; WMV1/WMV2 still use ffmpeg.
|
||||
- ffmpeg and go-media share one media metadata cache keyed by path, size and modification time, so a probe, duration or subtitle result from either backend is reused by the other.
|
||||
- Document enhancements
|
||||
- Official Collabora support using WOPI config.
|
||||
- Document previews use a pure-go in-process package (all versions get it now).
|
||||
|
||||
+1
-1
@@ -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.2.2
|
||||
github.com/gtsteffaniak/go-media v1.3.0
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
|
||||
+2
-2
@@ -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.2.2 h1:CgdXyqiZGa81vZUGL1AXGxQe+8owkUkX6roNACGQJ0w=
|
||||
github.com/gtsteffaniak/go-media v1.2.2/go.mod h1:x8k0z759GiawTtKBTXDNXF/pT1UQDhR4e70/eypXpOk=
|
||||
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-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=
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
@@ -52,7 +53,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, sharedFFmpegService); err != nil {
|
||||
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -380,7 +381,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s (%s): %v", info.RealPath, info.Name, err)
|
||||
} else {
|
||||
@@ -393,7 +394,8 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
subtitles, ok := gomedia.EmbeddedSubtitles(info.RealPath, info.ModTime, func() ([]utils.SubtitleTrack, bool) {
|
||||
return ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime), true
|
||||
// Shadow run: bypass the shared cache so ffprobe is compared fresh.
|
||||
return ffmpeg.DetectEmbeddedSubtitlesContext(mediacache.WithoutCache(context.Background()), info.RealPath), true
|
||||
})
|
||||
if !ok {
|
||||
subtitles = ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
@@ -471,23 +473,11 @@ 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{
|
||||
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,
|
||||
}
|
||||
item.Metadata = &iteminfo.MediaMetadata{HasLyrics: info.HasLyrics}
|
||||
applyMediaTags(item.Metadata, info, realPath)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -541,8 +531,9 @@ func audioDuration(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPat
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
}
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
// The shadow run can outlive the request, so it uses a context that is not
|
||||
// cancelled with it. It bypasses the shared cache so ffmpeg is compared fresh.
|
||||
shadowCtx := mediacache.WithoutCache(context.WithoutCancel(ctx))
|
||||
legacy := func() (float64, bool) {
|
||||
d, err := service.GetMediaDuration(shadowCtx, realPath)
|
||||
return d, err == nil
|
||||
@@ -707,14 +698,15 @@ 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, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, 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.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
// The shadow run can outlive the request, so it uses a context that is not
|
||||
// cancelled with it. It bypasses the shared cache so ffprobe is compared fresh.
|
||||
shadowCtx := mediacache.WithoutCache(context.WithoutCancel(ctx))
|
||||
legacyProbe := func() (gomedia.ProbeResult, bool) {
|
||||
info, err := probeService.ProbeFile(shadowCtx, realPath)
|
||||
if err != nil || info == nil {
|
||||
@@ -759,12 +751,33 @@ 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
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
var (
|
||||
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitle track lists from ffprobe
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // extracted embedded subtitle content
|
||||
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration from ffprobe
|
||||
)
|
||||
@@ -9,13 +9,51 @@ import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
// HEICDisplayRotationReader reads the display rotation that a HEIC/HEIF/AVIF
|
||||
// file's item properties (irot) ask for, without decoding it. The rotation is
|
||||
// in degrees counter-clockwise, normalized to (-180, 180], which is the value
|
||||
// ffmpeg prints as "Display Matrix: rotation of ..." for the same file. ok is
|
||||
// false when the reader could not tell; the ffmpeg probe is used instead.
|
||||
type HEICDisplayRotationReader func(path string) (rotation float64, ok bool)
|
||||
|
||||
// heicDisplayRotationReader is the header reader set by
|
||||
// SetHEICDisplayRotationReader, or nil.
|
||||
var heicDisplayRotationReader atomic.Pointer[HEICDisplayRotationReader]
|
||||
|
||||
// SetHEICDisplayRotationReader installs the reader HEICDisplayRotation tries
|
||||
// before running ffmpeg. The gomedia package installs go-media's reader when it
|
||||
// is loaded; it cannot be called from here directly because gomedia imports
|
||||
// this package. Passing nil removes the reader.
|
||||
func SetHEICDisplayRotationReader(fn HEICDisplayRotationReader) {
|
||||
if fn == nil {
|
||||
heicDisplayRotationReader.Store(nil)
|
||||
return
|
||||
}
|
||||
heicDisplayRotationReader.Store(&fn)
|
||||
}
|
||||
|
||||
// HEICDisplayRotation returns the display rotation of heicPath and whether it is
|
||||
// known. The installed header reader (go-media) answers first; the
|
||||
// "ffmpeg -f null" stderr probe runs only when no reader is installed or the
|
||||
// reader could not read the file.
|
||||
func (s *Service) HEICDisplayRotation(ctx context.Context, heicPath string) (float64, bool) {
|
||||
if p := heicDisplayRotationReader.Load(); p != nil {
|
||||
if rotation, ok := (*p)(heicPath); ok {
|
||||
return rotation, true
|
||||
}
|
||||
}
|
||||
return s.GetHEICDisplayMatrixRotation(ctx, heicPath)
|
||||
}
|
||||
|
||||
// GetHEICDisplayMatrixRotation returns the largest rotation (degrees) ffmpeg reports for a
|
||||
// HEIC display matrix. The second return value is false when the probe could not run or
|
||||
// parse stderr — callers must not treat that as “no rotation was applied”.
|
||||
// HEICDisplayRotation is preferred; it only runs this probe as a fallback.
|
||||
func (s *Service) GetHEICDisplayMatrixRotation(ctx context.Context, heicPath string) (float64, bool) {
|
||||
if s == nil {
|
||||
return 0, false
|
||||
@@ -36,6 +74,8 @@ func (s *Service) GetHEICDisplayMatrixRotation(ctx context.Context, heicPath str
|
||||
return rotation, ok
|
||||
}
|
||||
|
||||
// parseDisplayMatrixRotation returns the largest-magnitude "Display Matrix"
|
||||
// rotation in ffmpeg stderr, and whether any was found.
|
||||
func parseDisplayMatrixRotation(stderr string) (float64, bool) {
|
||||
var best float64
|
||||
var found bool
|
||||
@@ -65,6 +105,7 @@ func parseDisplayMatrixRotation(stderr string) (float64, bool) {
|
||||
return best, found
|
||||
}
|
||||
|
||||
// isNormalOrientation reports whether an EXIF orientation needs no change.
|
||||
func isNormalOrientation(orientation string) bool {
|
||||
switch orientation {
|
||||
case "", "Horizontal (normal)", "Top-left", "1":
|
||||
@@ -74,6 +115,8 @@ func isNormalOrientation(orientation string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// isPureRotationOrientation reports whether an EXIF orientation is a rotation
|
||||
// without a mirror.
|
||||
func isPureRotationOrientation(orientation string) bool {
|
||||
switch orientation {
|
||||
case "Rotate 90 CW", "Right-top", "6",
|
||||
@@ -100,6 +143,8 @@ func orientationNeedsPostCorrection(orientation string, displayRotation float64,
|
||||
return true
|
||||
}
|
||||
|
||||
// applyOrientationToJPEG rotates or flips JPEG bytes for an EXIF orientation.
|
||||
// The input is returned unchanged when it cannot be decoded or re-encoded.
|
||||
func applyOrientationToJPEG(jpegBytes []byte, orientation string) []byte {
|
||||
if len(jpegBytes) < 100 || isNormalOrientation(orientation) {
|
||||
return jpegBytes
|
||||
|
||||
@@ -50,3 +50,35 @@ func TestOrientationNeedsPostCorrection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHEICDisplayRotationPrefersReader(t *testing.T) {
|
||||
prev := heicDisplayRotationReader.Load()
|
||||
t.Cleanup(func() { heicDisplayRotationReader.Store(prev) })
|
||||
|
||||
var calls int
|
||||
SetHEICDisplayRotationReader(func(path string) (float64, bool) {
|
||||
calls++
|
||||
if path == "known.heic" {
|
||||
return -90, true
|
||||
}
|
||||
return 0, false
|
||||
})
|
||||
var svc *Service // nil: the ffmpeg probe cannot run
|
||||
got, ok := svc.HEICDisplayRotation(t.Context(), "known.heic")
|
||||
if !ok || got != -90 {
|
||||
t.Fatalf("HEICDisplayRotation(known) = %v, %v; want -90, true", got, ok)
|
||||
}
|
||||
// The reader cannot tell: the ffmpeg probe runs, and with no service it
|
||||
// reports unknown rather than "no rotation".
|
||||
if _, ok := svc.HEICDisplayRotation(t.Context(), "other.heic"); ok {
|
||||
t.Fatal("HEICDisplayRotation(other) ok = true, want the ffmpeg probe's false")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("reader calls = %d, want 2", calls)
|
||||
}
|
||||
|
||||
SetHEICDisplayRotationReader(nil)
|
||||
if _, ok := svc.HEICDisplayRotation(t.Context(), "known.heic"); ok {
|
||||
t.Fatal("with no reader, HEICDisplayRotation must use the ffmpeg probe")
|
||||
}
|
||||
}
|
||||
@@ -81,14 +81,16 @@ func (s *Service) ConvertHEICToJPEG(ctx context.Context, heicPath string, target
|
||||
return s.CorrectHEICJPEGOrientation(ctx, heicPath, jpegBytes), nil
|
||||
}
|
||||
|
||||
// CorrectHEICJPEGOrientation applies irot / display-matrix correction after a full-resolution
|
||||
// HEIC decode when pixels are still in coded orientation (go-media and ffmpeg filter-free paths).
|
||||
// CorrectHEICJPEGOrientation applies EXIF orientation after a filter-free ffmpeg HEIC decode
|
||||
// when the pixels are still in coded orientation. The display rotation (irot) comes from
|
||||
// HEICDisplayRotation: go-media reads it from the item properties, and the ffmpeg stderr probe
|
||||
// is only a fallback.
|
||||
func (s *Service) CorrectHEICJPEGOrientation(ctx context.Context, heicPath string, jpegBytes []byte) []byte {
|
||||
orientation, err := s.GetHEICOrientation(heicPath)
|
||||
if err != nil {
|
||||
orientation = "Horizontal (normal)"
|
||||
}
|
||||
displayRotation, displayKnown := s.GetHEICDisplayMatrixRotation(ctx, heicPath)
|
||||
displayRotation, displayKnown := s.HEICDisplayRotation(ctx, heicPath)
|
||||
if orientationNeedsPostCorrection(orientation, displayRotation, displayKnown) {
|
||||
return applyOrientationToJPEG(jpegBytes, orientation)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
)
|
||||
|
||||
const cacheFixtureMP4 = "../../../frontend/tests/playwright-files/myfolder/testdata/sample-640x480.mp4"
|
||||
|
||||
var (
|
||||
testServiceOnce sync.Once
|
||||
testServiceErr error
|
||||
)
|
||||
|
||||
// initTestService starts the ffmpeg service once, without hardware probes, or
|
||||
// skips.
|
||||
func initTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("starts ffmpeg")
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
t.Skip("ffprobe not installed")
|
||||
}
|
||||
testServiceOnce.Do(func() {
|
||||
testServiceErr = Initialize(context.Background(), InitOptions{SkipHWTests: true, CacheDir: os.TempDir()})
|
||||
})
|
||||
if testServiceErr != nil {
|
||||
t.Skipf("ffmpeg init failed: %v", testServiceErr)
|
||||
}
|
||||
svc := Get()
|
||||
if svc == nil {
|
||||
t.Skip("ffmpeg service unavailable")
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// copyFixture copies the MP4 fixture so the test owns its cache key.
|
||||
func copyFixture(t *testing.T) (string, mediacache.Key) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(cacheFixtureMP4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
if err = os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := mediacache.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mediacache.Probes.Delete(key.String())
|
||||
mediacache.Durations.Delete(key.String())
|
||||
})
|
||||
return path, key
|
||||
}
|
||||
|
||||
// TestProbeFileUsesSharedCache checks that ffprobe results go into the shared
|
||||
// cache, cached values are reused, and WithoutCache bypasses both.
|
||||
func TestProbeFileUsesSharedCache(t *testing.T) {
|
||||
svc := initTestService(t)
|
||||
path, key := copyFixture(t)
|
||||
|
||||
got, err := svc.ProbeFile(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cached, ok := mediacache.Probes.Get(key.String())
|
||||
if !ok || cached != *got {
|
||||
t.Fatalf("ProbeFile must store its result in the shared cache: cached=%+v ok=%v got=%+v", cached, ok, *got)
|
||||
}
|
||||
|
||||
sentinel := FileProbeInfo{Duration: 1, VideoCodec: "sentinel"}
|
||||
mediacache.Probes.Set(key.String(), sentinel)
|
||||
got, err = svc.ProbeFile(context.Background(), path)
|
||||
if err != nil || *got != sentinel {
|
||||
t.Fatalf("ProbeFile must reuse a cached value (e.g. from go-media): got=%+v err=%v", got, err)
|
||||
}
|
||||
|
||||
fresh, err := svc.ProbeFile(mediacache.WithoutCache(context.Background()), path)
|
||||
if err != nil || *fresh == sentinel {
|
||||
t.Fatalf("WithoutCache must probe fresh: got=%+v err=%v", fresh, err)
|
||||
}
|
||||
if cached, _ := mediacache.Probes.Get(key.String()); cached != sentinel {
|
||||
t.Fatal("WithoutCache must not overwrite the shared cache")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMediaDurationUsesSharedCache checks that cached durations are reused
|
||||
// and WithoutCache probes fresh.
|
||||
func TestGetMediaDurationUsesSharedCache(t *testing.T) {
|
||||
svc := initTestService(t)
|
||||
path, key := copyFixture(t)
|
||||
|
||||
mediacache.Durations.Set(key.String(), 4242)
|
||||
d, err := svc.GetMediaDuration(context.Background(), path)
|
||||
if err != nil || d != 4242 {
|
||||
t.Fatalf("GetMediaDuration must reuse a cached value: d=%v err=%v", d, err)
|
||||
}
|
||||
d, err = svc.GetMediaDuration(mediacache.WithoutCache(context.Background()), path)
|
||||
if err != nil || d == 4242 || d <= 0 {
|
||||
t.Fatalf("WithoutCache must probe fresh: d=%v err=%v", d, err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
)
|
||||
|
||||
@@ -49,24 +50,29 @@ func resolveLocalMediaPath(mediaPath string) (string, os.FileInfo, error) {
|
||||
return clean, info, nil
|
||||
}
|
||||
|
||||
// FileProbeInfo holds ffprobe results for a local media file.
|
||||
type FileProbeInfo struct {
|
||||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
FormatName string
|
||||
}
|
||||
// FileProbeInfo holds probe results for a local media file. It is the shared
|
||||
// cache type, so go-media and ffprobe results are interchangeable.
|
||||
type FileProbeInfo = mediacache.ProbeInfo
|
||||
|
||||
// ProbeFile extracts duration and codec info from a media file.
|
||||
// ProbeFile extracts duration and codec info from a media file. Results are
|
||||
// stored in the shared media cache, which the go-media probe also reads, unless
|
||||
// ctx is mediacache.WithoutCache.
|
||||
func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeInfo, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return nil, fmt.Errorf("ffmpeg service not available")
|
||||
}
|
||||
|
||||
localPath, _, err := resolveLocalMediaPath(mediaPath)
|
||||
localPath, fileInfo, err := resolveLocalMediaPath(mediaPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
cacheKey := mediacache.FromFileInfo(localPath, fileInfo).String()
|
||||
if useCache {
|
||||
if cached, ok := mediacache.Probes.Get(cacheKey); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
info, err := s.inner.ProbeStream(ctx, goffmpeg.ProbeStreamOptions{
|
||||
URL: localPath,
|
||||
@@ -77,15 +83,20 @@ func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeIn
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FileProbeInfo{
|
||||
res := FileProbeInfo{
|
||||
Duration: info.Duration,
|
||||
VideoCodec: info.VideoCodec,
|
||||
AudioCodec: info.AudioCodec,
|
||||
FormatName: info.FormatName,
|
||||
}, nil
|
||||
}
|
||||
if useCache {
|
||||
mediacache.Probes.Set(cacheKey, res)
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// GetMediaDuration extracts the duration of a media file in seconds.
|
||||
// GetMediaDuration extracts the duration of a media file in seconds. Results
|
||||
// are stored in the shared media cache unless ctx is mediacache.WithoutCache.
|
||||
func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float64, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return 0, fmt.Errorf("ffmpeg service not available")
|
||||
@@ -95,9 +106,12 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%s:%d", localPath, fileInfo.ModTime().Unix())
|
||||
if duration, found := MetadataCache.Get(cacheKey); found {
|
||||
return duration, nil
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
cacheKey := mediacache.FromFileInfo(localPath, fileInfo).String()
|
||||
if useCache {
|
||||
if duration, found := mediacache.Durations.Get(cacheKey); found {
|
||||
return duration, nil
|
||||
}
|
||||
}
|
||||
|
||||
duration, err := s.inner.GetMediaDuration(ctx, localPath)
|
||||
@@ -105,6 +119,8 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
|
||||
return 0, err
|
||||
}
|
||||
|
||||
MetadataCache.Set(cacheKey, duration)
|
||||
if useCache {
|
||||
mediacache.Durations.Set(cacheKey, duration)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/ops"
|
||||
@@ -460,7 +461,8 @@ func (s *Service) VideoPreview(ctx context.Context, w io.Writer, opts ops.Previe
|
||||
return s.inner.CaptureFrame(ctx, w, frame)
|
||||
}
|
||||
|
||||
// ExtractSubtitle returns embedded subtitle content as WebVTT.
|
||||
// ExtractSubtitle returns embedded subtitle content as WebVTT. Results are
|
||||
// stored in the shared media cache unless ctx is mediacache.WithoutCache.
|
||||
func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamIndex int) (string, error) {
|
||||
if s == nil || s.inner == nil {
|
||||
return "", fmt.Errorf("ffmpeg service not available")
|
||||
@@ -470,9 +472,12 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
cacheKey := fmt.Sprintf("subtitle_content:%s:%d:%d", videoPath, streamIndex, fileInfo.ModTime().Unix())
|
||||
if content, ok := SubtitleContentCache.Get(cacheKey); ok {
|
||||
return content, nil
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
cacheKey := mediacache.FromFileInfo(videoPath, fileInfo).Stream(streamIndex)
|
||||
if useCache {
|
||||
if content, ok := mediacache.SubtitleContent.Get(cacheKey); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
content, err := s.inner.ExtractSubtitle(ctx, videoPath, streamIndex)
|
||||
@@ -480,7 +485,9 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
|
||||
return "", err
|
||||
}
|
||||
|
||||
SubtitleContentCache.Set(cacheKey, content)
|
||||
if useCache {
|
||||
mediacache.SubtitleContent.Set(cacheKey, content)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,30 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// DetectEmbeddedSubtitles detects embedded subtitle streams.
|
||||
// DetectEmbeddedSubtitles detects embedded subtitle streams. Results are stored
|
||||
// in the shared media cache, keyed by the file's current size and modification
|
||||
// time; modtime is kept for API compatibility and is not used for the key.
|
||||
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
|
||||
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
if cached, ok := MediaCache.Get(key); ok {
|
||||
return cached
|
||||
_ = modtime
|
||||
return DetectEmbeddedSubtitlesContext(context.Background(), videoPath)
|
||||
}
|
||||
|
||||
// DetectEmbeddedSubtitlesContext is DetectEmbeddedSubtitles with a context. The
|
||||
// shared media cache is skipped when ctx is mediacache.WithoutCache.
|
||||
func DetectEmbeddedSubtitlesContext(ctx context.Context, videoPath string) []utils.SubtitleTrack {
|
||||
useCache := !mediacache.Bypassed(ctx)
|
||||
key, statErr := mediacache.Stat(videoPath)
|
||||
useCache = useCache && statErr == nil
|
||||
if useCache {
|
||||
if cached, ok := mediacache.SubtitleTracks.Get(key.String()); ok {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
svc := Get()
|
||||
@@ -24,14 +38,16 @@ func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.Subtit
|
||||
return nil
|
||||
}
|
||||
|
||||
tracks, err := svc.inner.DetectSubtitles(context.Background(), videoPath)
|
||||
tracks, err := svc.inner.DetectSubtitles(ctx, videoPath)
|
||||
if err != nil {
|
||||
logger.Debug("ffprobe failed for file: " + videoPath + ", error: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
subtitles := mapSubtitleTracks(tracks)
|
||||
MediaCache.Set(key, subtitles)
|
||||
if useCache {
|
||||
mediacache.SubtitleTracks.Set(key.String(), subtitles)
|
||||
}
|
||||
return subtitles
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const fixtureWMV2 = fixtureDir + "/file_example_WMV_480_1_2MB.wmv"
|
||||
|
||||
// TestContainerGates pins which extensions go-media handles for probes and
|
||||
// for thumbnails.
|
||||
func TestContainerGates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
probe, thumbnail bool
|
||||
}{
|
||||
{"a.mp4", true, true},
|
||||
{"a.M4V", true, true},
|
||||
{"a.mov", true, true},
|
||||
{"a.mkv", true, true},
|
||||
{"a.webm", true, true},
|
||||
{"a.wmv", false, true},
|
||||
{"a.ASF", false, true},
|
||||
{"a.avi", false, false},
|
||||
{"a.ts", false, false},
|
||||
{"a.m2ts", false, false},
|
||||
{"a.mts", false, false},
|
||||
{"a.mpg", false, false},
|
||||
{"a.mpeg", false, false},
|
||||
{"a.vob", false, false},
|
||||
{"a.flv", false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
require.Equal(t, c.probe, supportsVideoContainer(c.name), "probe gate for %s", c.name)
|
||||
require.Equal(t, c.thumbnail, supportsVideoThumbnail(c.name), "thumbnail gate for %s", c.name)
|
||||
}
|
||||
for _, name := range []string{"a.heic", "a.HEIF", "a.heics", "a.avif"} {
|
||||
require.True(t, supportsHEIFImage(name), name)
|
||||
}
|
||||
require.False(t, supportsHEIFImage("a.jpg"))
|
||||
}
|
||||
|
||||
// TestWMV2FallsBackToFFmpeg checks that a WMV2 file is not probed by go-media
|
||||
// and that its thumbnail falls back to ffmpeg (go-media has no WMV1/WMV2
|
||||
// decoder).
|
||||
func TestWMV2FallsBackToFFmpeg(t *testing.T) {
|
||||
withGate(t, false)
|
||||
_, ok := Probe(fixtureWMV2, nil)
|
||||
require.False(t, ok, "WMV probes stay on ffprobe")
|
||||
_, ok = VideoThumbnail(fixtureWMV2, 320, 240, 80, 10, nil)
|
||||
require.False(t, ok, "WMV2 thumbnails must fall back to ffmpeg")
|
||||
}
|
||||
|
||||
// TestWMV1FallsBackToFFmpeg is TestWMV2FallsBackToFFmpeg for a WMV1 stream
|
||||
// encoded on the fly.
|
||||
func TestWMV1FallsBackToFFmpeg(t *testing.T) {
|
||||
withGate(t, false)
|
||||
requireTool(t, "ffmpeg")
|
||||
path := filepath.Join(t.TempDir(), "wmv1.wmv")
|
||||
out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=2",
|
||||
"-c:v", "wmv1", path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("ffmpeg cannot encode wmv1: %v: %s", err, out)
|
||||
}
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
_, ok := VideoThumbnail(path, 160, 120, 80, 10, nil)
|
||||
require.False(t, ok, "WMV1 thumbnails must fall back to ffmpeg")
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
@@ -28,20 +29,27 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var (
|
||||
subtitleListCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour)
|
||||
subtitleContentCache = cache.NewCache[string](24 * time.Hour)
|
||||
// Probe, duration, and subtitle results are cached in the mediacache package,
|
||||
// which the ffmpeg code path shares. A directory listing probes every video
|
||||
// child, and the preview flow probes the focused file again; the cache keeps
|
||||
// each request from re-reading the same containers. Keys include size and
|
||||
// modification time, so an edited file is read again.
|
||||
|
||||
// probeCache caches successful probe results by file identity (path, modtime,
|
||||
// size). A directory listing probes every video child, and the preview flow
|
||||
// probes the focused file again; without this each request re-read the same
|
||||
// containers. Keying on modtime+size means an edited file is re-probed.
|
||||
probeCache = cache.NewCache[ProbeResult](24 * time.Hour)
|
||||
// probeGroup coalesces concurrent probes of the same file so a burst of
|
||||
// requests for one container reads it once.
|
||||
var probeGroup singleflight.Group
|
||||
|
||||
// probeGroup coalesces concurrent probes of the same file so a burst of
|
||||
// requests for one container reads it once.
|
||||
probeGroup singleflight.Group
|
||||
)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Enabled reports whether the go-media fast path may run. The admin
|
||||
// "Always use ffmpeg" setting turns it off.
|
||||
@@ -60,9 +68,15 @@ func videoDimensions(info *media.ProbeInfo) (w, h int, ok bool) {
|
||||
}
|
||||
|
||||
// videoContainerExts are the containers go-media parses for video probes,
|
||||
// thumbnails, and embedded subtitles. Other video containers (AVI, WMV, TS, ...)
|
||||
// are not go-media targets, so they go straight to ffmpeg with no file open and
|
||||
// no log line. go-media reads MOV as MP4.
|
||||
// thumbnails, embedded subtitles, and keyframe indexes. go-media reads MOV as
|
||||
// MP4.
|
||||
//
|
||||
// go-media also demuxes AVI, ASF/WMV, MPEG-TS/M2TS, and MPEG-PS (VOB), but their
|
||||
// probes are left to ffmpeg on purpose: go-media's results for those containers
|
||||
// do not yet match ffprobe (TS/M2TS and PS list no audio streams, PS durations
|
||||
// are missing or wrong, AVI misnames MPEG-4 Part 2 and AAC, ASF reports WMA Pro
|
||||
// as wmav2). Those files go straight to ffmpeg with no file open and no log
|
||||
// line. See videoThumbnailExts for the thumbnail exception.
|
||||
var videoContainerExts = map[string]bool{
|
||||
".mp4": true,
|
||||
".m4v": true,
|
||||
@@ -71,11 +85,31 @@ var videoContainerExts = map[string]bool{
|
||||
".webm": true,
|
||||
}
|
||||
|
||||
// videoThumbnailExts are the containers go-media decodes video thumbnails from:
|
||||
// videoContainerExts plus ASF/WMV, whose WMV3 keyframes go-media decodes
|
||||
// bit-exact with ffmpeg. WMV1, WMV2, MS-MPEG4, and WVC1 Advanced Profile return
|
||||
// an unsupported error and fall back to ffmpeg. AVI, TS/M2TS, and PS stay on
|
||||
// ffmpeg: go-media's TS and PS thumbnails scan the whole file for the duration
|
||||
// (several times slower than ffmpeg on large files), and AVI seeks by a
|
||||
// duration that can be wrong.
|
||||
var videoThumbnailExts = map[string]bool{
|
||||
".mp4": true,
|
||||
".m4v": true,
|
||||
".mov": true,
|
||||
".mkv": true,
|
||||
".webm": true,
|
||||
".wmv": true,
|
||||
".asf": true,
|
||||
}
|
||||
|
||||
// heifImageExts are still-image containers go-media decodes for HEIC preview.
|
||||
// AVIF is a HEIF container with an AV1 image item; go-media decodes it in pure
|
||||
// Go and applies its irot/imir, like HEIC.
|
||||
var heifImageExts = map[string]bool{
|
||||
".heic": true,
|
||||
".heif": true,
|
||||
".heics": true,
|
||||
".avif": true,
|
||||
}
|
||||
|
||||
const heicDecodeMaxEdge = 16384
|
||||
@@ -93,15 +127,27 @@ var cameraRawExts = map[string]bool{
|
||||
".dc2": true, ".x3f": true, ".iiq": true, ".nkc": true, ".r3d": true,
|
||||
}
|
||||
|
||||
// supportsVideoContainer reports whether go-media targets path's container.
|
||||
// supportsVideoContainer reports whether go-media targets path's container for
|
||||
// probes, embedded subtitles, and keyframe indexes.
|
||||
func supportsVideoContainer(path string) bool {
|
||||
return videoContainerExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// supportsVideoThumbnail reports whether go-media targets path's container for
|
||||
// video thumbnails.
|
||||
func supportsVideoThumbnail(path string) bool {
|
||||
return videoThumbnailExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// supportsHEIFImage reports whether path is a HEIF-family still image
|
||||
// (HEIC/HEIF/AVIF) go-media decodes.
|
||||
|
||||
func supportsHEIFImage(path string) bool {
|
||||
return heifImageExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// supportsCameraRAW reports whether path is a camera RAW format go-media
|
||||
// extracts a preview from.
|
||||
func supportsCameraRAW(path string) bool {
|
||||
return cameraRawExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
@@ -133,16 +179,26 @@ 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, err := os.Open(path)
|
||||
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
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, 0, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
return f, info, nil
|
||||
}
|
||||
|
||||
// ffprobeFormatNames maps go-media containers to the format_name strings ffprobe
|
||||
@@ -179,30 +235,25 @@ func probeFast(path string) (ProbeResult, bool) {
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
f, info, err := openFileInfo(path)
|
||||
if err != nil {
|
||||
logFallback("probe", path, err)
|
||||
return ProbeResult{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
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 {
|
||||
cacheKey := mediacache.FromFileInfo(path, info).String()
|
||||
if res, ok := mediacache.Probes.Get(cacheKey); ok {
|
||||
return res, true
|
||||
}
|
||||
|
||||
// Coalesce concurrent probes of the same file into one container read.
|
||||
v, err, _ := probeGroup.Do(cacheKey, func() (any, error) {
|
||||
res, ok := parseProbe(path, f, size)
|
||||
res, ok := parseProbe(path, f, info.Size())
|
||||
if !ok {
|
||||
return nil, errProbeUnsupported
|
||||
}
|
||||
probeCache.Set(cacheKey, res)
|
||||
mediacache.Probes.Set(cacheKey, res)
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -219,12 +270,6 @@ func probeFast(path string) (ProbeResult, bool) {
|
||||
// no detail because parseProbe already logged the specific reason.
|
||||
var errProbeUnsupported = errors.New("probe unsupported")
|
||||
|
||||
// probeCacheKey identifies a file version for caching: path plus modtime and size.
|
||||
// An edited file gets a new key, so stale metadata is never served.
|
||||
func probeCacheKey(path string, modTimeNanos, size int64) string {
|
||||
return fmt.Sprintf("probe:%s:%d:%d", path, modTimeNanos, size)
|
||||
}
|
||||
|
||||
// parseProbe reads a container and builds a ProbeResult. It reports false when
|
||||
// go-media should defer to ffmpeg, having logged why.
|
||||
func parseProbe(path string, f *os.File, size int64) (ProbeResult, bool) {
|
||||
@@ -280,14 +325,18 @@ func durationFast(path string) (float64, bool) {
|
||||
if !Enabled() {
|
||||
return 0, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
f, info, err := openFileInfo(path)
|
||||
if err != nil {
|
||||
logFallback("duration", path, err)
|
||||
return 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
res, err := media.Duration(f, size)
|
||||
cacheKey := mediacache.FromFileInfo(path, info).String()
|
||||
if d, ok := mediacache.Durations.Get(cacheKey); ok {
|
||||
return d, true
|
||||
}
|
||||
res, err := media.Duration(f, info.Size())
|
||||
if err != nil {
|
||||
logFallback("duration", path, err)
|
||||
return 0, false
|
||||
@@ -296,7 +345,9 @@ func durationFast(path string) (float64, bool) {
|
||||
// Unknown duration is not an error; ffmpeg may still find one.
|
||||
return 0, false
|
||||
}
|
||||
return res.Duration.Seconds(), true
|
||||
d := res.Duration.Seconds()
|
||||
mediacache.Durations.Set(cacheKey, d)
|
||||
return d, true
|
||||
}
|
||||
|
||||
// AudioInfo is the tag, lyrics availability, and cover art FileBrowser reads
|
||||
@@ -311,38 +362,60 @@ 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) })
|
||||
return timed("tags", path, legacy, audioDiffer(withArt), func() (AudioInfo, bool) { return audioFast(path, withArt, true) })
|
||||
}
|
||||
|
||||
func audioFast(path string, withArt bool) (AudioInfo, bool) {
|
||||
func audioFast(path string, withArt, withSidecar bool) (AudioInfo, bool) {
|
||||
if !Enabled() {
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
f, st, err := openFileInfo(path)
|
||||
if err != nil {
|
||||
logFallback("tags", path, err)
|
||||
return AudioInfo{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 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
|
||||
size := st.Size()
|
||||
cacheKey := tagsCacheKey(path, st.ModTime().UnixNano(), size)
|
||||
var info AudioInfo
|
||||
cached := false
|
||||
if !withArt {
|
||||
info, cached = tagsCache.Get(cacheKey)
|
||||
}
|
||||
info := AudioInfo{Tags: audio.Tags, HasLyrics: audio.HasEmbeddedLyrics}
|
||||
if !info.HasLyrics {
|
||||
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 {
|
||||
// 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.
|
||||
@@ -384,7 +457,9 @@ func lyricsFast(path string) (text, format string, ok bool) {
|
||||
|
||||
// EmbeddedSubtitles lists embedded subtitle tracks. go-media supports MKV, WebM,
|
||||
// and MP4 only. Other containers are reported as unsupported, not as "no
|
||||
// subtitles", because go-media returns an empty list for them.
|
||||
// subtitles", because go-media returns an empty list for them. modtime is kept
|
||||
// for API compatibility; the cache key comes from the file's current size and
|
||||
// modification time.
|
||||
// legacy is the ffmpeg subtitle detection, run only in shadow mode.
|
||||
func EmbeddedSubtitles(path string, modtime time.Time, legacy Legacy[[]utils.SubtitleTrack]) ([]utils.SubtitleTrack, bool) {
|
||||
return timed("subtitle list", path, legacy, subtitleListDiff, func() ([]utils.SubtitleTrack, bool) {
|
||||
@@ -399,19 +474,20 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac
|
||||
if !supportsVideoContainer(path) {
|
||||
return nil, false
|
||||
}
|
||||
key := fmt.Sprintf("embedded_subtitles:%s:%d", path, modtime.Unix())
|
||||
if cached, ok := subtitleListCache.Get(key); ok {
|
||||
return cached, true
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
_ = modtime
|
||||
f, st, err := openFileInfo(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle list", path, err)
|
||||
return nil, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := media.Probe(f, size)
|
||||
key := mediacache.FromFileInfo(path, st).String()
|
||||
if cached, ok := mediacache.SubtitleTracks.Get(key); ok {
|
||||
return cached, true
|
||||
}
|
||||
|
||||
info, err := media.Probe(f, st.Size())
|
||||
if err != nil {
|
||||
logFallback("subtitle list", path, err)
|
||||
return nil, false
|
||||
@@ -433,7 +509,7 @@ func embeddedSubtitlesFast(path string, modtime time.Time) ([]utils.SubtitleTrac
|
||||
})
|
||||
}
|
||||
tracks := ffmpeg.BuildSubtitleTracks(streams)
|
||||
subtitleListCache.Set(key, tracks)
|
||||
mediacache.SubtitleTracks.Set(key, tracks)
|
||||
return tracks, true
|
||||
}
|
||||
|
||||
@@ -453,24 +529,19 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) {
|
||||
if !supportsVideoContainer(path) {
|
||||
return "", false
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
}
|
||||
key := fmt.Sprintf("subtitle_content:%s:%d:%d", path, streamIndex, st.ModTime().Unix())
|
||||
if content, ok := subtitleContentCache.Get(key); ok {
|
||||
return content, true
|
||||
}
|
||||
|
||||
f, size, err := openFile(path)
|
||||
f, st, err := openFileInfo(path)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
vtt, err := media.ExtractEmbeddedSubtitle(f, size, streamIndex)
|
||||
key := mediacache.FromFileInfo(path, st).Stream(streamIndex)
|
||||
if content, ok := mediacache.SubtitleContent.Get(key); ok {
|
||||
return content, true
|
||||
}
|
||||
|
||||
vtt, err := media.ExtractEmbeddedSubtitle(f, st.Size(), streamIndex)
|
||||
if err != nil {
|
||||
logFallback("subtitle extract", path, err)
|
||||
return "", false
|
||||
@@ -479,7 +550,7 @@ func extractSubtitleFast(path string, streamIndex int) (string, bool) {
|
||||
logFallback("subtitle extract", path, errors.New("empty subtitle output"))
|
||||
return "", false
|
||||
}
|
||||
subtitleContentCache.Set(key, vtt)
|
||||
mediacache.SubtitleContent.Set(key, vtt)
|
||||
return vtt, true
|
||||
}
|
||||
|
||||
@@ -501,7 +572,7 @@ func videoThumbnailFast(path string, width, height, quality int, seekPercent flo
|
||||
if !Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
if !supportsVideoContainer(path) {
|
||||
if !supportsVideoThumbnail(path) {
|
||||
return nil, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -131,27 +133,78 @@ func TestProbeUsesCache(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o644))
|
||||
|
||||
st, err := os.Stat(path)
|
||||
key, err := mediacache.Stat(path)
|
||||
require.NoError(t, err)
|
||||
key := probeCacheKey(path, st.ModTime().UnixNano(), st.Size())
|
||||
sentinel := ProbeResult{Duration: 123, VideoCodec: "sentinel", AudioCodec: "sentinel", FormatName: "sentinel"}
|
||||
probeCache.Set(key, sentinel)
|
||||
t.Cleanup(func() { probeCache.Delete(key) })
|
||||
// The ffmpeg probe stores into the same shared cache, so this also stands in
|
||||
// for a value ffprobe produced earlier.
|
||||
mediacache.Probes.Set(key.String(), sentinel)
|
||||
t.Cleanup(func() { mediacache.Probes.Delete(key.String()) })
|
||||
|
||||
got, ok := Probe(path, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sentinel, got, "probe must return the cached value for an unchanged file")
|
||||
|
||||
// An edit (new size and mtime) must miss the cached value.
|
||||
require.NoError(t, os.WriteFile(path, append(data, 0), 0o644))
|
||||
future := time.Now().Add(time.Hour)
|
||||
require.NoError(t, os.Chtimes(path, future, future))
|
||||
got, ok = Probe(path, nil)
|
||||
require.True(t, ok)
|
||||
require.NotEqual(t, sentinel, got, "an edited file must be probed again")
|
||||
edited, err := mediacache.Stat(path)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { mediacache.Probes.Delete(edited.String()) })
|
||||
}
|
||||
|
||||
// TestProbeCacheKeyTracksFileIdentity checks that path, modtime, and size all
|
||||
// participate in the cache key, so an edited file is never served stale metadata.
|
||||
func TestProbeCacheKeyTracksFileIdentity(t *testing.T) {
|
||||
base := probeCacheKey("f.mp4", 100, 200)
|
||||
require.Equal(t, base, probeCacheKey("f.mp4", 100, 200), "identical identity must hash the same")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 101, 200), "modtime change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "size change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("g.mp4", 100, 200), "path change must change the key")
|
||||
require.NotEqual(t, base, probeCacheKey("f.mp4", 100, 201), "key must not collide across fields")
|
||||
// TestDurationUsesSharedCache checks that Duration reads and fills the shared
|
||||
// cache that ffmpeg's GetMediaDuration also uses.
|
||||
func TestDurationUsesSharedCache(t *testing.T) {
|
||||
withGate(t, false)
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
data, err := os.ReadFile(fixtureMP4)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o644))
|
||||
key, err := mediacache.Stat(path)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { mediacache.Durations.Delete(key.String()) })
|
||||
|
||||
d, ok := Duration(path, nil)
|
||||
require.True(t, ok)
|
||||
cached, found := mediacache.Durations.Get(key.String())
|
||||
require.True(t, found, "go-media duration must be stored in the shared cache")
|
||||
require.Equal(t, d, cached)
|
||||
|
||||
mediacache.Durations.Set(key.String(), 4242)
|
||||
d, ok = Duration(path, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 4242.0, d, "a cached duration (from either backend) must be reused")
|
||||
}
|
||||
|
||||
// TestSubtitleCachesUseFileIdentity checks the subtitle list cache is keyed by
|
||||
// the file's real size and mtime, not by the caller's modtime argument.
|
||||
func TestSubtitleCachesUseFileIdentity(t *testing.T) {
|
||||
withGate(t, false)
|
||||
path := filepath.Join(t.TempDir(), "sample.mp4")
|
||||
data, err := os.ReadFile(fixtureMP4)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, data, 0o644))
|
||||
key, err := mediacache.Stat(path)
|
||||
require.NoError(t, err)
|
||||
sentinel := []utils.SubtitleTrack{{Name: "sentinel"}}
|
||||
mediacache.SubtitleTracks.Set(key.String(), sentinel)
|
||||
t.Cleanup(func() { mediacache.SubtitleTracks.Delete(key.String()) })
|
||||
|
||||
// The modtime argument is ignored for the key.
|
||||
got, ok := EmbeddedSubtitles(path, time.Unix(1, 0), nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sentinel, got)
|
||||
|
||||
mediacache.SubtitleContent.Set(key.Stream(7), "WEBVTT\n\nsentinel")
|
||||
t.Cleanup(func() { mediacache.SubtitleContent.Delete(key.Stream(7)) })
|
||||
vtt, ok := ExtractSubtitle(path, 7, nil)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "WEBVTT\n\nsentinel", vtt)
|
||||
}
|
||||
|
||||
func TestProbeWebMMatchesFFprobe(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
)
|
||||
|
||||
// heicRotationShadowTimeout bounds the dev-mode ffmpeg comparison probe.
|
||||
const heicRotationShadowTimeout = time.Minute
|
||||
|
||||
// HEICDisplayRotation returns the display rotation that a HEIC/HEIF/AVIF
|
||||
// file's irot property asks for, read from the item properties without
|
||||
// decoding. The value is degrees counter-clockwise in (-180, 180], the same
|
||||
// number ffmpeg prints as "Display Matrix: rotation of ..." for the file. A
|
||||
// file with no irot returns 0 and ok=true. ok is false when go-media is
|
||||
// disabled or cannot read the item properties; the caller then runs the ffmpeg
|
||||
// probe.
|
||||
//
|
||||
// It is installed as ffmpeg's HEIC display rotation reader. In shadow mode the
|
||||
// ffmpeg stderr probe runs for comparison.
|
||||
func HEICDisplayRotation(path string) (float64, bool) {
|
||||
return timed("heic rotation", path, heicRotationLegacy(path), heicRotationDiff, func() (float64, bool) {
|
||||
return heicDisplayRotationFast(path)
|
||||
})
|
||||
}
|
||||
|
||||
// heicRotationLegacy returns the ffmpeg stderr probe for shadow mode, or nil
|
||||
// when ffmpeg is not available.
|
||||
func heicRotationLegacy(path string) Legacy[float64] {
|
||||
if !ShadowEnabled() {
|
||||
return nil
|
||||
}
|
||||
svc := ffmpeg.Get()
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return func() (float64, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), heicRotationShadowTimeout)
|
||||
defer cancel()
|
||||
return svc.GetHEICDisplayMatrixRotation(ctx, path)
|
||||
}
|
||||
}
|
||||
|
||||
// heicDisplayRotationFast reads the primary item's irot with go-media and
|
||||
// converts it to ffmpeg's display rotation.
|
||||
func heicDisplayRotationFast(path string) (float64, bool) {
|
||||
if !Enabled() {
|
||||
return 0, false
|
||||
}
|
||||
// No extension check: the resolved path can have another name (symlink
|
||||
// target). go-media sniffs the content and reports other files unsupported.
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("heic rotation", path, err)
|
||||
return 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
t, err := media.HEIFTransform(f, size)
|
||||
if err != nil {
|
||||
logFallback("heic rotation", path, err)
|
||||
return 0, false
|
||||
}
|
||||
return displayRotation(t), true
|
||||
}
|
||||
|
||||
// displayRotation converts go-media's counter-clockwise irot angle to ffmpeg's
|
||||
// display matrix convention: counter-clockwise degrees normalized to
|
||||
// (-180, 180]. ffmpeg builds the matrix with av_display_rotation_set(-irot)
|
||||
// and prints av_display_rotation_get, so irot 270 is reported as -90. A mirror
|
||||
// does not change the rotation value.
|
||||
func displayRotation(t media.ImageTransform) float64 {
|
||||
r := ((t.Rotation % 360) + 360) % 360
|
||||
if r > 180 {
|
||||
r -= 360
|
||||
}
|
||||
return float64(r)
|
||||
}
|
||||
|
||||
// heicRotationDiff compares go-media's display rotation with ffmpeg's. 180 and
|
||||
// -180 are the same rotation.
|
||||
func heicRotationDiff(fast, legacy float64) []string {
|
||||
norm := func(v float64) float64 {
|
||||
v = math.Mod(v, 360)
|
||||
if v <= -180 {
|
||||
v += 360
|
||||
} else if v > 180 {
|
||||
v -= 360
|
||||
}
|
||||
return v
|
||||
}
|
||||
if math.Abs(norm(fast)-norm(legacy)) > 0.5 {
|
||||
return []string{fmt.Sprintf("display rotation go-media=%.2f ffmpeg=%.2f", fast, legacy)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fixtureAVIFRotated has irot=1 (90° counter-clockwise) and imir axis 0.
|
||||
const fixtureAVIFRotated = "testdata/irot1_imir0.avif"
|
||||
|
||||
// TestDisplayRotation checks the irot to ffmpeg display rotation mapping.
|
||||
func TestDisplayRotation(t *testing.T) {
|
||||
cases := []struct {
|
||||
rotation int
|
||||
want float64
|
||||
}{
|
||||
{0, 0}, {90, 90}, {180, 180}, {270, -90}, {-90, -90}, {360, 0}, {450, 90},
|
||||
}
|
||||
for _, c := range cases {
|
||||
require.Equal(t, c.want, displayRotation(media.ImageTransform{Rotation: c.rotation}), "rotation %d", c.rotation)
|
||||
}
|
||||
require.Equal(t, 90.0, displayRotation(media.ImageTransform{Rotation: 90, Mirror: true}), "a mirror does not change the rotation")
|
||||
}
|
||||
|
||||
// TestHEICDisplayRotationReadsIrot checks the rotation read from a fixture
|
||||
// with irot=1.
|
||||
func TestHEICDisplayRotationReadsIrot(t *testing.T) {
|
||||
withGate(t, false)
|
||||
got, ok := HEICDisplayRotation(fixtureAVIFRotated)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 90.0, got)
|
||||
}
|
||||
|
||||
// TestHEICDisplayRotationFallsBack checks that non-HEIF input, broken input,
|
||||
// and the Always use ffmpeg setting leave the answer to ffmpeg.
|
||||
func TestHEICDisplayRotationFallsBack(t *testing.T) {
|
||||
withGate(t, false)
|
||||
_, ok := HEICDisplayRotation(fixtureMP4)
|
||||
require.False(t, ok, "a non-HEIF file must fall back to the ffmpeg probe")
|
||||
|
||||
junk := filepath.Join(t.TempDir(), "junk.heic")
|
||||
require.NoError(t, os.WriteFile(junk, []byte("not heif"), 0o644))
|
||||
_, ok = HEICDisplayRotation(junk)
|
||||
require.False(t, ok)
|
||||
|
||||
withGate(t, true)
|
||||
_, ok = HEICDisplayRotation(fixtureAVIFRotated)
|
||||
require.False(t, ok, "the Always use ffmpeg setting must skip go-media")
|
||||
}
|
||||
|
||||
// TestHEICDisplayRotationInstalled checks that loading this package installs
|
||||
// go-media as the ffmpeg package's rotation reader.
|
||||
func TestHEICDisplayRotationInstalled(t *testing.T) {
|
||||
withGate(t, false)
|
||||
var svc *ffmpeg.Service // nil: the ffmpeg probe cannot run, so a result proves the reader answered
|
||||
got, ok := svc.HEICDisplayRotation(t.Context(), fixtureAVIFRotated)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 90.0, got)
|
||||
}
|
||||
|
||||
// TestHEICRotationDiff checks the shadow comparison of display rotations.
|
||||
func TestHEICRotationDiff(t *testing.T) {
|
||||
require.Empty(t, heicRotationDiff(-90, -90))
|
||||
require.Empty(t, heicRotationDiff(180, -180))
|
||||
require.Empty(t, heicRotationDiff(0, math.Copysign(0, -1)))
|
||||
require.Len(t, heicRotationDiff(90, -90), 1)
|
||||
}
|
||||
|
||||
// TestAVIFDecodesThroughHEICPath checks that an AVIF file is accepted by the
|
||||
// HEIC decode path.
|
||||
func TestAVIFDecodesThroughHEICPath(t *testing.T) {
|
||||
withGate(t, false)
|
||||
data, ok := HEICJPEG(fixtureAVIFRotated, 80, nil)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, data)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
)
|
||||
|
||||
// keyframeTimeDedupeSec matches go-ffmpeg's ffprobe keyframe probe, which
|
||||
// drops keyframes within 1 ms of the previous one.
|
||||
const keyframeTimeDedupeSec = 0.001
|
||||
|
||||
// keyframeShadowTolerance is how far apart two backends' keyframe times can be
|
||||
// before the shadow comparison reports them. ffprobe prints six decimals.
|
||||
const keyframeShadowTolerance = 0.002
|
||||
|
||||
// KeyframeTimes returns the presentation times, in seconds, of the first video
|
||||
// stream's keyframes, for HLS segment planning. The result is sorted, has no
|
||||
// negative times, and drops keyframes within 1 ms of the previous one, the same
|
||||
// as go-ffmpeg's ffprobe probe. go-media reads only the container index (MP4
|
||||
// stss/stts/ctts, Matroska Cues or block headers), so it is much faster than
|
||||
// demuxing every packet with ffprobe.
|
||||
//
|
||||
// ok is false when go-media is disabled, the container is not one go-media
|
||||
// indexes, or the read fails; the caller then runs ffprobe. ctx cancels the
|
||||
// file reads. legacy is the ffprobe probe, run only in shadow mode.
|
||||
//
|
||||
// go-media can report fewer keyframes than ffprobe (ffprobe's parsers also flag
|
||||
// IDR frames the container index omits, and some Matroska muxers index only
|
||||
// part of the keyframes in Cues). It never reports a keyframe ffprobe would not.
|
||||
func KeyframeTimes(ctx context.Context, path string, legacy Legacy[[]float64]) ([]float64, bool) {
|
||||
return timed("keyframe index", path, legacy, keyframeDiff, func() ([]float64, bool) {
|
||||
return keyframeTimesFast(ctx, path, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// KeyframeTimesWithin is KeyframeTimes limited to keyframes within windowSec
|
||||
// seconds of the first keyframe. It is the pre-flight check that can rule out
|
||||
// stream copy without reading the whole index (Matroska files without Cues).
|
||||
// It has no shadow comparison.
|
||||
func KeyframeTimesWithin(ctx context.Context, path string, windowSec float64) ([]float64, bool) {
|
||||
return timed("keyframe preflight", path, nil, nil, func() ([]float64, bool) {
|
||||
return keyframeTimesFast(ctx, path, windowSec)
|
||||
})
|
||||
}
|
||||
|
||||
// keyframeTimesFast reads the keyframe index with go-media. windowSec > 0
|
||||
// limits it to keyframes within that many seconds of the first one.
|
||||
func keyframeTimesFast(ctx context.Context, path string, windowSec float64) ([]float64, bool) {
|
||||
if !Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
if !supportsVideoContainer(path) {
|
||||
return nil, false
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
f, size, err := openFile(path)
|
||||
if err != nil {
|
||||
logFallback("keyframe index", path, err)
|
||||
return nil, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
r := ctxReaderAt{ctx: ctx, r: f}
|
||||
var times []float64
|
||||
if windowSec > 0 {
|
||||
times, err = media.KeyframeTimesWithin(r, size, windowSec)
|
||||
} else {
|
||||
times, err = media.KeyframeTimes(r, size)
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
logFallback("keyframe index", path, err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
times = dedupeKeyframeTimes(times)
|
||||
if len(times) == 0 {
|
||||
logUnsupported("keyframe index", path, "no keyframes in container index")
|
||||
return nil, false
|
||||
}
|
||||
return times, true
|
||||
}
|
||||
|
||||
// dedupeKeyframeTimes sorts times, drops negative values, and drops times
|
||||
// within keyframeTimeDedupeSec of the previous kept time, as go-ffmpeg does for
|
||||
// ffprobe output.
|
||||
func dedupeKeyframeTimes(times []float64) []float64 {
|
||||
sorted := append([]float64(nil), times...)
|
||||
sort.Float64s(sorted)
|
||||
out := make([]float64, 0, len(sorted))
|
||||
for _, t := range sorted {
|
||||
if t < 0 || math.IsNaN(t) || math.IsInf(t, 0) {
|
||||
continue
|
||||
}
|
||||
if len(out) == 0 || t > out[len(out)-1]+keyframeTimeDedupeSec {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ctxReaderAt fails reads once ctx is done, so a cancelled request or the plan
|
||||
// timeout stops a long index walk on a slow share.
|
||||
type ctxReaderAt struct {
|
||||
ctx context.Context
|
||||
r io.ReaderAt
|
||||
}
|
||||
|
||||
// ReadAt reads from the wrapped reader unless the context is done.
|
||||
func (c ctxReaderAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.r.ReadAt(p, off)
|
||||
}
|
||||
|
||||
// keyframeDiff compares go-media's keyframe index with ffprobe's. go-media
|
||||
// reporting a subset of ffprobe's keyframes is expected (parser-flagged IDR
|
||||
// frames, partial Cues) and is logged at info level.
|
||||
func keyframeDiff(fast, legacy []float64) []string {
|
||||
missing := 0
|
||||
j := 0
|
||||
for _, t := range fast {
|
||||
for j < len(legacy) && legacy[j] < t-keyframeShadowTolerance {
|
||||
j++
|
||||
missing++
|
||||
}
|
||||
if j >= len(legacy) || math.Abs(legacy[j]-t) > keyframeShadowTolerance {
|
||||
return []string{fmt.Sprintf("keyframe at %.6fs from go-media not in ffmpeg index (go-media=%d ffmpeg=%d keyframes)", t, len(fast), len(legacy))}
|
||||
}
|
||||
j++
|
||||
}
|
||||
missing += len(legacy) - j
|
||||
if missing > 0 {
|
||||
return []string{fmt.Sprintf("%sgo-media found %d of %d ffmpeg keyframes (keyframes outside the container index)", expectedDiffPrefix, len(fast), len(legacy))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package gomedia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ffprobeKeyframes runs the same packet probe as go-ffmpeg's
|
||||
// ProbeVideoKeyframeTimes: key packets of v:0, pts_time falling back to
|
||||
// dts_time, sorted and de-duplicated within 1 ms.
|
||||
func ffprobeKeyframes(t *testing.T, path string) []float64 {
|
||||
t.Helper()
|
||||
requireTool(t, "ffprobe")
|
||||
out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "packet=pts_time,dts_time,flags", "-of", "csv=p=0", path).Output()
|
||||
require.NoError(t, err)
|
||||
var times []float64
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(strings.TrimSpace(line), ",")
|
||||
if len(f) < 3 || !strings.Contains(f[2], "K") {
|
||||
continue
|
||||
}
|
||||
v, perr := strconv.ParseFloat(f[0], 64)
|
||||
if perr != nil {
|
||||
v, perr = strconv.ParseFloat(f[1], 64)
|
||||
}
|
||||
if perr == nil {
|
||||
times = append(times, v)
|
||||
}
|
||||
}
|
||||
sort.Float64s(times)
|
||||
return dedupeKeyframeTimes(times)
|
||||
}
|
||||
|
||||
// encodeKeyframeClip makes a short clip with a fixed GOP. B-frames give MP4
|
||||
// files a ctts table and an edit list, the hard case for the timestamp model.
|
||||
func encodeKeyframeClip(t *testing.T, name string, args ...string) string {
|
||||
t.Helper()
|
||||
requireTool(t, "ffmpeg")
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
full := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=6"}
|
||||
full = append(full, args...)
|
||||
full = append(full, path)
|
||||
if out, err := exec.Command("ffmpeg", full...).CombinedOutput(); err != nil {
|
||||
t.Skipf("ffmpeg cannot encode %s: %v: %s", name, err, out)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// requireSameKeyframes fails unless got matches want within the shadow
|
||||
// tolerance.
|
||||
func requireSameKeyframes(t *testing.T, want, got []float64) {
|
||||
t.Helper()
|
||||
require.Equal(t, len(want), len(got), "keyframe count: ffprobe=%v go-media=%v", want, got)
|
||||
for i := range want {
|
||||
require.InDelta(t, want[i], got[i], keyframeShadowTolerance, "keyframe %d: ffprobe=%v go-media=%v", i, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyframeTimesMatchFFprobe compares go-media keyframe indexes with
|
||||
// ffprobe for fixtures and encoded clips, including B-frame MP4/MOV.
|
||||
func TestKeyframeTimesMatchFFprobe(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("encodes clips with ffmpeg")
|
||||
}
|
||||
withGate(t, false)
|
||||
x264 := []string{"-c:v", "libx264", "-g", "50", "-keyint_min", "50", "-sc_threshold", "0", "-bf", "2", "-pix_fmt", "yuv420p"}
|
||||
cases := map[string]string{
|
||||
"fixture.mp4": fixtureMP4,
|
||||
"fixture.webm": fixtureWebM,
|
||||
"bframes.mp4": encodeKeyframeClip(t, "bframes.mp4", x264...),
|
||||
"bframes.mov": encodeKeyframeClip(t, "bframes.mov", x264...),
|
||||
"bframes.mkv": encodeKeyframeClip(t, "bframes.mkv", x264...),
|
||||
"mpeg4.m4v": encodeKeyframeClip(t, "mpeg4.m4v", "-c:v", "mpeg4", "-g", "30", "-f", "mp4"),
|
||||
}
|
||||
for name, path := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
want := ffprobeKeyframes(t, path)
|
||||
got, ok := KeyframeTimes(context.Background(), path, nil)
|
||||
require.True(t, ok)
|
||||
requireSameKeyframes(t, want, got)
|
||||
|
||||
within, ok := KeyframeTimesWithin(context.Background(), path, 2.5)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, within)
|
||||
require.LessOrEqual(t, len(within), len(got))
|
||||
for i := range within {
|
||||
require.Equal(t, got[i], within[i])
|
||||
require.LessOrEqual(t, within[i]-got[0], 2.5+1e-9)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyframeTimesFallsBack checks the cases that must leave the index to
|
||||
// ffprobe.
|
||||
func TestKeyframeTimesFallsBack(t *testing.T) {
|
||||
withGate(t, false)
|
||||
dir := t.TempDir()
|
||||
|
||||
// Not an indexed container: no file open, ffprobe answers.
|
||||
ts := filepath.Join(dir, "clip.ts")
|
||||
require.NoError(t, os.WriteFile(ts, []byte{0x47}, 0o644))
|
||||
_, ok := KeyframeTimes(context.Background(), ts, nil)
|
||||
require.False(t, ok)
|
||||
|
||||
// Indexed extension but not a real container.
|
||||
junk := filepath.Join(dir, "junk.mp4")
|
||||
require.NoError(t, os.WriteFile(junk, []byte("not a video"), 0o644))
|
||||
_, ok = KeyframeTimes(context.Background(), junk, nil)
|
||||
require.False(t, ok)
|
||||
|
||||
// Missing file.
|
||||
_, ok = KeyframeTimes(context.Background(), filepath.Join(dir, "missing.mp4"), nil)
|
||||
require.False(t, ok)
|
||||
|
||||
// Cancelled context.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, ok = KeyframeTimes(ctx, fixtureMP4, nil)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestKeyframeTimesGate checks that the Always use ffmpeg setting disables
|
||||
// the go-media keyframe index.
|
||||
func TestKeyframeTimesGate(t *testing.T) {
|
||||
withGate(t, true)
|
||||
_, ok := KeyframeTimes(context.Background(), fixtureMP4, nil)
|
||||
require.False(t, ok, "KeyframeTimes must skip go-media when the gate is on")
|
||||
_, ok = KeyframeTimesWithin(context.Background(), fixtureMP4, 10)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestDedupeKeyframeTimes checks sorting, filtering, and the 1 ms dedupe.
|
||||
func TestDedupeKeyframeTimes(t *testing.T) {
|
||||
got := dedupeKeyframeTimes([]float64{2, -1, 0, 0.0005, 1, 1.001, 1.0021, math.NaN(), math.Inf(1)})
|
||||
require.Equal(t, []float64{0, 1, 1.0021, 2}, got)
|
||||
require.Empty(t, dedupeKeyframeTimes(nil))
|
||||
}
|
||||
|
||||
// TestCtxReaderAt checks that reads stop once the context is cancelled.
|
||||
func TestCtxReaderAt(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := ctxReaderAt{ctx: ctx, r: strings.NewReader("abcdef")}
|
||||
buf := make([]byte, 3)
|
||||
n, err := r.ReadAt(buf, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "bcd", string(buf[:n]))
|
||||
cancel()
|
||||
_, err = r.ReadAt(buf, 0)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
// TestKeyframeDiff checks the shadow comparison: a subset is expected, an
|
||||
// extra keyframe is a real difference.
|
||||
func TestKeyframeDiff(t *testing.T) {
|
||||
require.Empty(t, keyframeDiff([]float64{0, 2, 4}, []float64{0, 2.001, 4}))
|
||||
|
||||
subset := keyframeDiff([]float64{0, 4}, []float64{0, 2, 4, 6})
|
||||
require.Len(t, subset, 1)
|
||||
require.True(t, strings.HasPrefix(subset[0], expectedDiffPrefix), subset[0])
|
||||
require.Contains(t, subset[0], "2 of 4")
|
||||
|
||||
extra := keyframeDiff([]float64{0, 1, 2}, []float64{0, 2})
|
||||
require.Len(t, extra, 1)
|
||||
require.False(t, strings.HasPrefix(extra[0], expectedDiffPrefix), extra[0])
|
||||
}
|
||||
@@ -59,8 +59,8 @@ func runShadow[T any](op, path string, goLatency time.Duration, fast T, legacy L
|
||||
ffStart := time.Now()
|
||||
legacyVal, ffOK := legacy()
|
||||
ffLatency := time.Since(ffStart)
|
||||
shadowLogf("go-media shadow %s for %s: go-media=%.2fms legacy(ffmpeg/dhowden)=%.2fms diff(legacy-go)=%+.2fms legacy_ok=%t",
|
||||
op, path, ms(goLatency), ms(ffLatency), ms(ffLatency)-ms(goLatency), ffOK)
|
||||
shadowLogf("go-media shadow %s for %s: go-media=%.2fms legacy(ffmpeg/dhowden)=%.2fms diff(go-legacy)=%+.2fms",
|
||||
op, path, ms(goLatency), ms(ffLatency), ms(goLatency)-ms(ffLatency))
|
||||
|
||||
if !ffOK {
|
||||
// go-media read the file but the legacy path failed. That is an outcome
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,109 @@
|
||||
// Package mediacache holds the media metadata caches shared by the ffmpeg and
|
||||
// go-media code paths. Both backends read and write the same entries, so a
|
||||
// value found by one is reused by the other instead of being probed again.
|
||||
//
|
||||
// Every entry is keyed by file identity: path, size, and modification time in
|
||||
// nanoseconds. An edited file gets a new key, so a stale value is never served
|
||||
// for it; the old entry ages out after TTL.
|
||||
package mediacache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
// TTL is how long a cached media value is kept after it is stored.
|
||||
const TTL = 24 * time.Hour
|
||||
|
||||
// ProbeInfo is the container summary both backends produce: ffprobe through
|
||||
// go-ffmpeg, and go-media. FormatName uses ffprobe's format_name strings.
|
||||
type ProbeInfo struct {
|
||||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
FormatName string
|
||||
}
|
||||
|
||||
var (
|
||||
// Probes caches container probes (duration, first video and audio codec,
|
||||
// format name).
|
||||
Probes = cache.NewCache[ProbeInfo](TTL)
|
||||
// Durations caches media length in seconds.
|
||||
Durations = cache.NewCache[float64](TTL)
|
||||
// SubtitleTracks caches the embedded subtitle track list of a video.
|
||||
SubtitleTracks = cache.NewCache[[]utils.SubtitleTrack](TTL)
|
||||
// SubtitleContent caches one embedded subtitle stream converted to WebVTT.
|
||||
// Use Key.Stream for its key.
|
||||
SubtitleContent = cache.NewCache[string](TTL)
|
||||
)
|
||||
|
||||
// Key identifies one version of a file. The zero Key is never produced for a
|
||||
// real file, because Path is always set.
|
||||
type Key struct {
|
||||
Path string
|
||||
Size int64
|
||||
ModTime int64 // modification time in Unix nanoseconds
|
||||
}
|
||||
|
||||
// FromFileInfo returns the Key for path using an already-read FileInfo, so
|
||||
// callers that stat or open the file anyway do not stat it twice.
|
||||
func FromFileInfo(path string, info fs.FileInfo) Key {
|
||||
return Key{Path: path, Size: info.Size(), ModTime: info.ModTime().UnixNano()}
|
||||
}
|
||||
|
||||
// Stat returns the Key for path. It follows symlinks, like os.Stat.
|
||||
func Stat(path string) (Key, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
return FromFileInfo(path, info), nil
|
||||
}
|
||||
|
||||
// String returns the cache key for k. The path is quoted so a path containing
|
||||
// the separator cannot collide with another file's key.
|
||||
func (k Key) String() string {
|
||||
return fmt.Sprintf("%q:%d:%d", k.Path, k.Size, k.ModTime)
|
||||
}
|
||||
|
||||
// Stream returns the cache key for one stream of k, used by SubtitleContent.
|
||||
func (k Key) Stream(index int) string {
|
||||
return fmt.Sprintf("%s:%d", k.String(), index)
|
||||
}
|
||||
|
||||
// ClearAll drops every cached media value. It is used when the backend that
|
||||
// produced the values is switched (the admin "Always use ffmpeg" setting), so
|
||||
// the newly selected backend answers fresh.
|
||||
func ClearAll() {
|
||||
Probes.ClearAll()
|
||||
Durations.ClearAll()
|
||||
SubtitleTracks.ClearAll()
|
||||
SubtitleContent.ClearAll()
|
||||
}
|
||||
|
||||
// bypassKey is the context key set by WithoutCache.
|
||||
type bypassKey struct{}
|
||||
|
||||
// WithoutCache returns a context under which the ffmpeg code paths neither read
|
||||
// nor write the shared caches. Dev-mode shadow runs use it, so the ffmpeg result
|
||||
// they compare against go-media is computed fresh rather than read back from the
|
||||
// value go-media just stored.
|
||||
func WithoutCache(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, bypassKey{}, true)
|
||||
}
|
||||
|
||||
// Bypassed reports whether ctx was returned by WithoutCache. A nil context is
|
||||
// not bypassed.
|
||||
func Bypassed(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := ctx.Value(bypassKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -272,8 +272,9 @@ func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
|
||||
return previewTypeOffice
|
||||
}
|
||||
|
||||
// Check HEIC with conversion enabled
|
||||
if strings.HasPrefix(file.Type, "image/heic") &&
|
||||
// Check HEIC with conversion enabled. AVIF is a HEIF container too and uses
|
||||
// the same path (go-media decode, ffmpeg fallback) under the HEIC setting.
|
||||
if (strings.HasPrefix(file.Type, "image/heic") || file.Type == "image/avif") &&
|
||||
settings.CanConvertImage(string(settings.HEICImagePreview)) {
|
||||
return previewTypeHEIC
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// withHEICConversion sets ffmpeg availability and the HEIC preview setting for
|
||||
// one test.
|
||||
func withHEICConversion(t *testing.T, enabled bool) {
|
||||
t.Helper()
|
||||
prevAvail := settings.Env.FFmpegAvailable
|
||||
prevPreview := settings.Config.Integrations.Media.Convert.ImagePreview
|
||||
t.Cleanup(func() {
|
||||
settings.Env.FFmpegAvailable = prevAvail
|
||||
settings.Config.Integrations.Media.Convert.ImagePreview = prevPreview
|
||||
})
|
||||
settings.Env.FFmpegAvailable = true
|
||||
settings.Config.Integrations.Media.Convert.ImagePreview = map[settings.ImagePreviewType]*bool{
|
||||
settings.HEICImagePreview: &enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// previewFile returns a file info with only a name and MIME type set.
|
||||
func previewFile(name, mimeType string) iteminfo.ExtendedFileInfo {
|
||||
var f iteminfo.ExtendedFileInfo
|
||||
f.Name = name
|
||||
f.Type = mimeType
|
||||
return f
|
||||
}
|
||||
|
||||
// TestDeterminePreviewTypeAVIF checks that AVIF uses the HEIF decode path when
|
||||
// HEIC conversion is enabled, and the plain image path otherwise.
|
||||
func TestDeterminePreviewTypeAVIF(t *testing.T) {
|
||||
withHEICConversion(t, true)
|
||||
if got := determinePreviewType(previewFile("a.avif", "image/avif")); got != previewTypeHEIC {
|
||||
t.Fatalf("avif with HEIC conversion = %v, want previewTypeHEIC", got)
|
||||
}
|
||||
if got := determinePreviewType(previewFile("a.heic", "image/heic")); got != previewTypeHEIC {
|
||||
t.Fatalf("heic with HEIC conversion = %v, want previewTypeHEIC", got)
|
||||
}
|
||||
|
||||
withHEICConversion(t, false)
|
||||
if got := determinePreviewType(previewFile("a.avif", "image/avif")); got != previewTypeImage {
|
||||
t.Fatalf("avif without HEIC conversion = %v, want previewTypeImage", got)
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,11 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// go-media fast path: fit within the box, no crop. Falls back to ffmpeg when
|
||||
// unsupported (e.g. HEVC, VP9, AV1) or failed.
|
||||
// go-media fast path: fit within the box, no crop. go-media decodes H.264,
|
||||
// HEVC, VP8, VP9, AV1, MPEG-4 Part 2, and WMV3 keyframes in pure Go. It falls
|
||||
// back to ffmpeg when the container is not a go-media target (AVI, MPEG-TS,
|
||||
// MPEG-PS, ...), the codec or profile is unsupported (WMV1/WMV2, VC-1
|
||||
// Advanced, AV1 above 4K, ...), or the decode fails.
|
||||
// In shadow mode (dev only) the ffmpeg preview also runs, for comparison.
|
||||
// The shadow run can outlive the request, so it uses a context that is not cancelled with it.
|
||||
shadowCtx := context.WithoutCancel(ctx)
|
||||
|
||||
@@ -14,14 +14,21 @@ const (
|
||||
|
||||
var (
|
||||
mediaSettingsMu sync.RWMutex
|
||||
mediaSettingsDefault settings.MediaSettings
|
||||
mediaSettingsDefault settings.FullAdminMedia
|
||||
)
|
||||
|
||||
// saveMediaSettings is the only write path for media settings. It clears derived
|
||||
// fields (EnabledProfiles, ImagePreview) so read-time values are never persisted.
|
||||
func saveMediaSettings(key string, ms settings.FullAdminMedia) error {
|
||||
settings.ClearMediaDerived(&ms)
|
||||
return sqlDb.SaveSetting(key, ms)
|
||||
}
|
||||
|
||||
// InitMediaSettings loads persisted runtime media settings from SQLite and seeds from config when missing.
|
||||
func InitMediaSettings() error {
|
||||
if _, err := sqlDb.GetSetting(mediaSettingsDefaultKey); err != nil {
|
||||
seed := settings.SanitizeMediaSettings(settings.CurrentMediaSettings())
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, seed); saveErr != nil {
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, seed); saveErr != nil {
|
||||
return fmt.Errorf("seed media settings: %w", saveErr)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +50,7 @@ func InitMediaSettings() error {
|
||||
// sanitize, so clamps (e.g. the readRate floor) must run again.
|
||||
overlaid = settings.SanitizeMediaSettings(overlaid)
|
||||
if !settings.MediaSettingsEqual(loaded, overlaid) {
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, overlaid); saveErr != nil {
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, overlaid); saveErr != nil {
|
||||
return fmt.Errorf("save config-synced media settings: %w", saveErr)
|
||||
}
|
||||
loaded = overlaid
|
||||
@@ -57,22 +64,22 @@ func InitMediaSettings() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMediaSettingsSetting(key string) (settings.MediaSettings, error) {
|
||||
func loadMediaSettingsSetting(key string) (settings.FullAdminMedia, error) {
|
||||
raw, err := sqlDb.GetSetting(key)
|
||||
if err != nil {
|
||||
return settings.MediaSettings{}, err
|
||||
return settings.FullAdminMedia{}, err
|
||||
}
|
||||
var ms settings.MediaSettings
|
||||
var ms settings.FullAdminMedia
|
||||
if err := json.Unmarshal(raw, &ms); err != nil {
|
||||
return settings.MediaSettings{}, fmt.Errorf("parse %s: %w", key, err)
|
||||
return settings.FullAdminMedia{}, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// GetMediaSettings returns the runtime media settings. The value is deep-copied
|
||||
// GetMediaSettings returns the stored runtime media settings. The value is deep-copied
|
||||
// so callers cannot mutate the stored settings (or the live config they are
|
||||
// applied to) through the conversion maps.
|
||||
func GetMediaSettings() settings.MediaSettings {
|
||||
func GetMediaSettings() settings.FullAdminMedia {
|
||||
mediaSettingsMu.RLock()
|
||||
defer mediaSettingsMu.RUnlock()
|
||||
return settings.CloneMediaSettings(mediaSettingsDefault)
|
||||
@@ -90,8 +97,11 @@ func PatchMediaSettings(patchJSON []byte) error {
|
||||
return mergeErr
|
||||
}
|
||||
merged = settings.SanitizeMediaSettings(merged)
|
||||
// A patch may echo derived fields back (e.g. imagePreview from the admin UI).
|
||||
// Clear them before the value reaches memory or the database.
|
||||
settings.ClearMediaDerived(&merged)
|
||||
|
||||
if saveErr := sqlDb.SaveSetting(mediaSettingsDefaultKey, merged); saveErr != nil {
|
||||
if saveErr := saveMediaSettings(mediaSettingsDefaultKey, merged); saveErr != nil {
|
||||
mediaSettingsMu.Unlock()
|
||||
return fmt.Errorf("save media settings: %w", saveErr)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
@@ -34,6 +35,56 @@ func TestGetMediaSettingsReturnsIndependentCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Derived read-time values must never reach the database, even when a caller
|
||||
// passes a struct that has them populated.
|
||||
func TestSaveMediaSettingsNeverPersistsDerivedFields(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
ms := GetMediaSettings()
|
||||
ms.Transcode.EnabledProfiles = []string{"quality"}
|
||||
ms.ImagePreview = map[settings.ImagePreviewType]bool{settings.JPEGImagePreview: true}
|
||||
if err := saveMediaSettings(mediaSettingsDefaultKey, ms); err != nil {
|
||||
t.Fatalf("saveMediaSettings: %v", err)
|
||||
}
|
||||
|
||||
raw, err := sqlDb.GetSetting(mediaSettingsDefaultKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSetting: %v", err)
|
||||
}
|
||||
var stored map[string]any
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatalf("decode stored settings: %v", err)
|
||||
}
|
||||
transcode, ok := stored["transcode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("stored transcode = %T, want object", stored["transcode"])
|
||||
}
|
||||
if _, ok := transcode["enabledProfiles"]; ok {
|
||||
t.Fatal("enabledProfiles (derived) was persisted")
|
||||
}
|
||||
if _, ok := stored["imagePreview"]; ok {
|
||||
t.Fatal("imagePreview (derived) was persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// The admin UI echoes its whole payload back on PATCH, derived fields included.
|
||||
// Those must be dropped before the value reaches memory, not only before the database.
|
||||
func TestPatchMediaSettingsDropsEchoedDerivedFields(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
patch := []byte(`{"imagePreview":{"jpeg":true},"transcode":{"enabledProfiles":["quality"]}}`)
|
||||
if err := PatchMediaSettings(patch); err != nil {
|
||||
t.Fatalf("PatchMediaSettings: %v", err)
|
||||
}
|
||||
got := GetMediaSettings()
|
||||
if got.ImagePreview != nil {
|
||||
t.Fatal("echoed imagePreview stayed in memory")
|
||||
}
|
||||
if got.Transcode.EnabledProfiles != nil {
|
||||
t.Fatal("echoed enabledProfiles stayed in memory")
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh install seeds the stored media settings from config.
|
||||
func TestMediaSettingsSeedFromConfigOnFirstInit(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
@@ -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, true)
|
||||
rawKeyframes, kfErr := probeKeyframesCached(ctx, svc, realPath, stat.Size(), stat.ModTime().UnixNano(), plan.segmentSec, mediaSpan{durationSec: info.Duration, startTimeSec: info.StartTime}, true)
|
||||
if kfErr != nil {
|
||||
copyImpossible = true
|
||||
logger.Warningf("transcode: keyframe probe failed on %s (%v) — forcing video transcode for HLS", realPath, kfErr)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -55,6 +56,9 @@ type keyframeCache struct {
|
||||
|
||||
var defaultKeyframeCache = &keyframeCache{entries: map[keyframeKey]*keyframeEntry{}}
|
||||
|
||||
// get returns the keyframe timeline for key from memory, then disk, then
|
||||
// probe. Concurrent callers for one key wait on a single probe; failures are
|
||||
// not cached.
|
||||
func (c *keyframeCache) get(ctx context.Context, key keyframeKey, probe func(context.Context) ([]float64, error)) ([]float64, error) {
|
||||
c.mu.Lock()
|
||||
e := c.entries[key]
|
||||
@@ -105,12 +109,15 @@ func keyframeDiskCacheEnabled() bool {
|
||||
return settings.Config.Server.CacheDir != ""
|
||||
}
|
||||
|
||||
// keyframeDiskPath returns the disk cache file for key's path.
|
||||
func keyframeDiskPath(key keyframeKey) string {
|
||||
sum := sha256.Sum256([]byte(key.path))
|
||||
name := hex.EncodeToString(sum[:])[:32] + ".json"
|
||||
return filepath.Join(settings.TranscodeKeyframeCacheDir(), name)
|
||||
}
|
||||
|
||||
// readKeyframeDiskCache returns the persisted timeline for key when it exists
|
||||
// and matches the file's size, mtime, and the cache version.
|
||||
func readKeyframeDiskCache(key keyframeKey) ([]float64, bool) {
|
||||
if !keyframeDiskCacheEnabled() {
|
||||
return nil, false
|
||||
@@ -132,6 +139,8 @@ func readKeyframeDiskCache(key keyframeKey) ([]float64, bool) {
|
||||
return stamp.Times, true
|
||||
}
|
||||
|
||||
// writeKeyframeDiskCache persists a timeline for key atomically. Errors are
|
||||
// ignored; the cache is an optimization.
|
||||
func writeKeyframeDiskCache(key keyframeKey, times []float64) {
|
||||
if len(times) == 0 || !keyframeDiskCacheEnabled() {
|
||||
return
|
||||
@@ -219,19 +228,88 @@ 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
|
||||
|
||||
func probeKeyframesCached(ctx context.Context, svc *ffmpeg.Service, realPath string, size, modTime int64, segmentSec float64, preflight bool) ([]float64, error) {
|
||||
// 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) {
|
||||
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,3 +177,33 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
@@ -123,7 +124,8 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
}
|
||||
// Shadow ffmpeg runs after the fast path; bound it so a detached request cannot
|
||||
// leave extraction running indefinitely (sync fallback still uses r.Context()).
|
||||
legacyCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), time.Minute)
|
||||
// WithoutCache: compare against a fresh extraction, not the value go-media stored.
|
||||
legacyCtx, cancel := context.WithTimeout(mediacache.WithoutCache(context.WithoutCancel(r.Context())), time.Minute)
|
||||
defer cancel()
|
||||
vtt, extractErr := svc.ExtractSubtitle(legacyCtx, fileInfo.RealPath, *track.Index)
|
||||
return vtt, extractErr == nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
@@ -19,11 +20,13 @@ func withMediaHandlerSettings(t *testing.T) {
|
||||
settings.Env.FFmpegAvailable = originalFFmpeg
|
||||
})
|
||||
settings.Env.FFmpegAvailable = true
|
||||
settings.Config.Integrations.Media.Transcode = settings.MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 3,
|
||||
MaxConcurrentPerUser: 2,
|
||||
Profiles: settings.DefaultTranscodeProfiles(),
|
||||
settings.Config.Integrations.Media.Transcode = settings.FullAdminTranscode{
|
||||
UserTranscode: settings.UserTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrentPerUser: 2,
|
||||
},
|
||||
MaxConcurrent: 3,
|
||||
Profiles: settings.DefaultTranscodeProfiles(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,52 +55,99 @@ func callMediaGet(t *testing.T, admin bool) map[string]any {
|
||||
return body
|
||||
}
|
||||
|
||||
// Non-admin users must be able to read the effective media values for the
|
||||
// player, but only the player-relevant subset.
|
||||
// objectField returns obj[key] as a JSON object, failing the test if it is missing or not an object.
|
||||
func objectField(t *testing.T, obj map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
child, ok := obj[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s is %T, want object", key, obj[key])
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
// keysOf returns the sorted keys of a JSON object.
|
||||
func keysOf(obj map[string]any) []string {
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, path string, got []string, want []string) {
|
||||
t.Helper()
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s keys = %v, want %v", path, got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s keys = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-admin users receive exactly the PUBLIC payload. Any new key fails this test,
|
||||
// which forces a conscious decision about whether it belongs in UserMedia.
|
||||
func TestSettingsMediaHandlerPlayerPayloadForNonAdmin(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
|
||||
body := callMediaGet(t, false)
|
||||
if _, ok := body["transcode"]; ok {
|
||||
t.Fatal("non-admin must not receive the full transcode settings")
|
||||
assertKeys(t, "top-level", keysOf(body), []string{"transcode", "imagePreview"})
|
||||
transcode := objectField(t, body, "transcode")
|
||||
assertKeys(t, "transcode", keysOf(transcode), []string{"enabled", "maxConcurrentPerUser", "enabledProfiles"})
|
||||
|
||||
if enabled, ok := transcode["enabled"].(bool); !ok || !enabled {
|
||||
t.Fatalf("transcode.enabled = %v, want true", transcode["enabled"])
|
||||
}
|
||||
if _, ok := body["lockedFromConfigPaths"]; ok {
|
||||
t.Fatal("non-admin must not receive config lock metadata")
|
||||
if limit, ok := transcode["maxConcurrentPerUser"].(float64); !ok || limit != 2 {
|
||||
t.Fatalf("transcode.maxConcurrentPerUser = %v, want 2", transcode["maxConcurrentPerUser"])
|
||||
}
|
||||
if _, ok := body["lockMessage"]; ok {
|
||||
t.Fatal("non-admin must not receive the config lock message")
|
||||
}
|
||||
if enabled, ok := body["enabled"].(bool); !ok || !enabled {
|
||||
t.Fatalf("enabled = %v, want true", body["enabled"])
|
||||
}
|
||||
if limit, ok := body["maxConcurrentPerUser"].(float64); !ok || limit != 2 {
|
||||
t.Fatalf("maxConcurrentPerUser = %v, want 2", body["maxConcurrentPerUser"])
|
||||
}
|
||||
profiles, ok := body["profiles"].([]any)
|
||||
profiles, ok := transcode["enabledProfiles"].([]any)
|
||||
if !ok || len(profiles) != 3 {
|
||||
t.Fatalf("profiles = %v, want 3 enabled ids", body["profiles"])
|
||||
t.Fatalf("transcode.enabledProfiles = %v, want 3 enabled ids", transcode["enabledProfiles"])
|
||||
}
|
||||
}
|
||||
|
||||
// Admins receive the full stored config. The keys shared with users must have the
|
||||
// same names, so the admin payload is a strict superset of the user payload.
|
||||
func TestSettingsMediaHandlerFullPayloadForAdmin(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
|
||||
body := callMediaGet(t, true)
|
||||
transcode, ok := body["transcode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("admin must receive the full transcode settings, got %T", body["transcode"])
|
||||
}
|
||||
if _, ok := transcode["profiles"].(map[string]any); !ok {
|
||||
t.Fatalf("admin transcode payload must include profiles, got %v", transcode["profiles"])
|
||||
}
|
||||
// Admin payload exposes the full settings surface, not the player subset.
|
||||
for _, key := range []string{"convert", "debug", "hardwareAcceleration"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
admin := callMediaGet(t, true)
|
||||
user := callMediaGet(t, false)
|
||||
|
||||
// lockedFromConfigPaths is omitted when config pins nothing, so it is not required here.
|
||||
for _, key := range []string{"transcode", "imagePreview", "convert", "debug", "hardwareAcceleration", "accelDevice"} {
|
||||
if _, ok := admin[key]; !ok {
|
||||
t.Fatalf("admin payload missing %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := body["enabled"]; ok {
|
||||
t.Fatal("the admin payload uses the full settings shape, not the player subset")
|
||||
if _, ok := admin["enabled"]; ok {
|
||||
t.Fatal("admin payload must not repeat transcode.enabled at the top level")
|
||||
}
|
||||
|
||||
adminTranscode := objectField(t, admin, "transcode")
|
||||
for _, key := range []string{"maxConcurrent", "profiles", "cacheMaxSizeMB", "encoderBackend", "readRate"} {
|
||||
if _, ok := adminTranscode[key]; !ok {
|
||||
t.Fatalf("admin transcode missing %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := adminTranscode["profiles"].(map[string]any); !ok {
|
||||
t.Fatalf("admin transcode.profiles must be an object, got %T", adminTranscode["profiles"])
|
||||
}
|
||||
|
||||
// Superset rule: every key a user receives must also exist for admins. Values may
|
||||
// differ by design: admins see stored config (so the UI can save it back unchanged),
|
||||
// users see effective values (for example enabled is false when ffmpeg is missing).
|
||||
for key := range objectField(t, user, "transcode") {
|
||||
if _, ok := adminTranscode[key]; !ok {
|
||||
t.Fatalf("admin transcode missing shared key %q", key)
|
||||
}
|
||||
}
|
||||
if admin["imagePreview"] == nil {
|
||||
t.Fatal("imagePreview must be present for admins")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +156,8 @@ func TestSettingsMediaHandlerReflectsDisabledTranscode(t *testing.T) {
|
||||
withMediaHandlerSettings(t)
|
||||
settings.Config.Integrations.Media.Transcode.Enabled = false
|
||||
|
||||
body := callMediaGet(t, false)
|
||||
if enabled, ok := body["enabled"].(bool); !ok || enabled {
|
||||
t.Fatalf("enabled = %v, want false", body["enabled"])
|
||||
transcode := objectField(t, callMediaGet(t, false), "transcode")
|
||||
if enabled, ok := transcode["enabled"].(bool); !ok || enabled {
|
||||
t.Fatalf("transcode.enabled = %v, want false", transcode["enabled"])
|
||||
}
|
||||
}
|
||||
@@ -453,33 +453,17 @@ func settingsShareDefaultsPatchHandler(w http.ResponseWriter, r *http.Request, d
|
||||
return settingsShareDefaultsGetHandler(w, r, d)
|
||||
}
|
||||
|
||||
// settingsMediaGetHandler returns the runtime media settings.
|
||||
// @Summary Get runtime media settings
|
||||
// @Description Admins receive the DB-backed media integration settings; every authenticated user
|
||||
// @Description receives the player-relevant subset (transcode enabled, profiles, per-user limit).
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} mediaSettingsResponse "Media settings"
|
||||
// @Router /api/settings/media [get]
|
||||
// mediaSettingsResponse is the runtime media settings plus config-lock metadata.
|
||||
// mediaSettingsResponse is the ADMIN ONLY media payload: the full stored settings
|
||||
// (FullAdminMedia, which embeds the public UserTranscode fields) plus config-lock
|
||||
// metadata and ffmpeg option lists. Non-admins never receive this type.
|
||||
type mediaSettingsResponse struct {
|
||||
settings.MediaSettings
|
||||
settings.FullAdminMedia
|
||||
LockedFromConfigPaths []string `json:"lockedFromConfigPaths,omitempty"`
|
||||
LockMessage string `json:"lockMessage,omitempty"`
|
||||
AccelDeviceOptions []ffmpeg.AccelDeviceOption `json:"accelDeviceOptions,omitempty"`
|
||||
EncoderBackendOptions []ffmpeg.EncoderBackendOption `json:"encoderBackendOptions,omitempty"`
|
||||
}
|
||||
|
||||
// mediaPlayerSettings is the player-relevant subset of the runtime media
|
||||
// settings. It is safe to expose to any authenticated user so the player can
|
||||
// reflect admin changes without a page reload.
|
||||
type mediaPlayerSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Profiles []string `json:"profiles"`
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"`
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"`
|
||||
}
|
||||
|
||||
func mediaSettingsLockMeta() (paths []string, message string) {
|
||||
paths = settings.ConfigSpecifiedMediaPaths()
|
||||
if len(paths) == 0 {
|
||||
@@ -491,12 +475,25 @@ func mediaSettingsLockMeta() (paths []string, message string) {
|
||||
// The media settings APIs no longer require ffmpeg. go-media serves probing,
|
||||
// metadata, subtitles, and video thumbnails without it. ffmpeg-only controls
|
||||
// (transcoding, hardware acceleration) report their own availability.
|
||||
// @Summary Get runtime media settings
|
||||
// @Description Returns the media settings the caller is allowed to see.
|
||||
// @Description Admins get the full settings, including ffmpeg and hardware options, so they can edit them.
|
||||
// @Description Everyone else gets a small read-only view: whether transcoding is on, how many
|
||||
// @Description transcodes they can run at once, which quality presets are enabled, and which image
|
||||
// @Description previews the server can generate. Keys in the view are the same for admins and users.
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} mediaSettingsResponse "Media settings"
|
||||
// @Router /api/settings/media [get]
|
||||
func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
// PUBLIC payload: non-admins get only what the player and general UI read.
|
||||
if !d.User.Permissions.Admin {
|
||||
return RenderJSON(w, r, currentMediaPlayerSettings())
|
||||
return RenderJSON(w, r, settings.CurrentUserMedia())
|
||||
}
|
||||
// ADMIN payload: full stored settings with effective and derived values filled in
|
||||
// by PrepMediaForFrontend (a read-only copy; never saved).
|
||||
media := settings.PrepMediaForFrontend(state.GetMediaSettings())
|
||||
lockedPaths, lockMessage := mediaSettingsLockMeta()
|
||||
media := state.GetMediaSettings()
|
||||
svc := ffmpeg.Get()
|
||||
var accelDeviceOptions []ffmpeg.AccelDeviceOption
|
||||
var encoderBackendOptions []ffmpeg.EncoderBackendOption
|
||||
@@ -506,7 +503,7 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
|
||||
encoderBackendOptions = svc.EncoderBackendOptions()
|
||||
}
|
||||
return RenderJSON(w, r, mediaSettingsResponse{
|
||||
MediaSettings: media,
|
||||
FullAdminMedia: media,
|
||||
LockedFromConfigPaths: lockedPaths,
|
||||
LockMessage: lockMessage,
|
||||
AccelDeviceOptions: accelDeviceOptions,
|
||||
@@ -514,17 +511,6 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
|
||||
})
|
||||
}
|
||||
|
||||
// currentMediaPlayerSettings reads the live media settings for the player UI.
|
||||
func currentMediaPlayerSettings() mediaPlayerSettings {
|
||||
media := state.GetMediaSettings()
|
||||
return mediaPlayerSettings{
|
||||
Enabled: settings.TranscodeEnabled(),
|
||||
Profiles: settings.EnabledTranscodeProfiles(),
|
||||
MaxConcurrentPerUser: settings.TranscodeMaxConcurrentPerUser(),
|
||||
HardwareAcceleration: media.HardwareAcceleration,
|
||||
}
|
||||
}
|
||||
|
||||
// settingsMediaPatchHandler merges a partial update into the runtime media settings.
|
||||
// @Summary Update runtime media settings
|
||||
// @Description Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.
|
||||
|
||||
@@ -151,7 +151,13 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
externalLinks = newExternalLinks
|
||||
}
|
||||
|
||||
defaultThemeColor := settings.DefaultBackgroundColor()
|
||||
// Instance default dark mode: htmlVars/defaultDark use a boolean for first paint;
|
||||
// globalVars.darkMode carries the full preference (including "auto") for the SPA.
|
||||
defaultDark := settings.DefaultDarkMode()
|
||||
defaultThemeColor := settings.Config.Frontend.Styling.LightBackground
|
||||
if defaultDark {
|
||||
defaultThemeColor = settings.Config.Frontend.Styling.DarkBackground
|
||||
}
|
||||
staticURL := settings.Config.Http.BaseURL + "public/static"
|
||||
description := settings.Config.Frontend.Description
|
||||
title := settings.Config.Frontend.Name
|
||||
@@ -276,57 +282,51 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// only include constant values that don't change during a users session
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
"name": settings.Config.Frontend.Name,
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
|
||||
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": settings.Config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !settings.Config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(settings.Config.Http.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": onlyOfficeUrlForSPA(),
|
||||
"oidcAvailable": settings.Config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": settings.Config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": settings.Config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": settings.Config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"passwordMinLength": settings.PasswordMinLength(),
|
||||
"ldapAvailable": settings.Config.Auth.Methods.LdapAuth.Enabled,
|
||||
"ffmpegAvailable": settings.FFmpegEnabled(),
|
||||
"hardwareAccelerationAvailable": settings.HardwareAccelerationSupported(),
|
||||
"transcodeEnabled": settings.TranscodeEnabled(),
|
||||
"transcodeProfiles": settings.EnabledTranscodeProfiles(),
|
||||
"transcodeMaxPerUser": settings.TranscodeMaxConcurrentPerUser(),
|
||||
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
|
||||
"enableHeicConversion": settings.CanConvertImage("heic"),
|
||||
"enableJxlConversion": settings.CanConvertImage("jxl"),
|
||||
"eventBasedThemes": !settings.Config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": settings.Config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": settings.Config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
"recaptcha": recaptchaEnabled,
|
||||
"recaptchaHost": recaptchaHost,
|
||||
"recaptchaKey": recaptchaKey,
|
||||
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
|
||||
"devMode": settings.Env.IsDevMode,
|
||||
"playwrightTest": settings.Env.IsPlaywright,
|
||||
"officeProduct": officeProductForSPA(),
|
||||
"officeExtensions": wopiExtensionsForSPA(),
|
||||
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
|
||||
"name": settings.Config.Frontend.Name,
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.DefaultDarkModePreference(),
|
||||
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": settings.Config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !settings.Config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(settings.Config.Http.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": onlyOfficeUrlForSPA(),
|
||||
"oidcAvailable": settings.Config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": settings.Config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": settings.Config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": settings.Config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"passwordMinLength": settings.PasswordMinLength(),
|
||||
"ldapAvailable": settings.Config.Auth.Methods.LdapAuth.Enabled,
|
||||
"ffmpegAvailable": settings.FFmpegEnabled(),
|
||||
"docPreviewExtensions": iteminfo.DocPreviewExtensionsForSPA(),
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": settings.Config.Frontend.Styling.CustomThemeOptions,
|
||||
"eventBasedThemes": !settings.Config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": settings.Config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": settings.Config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
"recaptcha": recaptchaEnabled,
|
||||
"recaptchaHost": recaptchaHost,
|
||||
"recaptchaKey": recaptchaKey,
|
||||
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
|
||||
"devMode": settings.Env.IsDevMode,
|
||||
"playwrightTest": settings.Env.IsPlaywright,
|
||||
"officeProduct": officeProductForSPA(),
|
||||
"officeExtensions": wopiExtensionsForSPA(),
|
||||
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
|
||||
}
|
||||
|
||||
cspNonce, err := utils.CSPNonce()
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestTranscodeManagerIsWiredWithoutFFmpeg(t *testing.T) {
|
||||
// Startup with ffmpeg missing and transcoding disabled.
|
||||
settings.Env.FFmpegAvailable = false
|
||||
disabled := originalMedia
|
||||
disabled.Transcode = settings.MediaTranscode{Enabled: false}
|
||||
disabled.Transcode = settings.FullAdminTranscode{UserTranscode: settings.UserTranscode{Enabled: false}}
|
||||
settings.ApplyMediaSettings(disabled)
|
||||
|
||||
setupTestEnv(t)
|
||||
|
||||
@@ -428,7 +428,7 @@ const (
|
||||
// clampTranscodeReadRate enforces throttled producers: unset or sub-realtime
|
||||
// read rates are raised, so a producer is never left unthrottled. Burst and
|
||||
// catch-up pacing are left to ffmpeg's defaults.
|
||||
func clampTranscodeReadRate(t *MediaTranscode) {
|
||||
func clampTranscodeReadRate(t *FullAdminTranscode) {
|
||||
if t.ReadRate <= 0 {
|
||||
t.ReadRate = defaultTranscodeReadRate
|
||||
} else if t.ReadRate < minTranscodeReadRate {
|
||||
@@ -1075,14 +1075,16 @@ func SetDefaults(generate bool) Settings {
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
|
||||
}
|
||||
s.Integrations.Media.Transcode = MediaTranscode{
|
||||
Enabled: false,
|
||||
MaxConcurrent: 2,
|
||||
MaxConcurrentPerUser: 1,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
ReadRate: defaultTranscodeReadRate,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
s.Integrations.Media.Transcode = FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{
|
||||
Enabled: false,
|
||||
MaxConcurrentPerUser: 1,
|
||||
},
|
||||
MaxConcurrent: 2,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
ReadRate: defaultTranscodeReadRate,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
}
|
||||
// Hardware acceleration is opt-out: "auto" lets go-ffmpeg follow its device
|
||||
// hierarchy (integrated GPU, discrete GPU, then software).
|
||||
|
||||
@@ -331,7 +331,7 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false,
|
||||
expectFull: true, // all fields shown
|
||||
expectComments: false, // no comments
|
||||
|
||||
@@ -68,14 +68,14 @@ func MediaPatchJSONForPaths(source any, paths []string) ([]byte, error) {
|
||||
|
||||
// ApplyConfigSpecifiedPathsToMedia overlays config media values onto stored settings for the
|
||||
// paths the config file pins, so a config value always wins over the stored one.
|
||||
func ApplyConfigSpecifiedPathsToMedia(stored MediaSettings, config Media) (MediaSettings, error) {
|
||||
func ApplyConfigSpecifiedPathsToMedia(stored FullAdminMedia, config Media) (FullAdminMedia, error) {
|
||||
paths := ConfigSpecifiedMediaPaths()
|
||||
if len(paths) == 0 {
|
||||
return stored, nil
|
||||
}
|
||||
patchJSON, err := MediaPatchJSONForPaths(config, paths)
|
||||
if err != nil {
|
||||
return MediaSettings{}, err
|
||||
return FullAdminMedia{}, err
|
||||
}
|
||||
if len(patchJSON) == 0 || string(patchJSON) == "{}" || string(patchJSON) == "null" {
|
||||
return stored, nil
|
||||
|
||||
@@ -60,11 +60,11 @@ func TestValidateMediaPatchNotConfigLockedWithoutLocks(t *testing.T) {
|
||||
func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
withConfigMediaPaths(t, "hardwareAcceleration", "transcode.profiles.datasaver.avgBitrateKbps")
|
||||
|
||||
stored := MediaSettings{
|
||||
stored := FullAdminMedia{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true},
|
||||
Profiles: TranscodeProfiles{
|
||||
DataSaver: TranscodeProfile{AvgBitrateKbps: 1500},
|
||||
},
|
||||
@@ -72,7 +72,7 @@ func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
}
|
||||
config := Media{
|
||||
HardwareAcceleration: false,
|
||||
Transcode: MediaTranscode{
|
||||
Transcode: FullAdminTranscode{
|
||||
Profiles: TranscodeProfiles{
|
||||
DataSaver: TranscodeProfile{AvgBitrateKbps: 800},
|
||||
},
|
||||
@@ -97,7 +97,7 @@ func TestApplyConfigSpecifiedPathsToMedia(t *testing.T) {
|
||||
func TestApplyConfigSpecifiedPathsToMediaNoLocks(t *testing.T) {
|
||||
withConfigMediaPaths(t)
|
||||
|
||||
stored := MediaSettings{HardwareAcceleration: true}
|
||||
stored := FullAdminMedia{HardwareAcceleration: true}
|
||||
overlaid, err := ApplyConfigSpecifiedPathsToMedia(stored, Media{HardwareAcceleration: false})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyConfigSpecifiedPathsToMedia: %v", err)
|
||||
|
||||
@@ -7,18 +7,18 @@ import (
|
||||
)
|
||||
|
||||
// MergeMediaSettingsPatchJSON merges a partial JSON patch into base media settings.
|
||||
func MergeMediaSettingsPatchJSON(base MediaSettings, patchJSON []byte) (MediaSettings, error) {
|
||||
func MergeMediaSettingsPatchJSON(base FullAdminMedia, patchJSON []byte) (FullAdminMedia, error) {
|
||||
baseBytes, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
return MediaSettings{}, err
|
||||
return FullAdminMedia{}, err
|
||||
}
|
||||
mergedBytes, err := mergeUserDefaultsJSON(baseBytes, patchJSON)
|
||||
if err != nil {
|
||||
return MediaSettings{}, err
|
||||
return FullAdminMedia{}, err
|
||||
}
|
||||
var merged MediaSettings
|
||||
var merged FullAdminMedia
|
||||
if err := json.Unmarshal(mergedBytes, &merged); err != nil {
|
||||
return MediaSettings{}, fmt.Errorf("unmarshal merged media settings: %w", err)
|
||||
return FullAdminMedia{}, fmt.Errorf("unmarshal merged media settings: %w", err)
|
||||
}
|
||||
// An explicit accelDevice wins over the legacy hardwareAcceleration bool in
|
||||
// the base settings, which would otherwise force the device back to software.
|
||||
@@ -35,7 +35,7 @@ func MergeMediaSettingsPatchJSON(base MediaSettings, patchJSON []byte) (MediaSet
|
||||
}
|
||||
|
||||
// MediaSettingsEqual reports whether two runtime media settings are equivalent.
|
||||
func MediaSettingsEqual(a, b MediaSettings) bool {
|
||||
func MediaSettingsEqual(a, b FullAdminMedia) bool {
|
||||
ab, errA := json.Marshal(a)
|
||||
bb, errB := json.Marshal(b)
|
||||
if errA != nil || errB != nil {
|
||||
@@ -46,7 +46,7 @@ func MediaSettingsEqual(a, b MediaSettings) bool {
|
||||
|
||||
// ApplyMediaSettingsIfChanged applies runtime media settings
|
||||
// only when they actually differ. It never re-runs ffmpeg detection.
|
||||
func ApplyMediaSettingsIfChanged(s MediaSettings) {
|
||||
func ApplyMediaSettingsIfChanged(s FullAdminMedia) {
|
||||
if MediaSettingsEqual(CurrentMediaSettings(), s) {
|
||||
return
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func ApplyMediaSettingsIfChanged(s MediaSettings) {
|
||||
|
||||
// CloneMediaSettings returns a deep copy of m — the conversion maps hold *bool
|
||||
// values, so a shallow copy would alias the live config's reference state.
|
||||
func CloneMediaSettings(m MediaSettings) MediaSettings {
|
||||
func CloneMediaSettings(m FullAdminMedia) FullAdminMedia {
|
||||
out := m
|
||||
out.Convert = FfmpegConvert{
|
||||
ImagePreview: cloneBoolMap(m.Convert.ImagePreview),
|
||||
@@ -84,7 +84,7 @@ func cloneBoolPtr(v *bool) *bool {
|
||||
}
|
||||
|
||||
// SanitizeMediaSettings clamps runtime media settings into supported ranges.
|
||||
func SanitizeMediaSettings(m MediaSettings) MediaSettings {
|
||||
func SanitizeMediaSettings(m FullAdminMedia) FullAdminMedia {
|
||||
t := &m.Transcode
|
||||
if t.MaxConcurrent < 1 {
|
||||
t.MaxConcurrent = 2
|
||||
|
||||
@@ -2,17 +2,16 @@ package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func testMediaSettings() MediaSettings {
|
||||
return MediaSettings{
|
||||
func testMediaSettings() FullAdminMedia {
|
||||
return FullAdminMedia{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 2,
|
||||
MaxConcurrentPerUser: 1,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true, MaxConcurrentPerUser: 1},
|
||||
MaxConcurrent: 2,
|
||||
CacheMaxSizeMB: 10240,
|
||||
CacheRetentionH: 24,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -54,11 +53,11 @@ func TestMergeMediaSettingsPatchJSONProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeMediaSettings(t *testing.T) {
|
||||
in := MediaSettings{
|
||||
Transcode: MediaTranscode{
|
||||
MaxConcurrent: -1,
|
||||
MaxConcurrentPerUser: 0,
|
||||
CacheRetentionH: 0,
|
||||
in := FullAdminMedia{
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{MaxConcurrentPerUser: 0},
|
||||
MaxConcurrent: -1,
|
||||
CacheRetentionH: 0,
|
||||
Profiles: TranscodeProfiles{
|
||||
Quality: TranscodeProfile{MaxResolution: -5, AvgBitrateKbps: -1},
|
||||
},
|
||||
@@ -116,10 +115,10 @@ func TestSanitizeMediaSettingsAccelDevice(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := SanitizeMediaSettings(MediaSettings{
|
||||
out := SanitizeMediaSettings(FullAdminMedia{
|
||||
AccelDevice: tc.device,
|
||||
HardwareAcceleration: tc.hwAccel,
|
||||
Transcode: MediaTranscode{MaxConcurrent: 1, MaxConcurrentPerUser: 1, CacheRetentionH: 1},
|
||||
Transcode: FullAdminTranscode{UserTranscode: UserTranscode{MaxConcurrentPerUser: 1}, MaxConcurrent: 1, CacheRetentionH: 1},
|
||||
})
|
||||
if out.AccelDevice != tc.wantDevice || out.HardwareAcceleration != tc.wantHW {
|
||||
t.Fatalf("accelDevice = %q/%v, want %q/%v", out.AccelDevice, out.HardwareAcceleration, tc.wantDevice, tc.wantHW)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
|
||||
"github.com/gtsteffaniak/go-ffmpeg/encode"
|
||||
@@ -209,8 +210,10 @@ func TranscodeCacheRetention() time.Duration {
|
||||
return time.Duration(h) * time.Hour
|
||||
}
|
||||
|
||||
// CurrentMediaSettings returns the runtime media settings (excluding ffmpegPath).
|
||||
func CurrentMediaSettings() MediaSettings {
|
||||
// CurrentMediaSettings returns the stored runtime media settings (excluding ffmpegPath).
|
||||
// Derived fields (ImagePreview, EnabledProfiles) are not populated; use CurrentFullAdminMedia
|
||||
// or CurrentUserMedia for response payloads.
|
||||
func CurrentMediaSettings() FullAdminMedia {
|
||||
mediaMu.RLock()
|
||||
defer mediaMu.RUnlock()
|
||||
return currentMediaSettingsLocked()
|
||||
@@ -218,30 +221,79 @@ func CurrentMediaSettings() MediaSettings {
|
||||
|
||||
// currentMediaSettingsLocked snapshots the live media settings. Callers must hold
|
||||
// mediaMu. The returned value is deep-copied so it cannot alias the live config.
|
||||
func currentMediaSettingsLocked() MediaSettings {
|
||||
func currentMediaSettingsLocked() FullAdminMedia {
|
||||
m := Config.Integrations.Media
|
||||
return CloneMediaSettings(MediaSettings{
|
||||
return CloneMediaSettings(FullAdminMedia{
|
||||
Convert: m.Convert,
|
||||
Debug: m.Debug,
|
||||
HardwareAcceleration: m.HardwareAcceleration,
|
||||
AccelDevice: m.AccelDevice,
|
||||
AlwaysUseFfmpeg: m.AlwaysUseFfmpeg,
|
||||
AlwaysUseOnlyOffice: m.AlwaysUseOnlyOffice,
|
||||
Transcode: m.Transcode,
|
||||
AccelDevice: m.AccelDevice,
|
||||
AlwaysUseFfmpeg: m.AlwaysUseFfmpeg,
|
||||
AlwaysUseOnlyOffice: m.AlwaysUseOnlyOffice,
|
||||
Transcode: m.Transcode,
|
||||
})
|
||||
}
|
||||
|
||||
// ImagePreviewAvailability resolves each supported image preview type to whether
|
||||
// conversion is active right now, using the same check the preview pipeline uses.
|
||||
func ImagePreviewAvailability() map[ImagePreviewType]bool {
|
||||
out := make(map[ImagePreviewType]bool, len(AllImagePreviewTypes))
|
||||
for _, t := range AllImagePreviewTypes {
|
||||
out[t] = CanConvertImage(string(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentUserMedia builds the PUBLIC media payload for non-admin users. Values come
|
||||
// from the same accessors as the admin payload, so the shared fields always agree.
|
||||
// The getters take mediaMu themselves, so they are called here without holding it.
|
||||
func CurrentUserMedia() UserMedia {
|
||||
return UserMedia{
|
||||
Transcode: UserTranscode{
|
||||
Enabled: TranscodeEnabled(),
|
||||
MaxConcurrentPerUser: TranscodeMaxConcurrentPerUser(),
|
||||
EnabledProfiles: EnabledTranscodeProfiles(),
|
||||
},
|
||||
ImagePreview: ImagePreviewAvailability(),
|
||||
}
|
||||
}
|
||||
|
||||
// PrepMediaForFrontend returns a read-only copy of m with derived values filled in,
|
||||
// following PrepForFrontend in internal/web/users.go. The caller's value is not modified.
|
||||
// Stored config (transcode.enabled, maxConcurrentPerUser) is deliberately NOT replaced
|
||||
// with effective values: the admin UI edits these fields and sends them back, so they
|
||||
// must stay the stored values. Only derived fields are filled.
|
||||
// NEVER pass the result to SaveSetting without ClearMediaDerived first.
|
||||
func PrepMediaForFrontend(m FullAdminMedia) FullAdminMedia {
|
||||
m.Transcode.EnabledProfiles = EnabledTranscodeProfiles()
|
||||
m.ImagePreview = ImagePreviewAvailability()
|
||||
return m
|
||||
}
|
||||
|
||||
// ClearMediaDerived zeroes the fields that PrepMediaForFrontend derives at read time so
|
||||
// they are never written to the database. Enabled and MaxConcurrentPerUser are stored
|
||||
// config and are not cleared here.
|
||||
func ClearMediaDerived(m *FullAdminMedia) {
|
||||
m.Transcode.EnabledProfiles = nil
|
||||
m.ImagePreview = nil
|
||||
}
|
||||
|
||||
// ApplyMediaSettings applies runtime media settings to the in-memory config.
|
||||
// It never re-runs ffmpeg detection: codec/hardware detection happens exactly
|
||||
// once at startup (InitFFmpeg). hardwareAcceleration is read live per transcode;
|
||||
// ffmpegPath, accelDevice, debug and concurrency apply on the next restart.
|
||||
func ApplyMediaSettings(s MediaSettings) {
|
||||
func ApplyMediaSettings(s FullAdminMedia) {
|
||||
mediaMu.Lock()
|
||||
defer mediaMu.Unlock()
|
||||
Config.Integrations.Media.Convert = s.Convert
|
||||
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,6 +4,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
)
|
||||
|
||||
func withMediaConfig(t *testing.T) {
|
||||
@@ -25,16 +27,15 @@ func TestMediaAccessorsReflectRuntimeSettings(t *testing.T) {
|
||||
originalTranscode := Config.Integrations.Media.Transcode
|
||||
t.Cleanup(func() { Config.Integrations.Media.Transcode = originalTranscode })
|
||||
|
||||
ApplyMediaSettings(MediaSettings{
|
||||
ApplyMediaSettings(FullAdminMedia{
|
||||
Debug: true,
|
||||
HardwareAcceleration: true,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: true,
|
||||
MaxConcurrent: 4,
|
||||
MaxConcurrentPerUser: 2,
|
||||
CacheMaxSizeMB: 512,
|
||||
CacheRetentionH: 6,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: true, MaxConcurrentPerUser: 2},
|
||||
MaxConcurrent: 4,
|
||||
CacheMaxSizeMB: 512,
|
||||
CacheRetentionH: 6,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -67,7 +68,7 @@ func TestMediaAccessorsReflectRuntimeSettings(t *testing.T) {
|
||||
|
||||
func TestTranscodeAccessorsUseDefaultsWhenUnset(t *testing.T) {
|
||||
withMediaConfig(t)
|
||||
ApplyMediaSettings(MediaSettings{})
|
||||
ApplyMediaSettings(FullAdminMedia{})
|
||||
|
||||
if got := TranscodeMaxConcurrent(); got != 2 {
|
||||
t.Fatalf("TranscodeMaxConcurrent = %d, want the 2 default", got)
|
||||
@@ -225,11 +226,11 @@ func TestMediaAccessorsAreRaceFree(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
enabled = !enabled
|
||||
ApplyMediaSettings(MediaSettings{
|
||||
ApplyMediaSettings(FullAdminMedia{
|
||||
Debug: enabled,
|
||||
HardwareAcceleration: enabled,
|
||||
Transcode: MediaTranscode{
|
||||
Enabled: enabled,
|
||||
Transcode: FullAdminTranscode{
|
||||
UserTranscode: UserTranscode{Enabled: enabled},
|
||||
MaxConcurrent: 2,
|
||||
Profiles: DefaultTranscodeProfiles(),
|
||||
},
|
||||
@@ -309,3 +310,26 @@ func TestInitFFmpegIsIdempotent(t *testing.T) {
|
||||
t.Fatal("InitFFmpeg must not run the probe during tests")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyMediaSettingsClearsMediaCacheOnBackendSwitch checks that toggling
|
||||
// "Always use ffmpeg" drops values the other backend cached, and that an
|
||||
// unrelated settings change keeps them.
|
||||
func TestApplyMediaSettingsClearsMediaCacheOnBackendSwitch(t *testing.T) {
|
||||
withMediaConfig(t)
|
||||
key := mediacache.Key{Path: "/settings-test.mp4", Size: 1, ModTime: 1}.String()
|
||||
t.Cleanup(func() { mediacache.Durations.Delete(key) })
|
||||
|
||||
media := CurrentMediaSettings()
|
||||
mediacache.Durations.Set(key, 1)
|
||||
media.Debug = !media.Debug
|
||||
ApplyMediaSettings(media)
|
||||
if _, ok := mediacache.Durations.Get(key); !ok {
|
||||
t.Fatal("an unrelated media settings change must keep the shared media cache")
|
||||
}
|
||||
|
||||
media.AlwaysUseFfmpeg = !media.AlwaysUseFfmpeg
|
||||
ApplyMediaSettings(media)
|
||||
if _, ok := mediacache.Durations.Get(key); ok {
|
||||
t.Fatal("switching Always use ffmpeg must clear the shared media cache")
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestSyncEnforcedSourcePermissionsOntoUser_skipsAdmin(t *testing.T) {
|
||||
enforced := SourceFilePermissionsEnforcement{Create: true}
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{{
|
||||
@@ -90,4 +90,3 @@ func TestValidateSelfUserUpdateScopesNotEnforced_caseInsensitive(t *testing.T) {
|
||||
t.Fatal("expected mixed-case scope field to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,43 +175,63 @@ type Media struct {
|
||||
// The fields below are deprecated and migrated to runtime (DB-backed) media settings on
|
||||
// first run. They are kept only to seed the database from an existing config.yaml.
|
||||
|
||||
Convert FfmpegConvert `json:"convert"` // deprecated: config for ffmpeg conversion settings. deprecated: manage via the admin media settings API
|
||||
Debug bool `json:"debug"` // deprecated: output ffmpeg stdout for media integration. deprecated: manage via the admin media settings API
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // deprecated: no longer configurable; embedded subtitles are always extracted
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // deprecated: enable hardware acceleration for ffmpeg if available. deprecated: manage via the admin media settings API
|
||||
Transcode MediaTranscode `json:"transcode"` // deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API
|
||||
Convert FfmpegConvert `json:"convert"` // deprecated: config for ffmpeg conversion settings. deprecated: manage via the admin media settings API
|
||||
Debug bool `json:"debug"` // deprecated: output ffmpeg stdout for media integration. deprecated: manage via the admin media settings API
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // deprecated: no longer configurable; embedded subtitles are always extracted
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // deprecated: enable hardware acceleration for ffmpeg if available. deprecated: manage via the admin media settings API
|
||||
Transcode FullAdminTranscode `json:"transcode"` // deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API
|
||||
|
||||
// AccelDevice selects the acceleration device: "auto" (default), "software"/"cpu",
|
||||
// "igpu", "dgpu", a Linux DRM render node (e.g. /dev/dri/renderD128), or a GPU
|
||||
// name substring. Managed via the admin media settings API.
|
||||
AccelDevice string `json:"accelDevice"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // deprecated: skip folio previews for office converter; manage via the admin media settings API
|
||||
AccelDevice string `json:"accelDevice"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // deprecated: manage via the admin media settings API
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // deprecated: skip folio previews for office converter; manage via the admin media settings API
|
||||
}
|
||||
|
||||
// MediaSettings holds the runtime (DB-backed) media integration settings.
|
||||
// FfmpegPath is config-only and intentionally excluded.
|
||||
type MediaSettings struct {
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // enable hardware acceleration for ffmpeg if available
|
||||
AccelDevice string `json:"accelDevice"` // acceleration device: "auto", "software"/"cpu", "igpu", "dgpu", a DRM render node, or a GPU name substring
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // skip the go-media fast path and use ffmpeg for all probe, metadata, subtitle, and thumbnail work (default: false)
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // skip folio document previews and use the office converter when configured (default: false)
|
||||
Transcode MediaTranscode `json:"transcode"` // HLS transcoding for media playback
|
||||
// FullAdminMedia holds every runtime (DB-backed) media integration setting.
|
||||
// ADMIN ONLY: non-admins receive UserMedia instead. FfmpegPath is config-only and intentionally excluded.
|
||||
// ImagePreview is derived at read time (see PrepMediaForFrontend) and is cleared before save.
|
||||
type FullAdminMedia struct {
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview,omitempty"` // image formats that can be converted
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
HardwareAcceleration bool `json:"hardwareAcceleration"` // enable hardware acceleration for ffmpeg if available
|
||||
AccelDevice string `json:"accelDevice"` // acceleration device: "auto", "software"/"cpu", "igpu", "dgpu", a DRM render node, or a GPU name substring
|
||||
AlwaysUseFfmpeg bool `json:"alwaysUseFfmpeg"` // skip the go-media fast path and use ffmpeg for all probe, metadata, subtitle, and thumbnail work (default: false)
|
||||
AlwaysUseOnlyOffice bool `json:"alwaysUseOnlyOffice"` // skip folio document previews and use the office converter when configured (default: false)
|
||||
Transcode FullAdminTranscode `json:"transcode"` // HLS transcoding for media playback
|
||||
}
|
||||
|
||||
// MediaTranscode configures authenticated HLS transcoding (disabled by default).
|
||||
type MediaTranscode struct {
|
||||
Enabled bool `json:"enabled"` // enable HLS transcoding for authenticated playback (default: false)
|
||||
MaxConcurrent int `json:"maxConcurrent"` // global concurrent transcode sessions (default: 2)
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"` // hard per-user concurrent transcode ceiling (default: 1)
|
||||
CacheMaxSizeMB int `json:"cacheMaxSizeMB"` // disk cache size limit in MB; 0 = unlimited (default: 10240)
|
||||
CacheRetentionH int `json:"cacheRetentionH"` // completed cache retention in hours (default: 24)
|
||||
Profiles TranscodeProfiles `json:"profiles"` // per-profile transcode settings shown in the player menu
|
||||
MaxResolution int `json:"maxResolution"` // superseded by profiles.<name>.maxResolution. deprecated: kept for config compatibility
|
||||
EncoderBackend string `json:"encoderBackend"` // default video encoder backend for transcodes: "" (auto), "cpu", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)
|
||||
ReadRate float64 `json:"readRate"` // VOD producer input pacing in Nx realtime (-readrate); unset or <1 clamps to >=1 (default: 2)
|
||||
// UserTranscode is PUBLIC: transcode fields every authenticated user receives in
|
||||
// the UserMedia payload. FullAdminTranscode embeds it, so the same JSON keys appear
|
||||
// in both admin and user responses. Add a field here only if the UI reads it.
|
||||
type UserTranscode struct {
|
||||
Enabled bool `json:"enabled"` // enable HLS transcoding for authenticated playback (default: false)
|
||||
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"` // hard per-user concurrent transcode ceiling (default: 1)
|
||||
EnabledProfiles []string `json:"enabledProfiles,omitempty"` // derived at read time (player-menu profile ids); never stored
|
||||
}
|
||||
|
||||
// UserMedia is PUBLIC: the only media payload non-admin users receive.
|
||||
// Add a top-level key here only if the player or general UI reads it.
|
||||
type UserMedia struct {
|
||||
Transcode UserTranscode `json:"transcode"`
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // availability, derived at read time
|
||||
}
|
||||
|
||||
// FullAdminTranscode configures authenticated HLS transcoding (disabled by default).
|
||||
// ADMIN ONLY beyond the embedded UserTranscode fields.
|
||||
type FullAdminTranscode struct {
|
||||
// inline: flattens the public fields in YAML (config.yaml) the same way the embed
|
||||
// flattens them in JSON, so config keys stay transcode.enabled, not transcode.UserTranscode.enabled.
|
||||
UserTranscode `yaml:",inline"`
|
||||
MaxConcurrent int `json:"maxConcurrent"` // global concurrent transcode sessions (default: 2)
|
||||
CacheMaxSizeMB int `json:"cacheMaxSizeMB"` // disk cache size limit in MB; 0 = unlimited (default: 10240)
|
||||
CacheRetentionH int `json:"cacheRetentionH"` // completed cache retention in hours (default: 24)
|
||||
Profiles TranscodeProfiles `json:"profiles"` // per-profile transcode settings shown in the player menu
|
||||
MaxResolution int `json:"maxResolution"` // superseded by profiles.<name>.maxResolution. deprecated: kept for config compatibility
|
||||
EncoderBackend string `json:"encoderBackend"` // default video encoder backend for transcodes: "" (auto), "cpu", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)
|
||||
ReadRate float64 `json:"readRate"` // VOD producer input pacing in Nx realtime (-readrate); unset or <1 clamps to >=1 (default: 2)
|
||||
}
|
||||
|
||||
// TranscodeProfiles configures the player's transcode quality presets.
|
||||
|
||||
@@ -134,6 +134,12 @@ func DefaultDarkModeIsAuto() bool {
|
||||
return Config.UserDefaults.UI.DarkMode == users.DarkModeAuto
|
||||
}
|
||||
|
||||
// DefaultDarkModePreference is the configured instance default for globalVars.darkMode
|
||||
// ("auto", "dark", or "light"). Unlike DefaultDarkMode, it preserves "auto" for the SPA.
|
||||
func DefaultDarkModePreference() users.DarkMode {
|
||||
return Config.UserDefaults.UI.DarkMode.Or(users.DarkModeDark)
|
||||
}
|
||||
|
||||
// DefaultBackgroundColor returns the background color of the instance's default theme.
|
||||
func DefaultBackgroundColor() string {
|
||||
if DefaultDarkMode() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestDefaultDarkModePreferencePreservesAuto(t *testing.T) {
|
||||
original := Config.UserDefaults.UI.DarkMode
|
||||
t.Cleanup(func() { Config.UserDefaults.UI.DarkMode = original })
|
||||
|
||||
Config.UserDefaults.UI.DarkMode = users.DarkModeAuto
|
||||
if got := DefaultDarkModePreference(); got != users.DarkModeAuto {
|
||||
t.Fatalf("DefaultDarkModePreference() = %q, want auto", got)
|
||||
}
|
||||
if DefaultDarkMode() {
|
||||
t.Fatal("DefaultDarkMode() should be false when preference is auto")
|
||||
}
|
||||
if !DefaultDarkModeIsAuto() {
|
||||
t.Fatal("DefaultDarkModeIsAuto() should be true when preference is auto")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(DefaultDarkModePreference())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != `"auto"` {
|
||||
t.Fatalf("json.Marshal preference = %s, want \"auto\"", raw)
|
||||
}
|
||||
}
|
||||
@@ -3217,6 +3217,24 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"/api/settings/media": {
|
||||
"get": {
|
||||
"description": "Returns the media settings the caller is allowed to see.\nAdmins get the full settings, including ffmpeg and hardware options, so they can edit them.\nEveryone else gets a small read-only view: whether transcoding is on, how many\ntranscodes they can run at once, which quality presets are enabled, and which image\npreviews the server can generate. Keys in the view are the same for admins and users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get runtime media settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Media settings",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.mediaSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.",
|
||||
"consumes": [
|
||||
@@ -6299,6 +6317,58 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.FullAdminTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabledProfiles": {
|
||||
"description": "derived at read time (player-menu profile ids); never stored",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.Http": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6640,57 +6710,12 @@ const docTemplate = `{
|
||||
"description": "deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.MediaTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8963,7 +8988,6 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"web.mediaSettingsResponse": {
|
||||
"description": "Admins receive the DB-backed media integration settings; every authenticated user receives the player-relevant subset (transcode enabled, profiles, per-user limit).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accelDevice": {
|
||||
@@ -9006,6 +9030,13 @@ const docTemplate = `{
|
||||
"description": "enable hardware acceleration for ffmpeg if available",
|
||||
"type": "boolean"
|
||||
},
|
||||
"imagePreview": {
|
||||
"description": "image formats that can be converted",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"lockMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9019,7 +9050,7 @@ const docTemplate = `{
|
||||
"description": "HLS transcoding for media playback",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3206,6 +3206,24 @@
|
||||
}
|
||||
},
|
||||
"/api/settings/media": {
|
||||
"get": {
|
||||
"description": "Returns the media settings the caller is allowed to see.\nAdmins get the full settings, including ffmpeg and hardware options, so they can edit them.\nEveryone else gets a small read-only view: whether transcoding is on, how many\ntranscodes they can run at once, which quality presets are enabled, and which image\npreviews the server can generate. Keys in the view are the same for admins and users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get runtime media settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Media settings",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/web.mediaSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Merges a partial JSON patch into the DB-backed media settings and applies them at runtime.",
|
||||
"consumes": [
|
||||
@@ -6288,6 +6306,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.FullAdminTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabledProfiles": {
|
||||
"description": "derived at read time (player-menu profile ids); never stored",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.Http": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6629,57 +6699,12 @@
|
||||
"description": "deprecated: HLS transcoding for media playback. deprecated: manage via the admin media settings API",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.MediaTranscode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cacheMaxSizeMB": {
|
||||
"description": "disk cache size limit in MB; 0 = unlimited (default: 10240)",
|
||||
"type": "integer"
|
||||
},
|
||||
"cacheRetentionH": {
|
||||
"description": "completed cache retention in hours (default: 24)",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "enable HLS transcoding for authenticated playback (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"encoderBackend": {
|
||||
"description": "default video encoder backend for transcodes: \"\" (auto), \"cpu\", or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)",
|
||||
"type": "string"
|
||||
},
|
||||
"maxConcurrent": {
|
||||
"description": "global concurrent transcode sessions (default: 2)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxConcurrentPerUser": {
|
||||
"description": "hard per-user concurrent transcode ceiling (default: 1)",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxResolution": {
|
||||
"description": "superseded by profiles.\u003cname\u003e.maxResolution. deprecated: kept for config compatibility",
|
||||
"type": "integer"
|
||||
},
|
||||
"profiles": {
|
||||
"description": "per-profile transcode settings shown in the player menu",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.TranscodeProfiles"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"description": "VOD producer input pacing in Nx realtime (-readrate); unset or \u003c1 clamps to \u003e=1 (default: 2)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.OidcConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8952,7 +8977,6 @@
|
||||
}
|
||||
},
|
||||
"web.mediaSettingsResponse": {
|
||||
"description": "Admins receive the DB-backed media integration settings; every authenticated user receives the player-relevant subset (transcode enabled, profiles, per-user limit).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accelDevice": {
|
||||
@@ -8995,6 +9019,13 @@
|
||||
"description": "enable hardware acceleration for ffmpeg if available",
|
||||
"type": "boolean"
|
||||
},
|
||||
"imagePreview": {
|
||||
"description": "image formats that can be converted",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"lockMessage": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -9008,7 +9039,7 @@
|
||||
"description": "HLS transcoding for media playback",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/settings.MediaTranscode"
|
||||
"$ref": "#/definitions/settings.FullAdminTranscode"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -480,6 +480,46 @@ definitions:
|
||||
styling:
|
||||
$ref: '#/definitions/settings.StylingConfig'
|
||||
type: object
|
||||
settings.FullAdminTranscode:
|
||||
properties:
|
||||
cacheMaxSizeMB:
|
||||
description: 'disk cache size limit in MB; 0 = unlimited (default: 10240)'
|
||||
type: integer
|
||||
cacheRetentionH:
|
||||
description: 'completed cache retention in hours (default: 24)'
|
||||
type: integer
|
||||
enabled:
|
||||
description: 'enable HLS transcoding for authenticated playback (default:
|
||||
false)'
|
||||
type: boolean
|
||||
enabledProfiles:
|
||||
description: derived at read time (player-menu profile ids); never stored
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
encoderBackend:
|
||||
description: 'default video encoder backend for transcodes: "" (auto), "cpu",
|
||||
or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)'
|
||||
type: string
|
||||
maxConcurrent:
|
||||
description: 'global concurrent transcode sessions (default: 2)'
|
||||
type: integer
|
||||
maxConcurrentPerUser:
|
||||
description: 'hard per-user concurrent transcode ceiling (default: 1)'
|
||||
type: integer
|
||||
maxResolution:
|
||||
description: 'superseded by profiles.<name>.maxResolution. deprecated: kept
|
||||
for config compatibility'
|
||||
type: integer
|
||||
profiles:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TranscodeProfiles'
|
||||
description: per-profile transcode settings shown in the player menu
|
||||
readRate:
|
||||
description: 'VOD producer input pacing in Nx realtime (-readrate); unset
|
||||
or <1 clamps to >=1 (default: 2)'
|
||||
type: number
|
||||
type: object
|
||||
settings.Http:
|
||||
properties:
|
||||
baseURL:
|
||||
@@ -764,45 +804,10 @@ definitions:
|
||||
type: boolean
|
||||
transcode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.MediaTranscode'
|
||||
- $ref: '#/definitions/settings.FullAdminTranscode'
|
||||
description: 'deprecated: HLS transcoding for media playback. deprecated:
|
||||
manage via the admin media settings API'
|
||||
type: object
|
||||
settings.MediaTranscode:
|
||||
properties:
|
||||
cacheMaxSizeMB:
|
||||
description: 'disk cache size limit in MB; 0 = unlimited (default: 10240)'
|
||||
type: integer
|
||||
cacheRetentionH:
|
||||
description: 'completed cache retention in hours (default: 24)'
|
||||
type: integer
|
||||
enabled:
|
||||
description: 'enable HLS transcoding for authenticated playback (default:
|
||||
false)'
|
||||
type: boolean
|
||||
encoderBackend:
|
||||
description: 'default video encoder backend for transcodes: "" (auto), "cpu",
|
||||
or an accel backend (nvenc, amf, qsv, vaapi, d3d12, videotoolbox)'
|
||||
type: string
|
||||
maxConcurrent:
|
||||
description: 'global concurrent transcode sessions (default: 2)'
|
||||
type: integer
|
||||
maxConcurrentPerUser:
|
||||
description: 'hard per-user concurrent transcode ceiling (default: 1)'
|
||||
type: integer
|
||||
maxResolution:
|
||||
description: 'superseded by profiles.<name>.maxResolution. deprecated: kept
|
||||
for config compatibility'
|
||||
type: integer
|
||||
profiles:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TranscodeProfiles'
|
||||
description: per-profile transcode settings shown in the player menu
|
||||
readRate:
|
||||
description: 'VOD producer input pacing in Nx realtime (-readrate); unset
|
||||
or <1 clamps to >=1 (default: 2)'
|
||||
type: number
|
||||
type: object
|
||||
settings.OidcConfig:
|
||||
properties:
|
||||
adminGroup:
|
||||
@@ -2423,9 +2428,6 @@ definitions:
|
||||
description: File metadata for non-text files
|
||||
type: object
|
||||
web.mediaSettingsResponse:
|
||||
description: Admins receive the DB-backed media integration settings; every authenticated
|
||||
user receives the player-relevant subset (transcode enabled, profiles, per-user
|
||||
limit).
|
||||
properties:
|
||||
accelDevice:
|
||||
description: 'acceleration device: "auto", "software"/"cpu", "igpu", "dgpu",
|
||||
@@ -2458,6 +2460,11 @@ definitions:
|
||||
hardwareAcceleration:
|
||||
description: enable hardware acceleration for ffmpeg if available
|
||||
type: boolean
|
||||
imagePreview:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: image formats that can be converted
|
||||
type: object
|
||||
lockMessage:
|
||||
type: string
|
||||
lockedFromConfigPaths:
|
||||
@@ -2466,7 +2473,7 @@ definitions:
|
||||
type: array
|
||||
transcode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.MediaTranscode'
|
||||
- $ref: '#/definitions/settings.FullAdminTranscode'
|
||||
description: HLS transcoding for media playback
|
||||
type: object
|
||||
web.pinnedItemPatchRequest:
|
||||
@@ -4767,6 +4774,23 @@ paths:
|
||||
tags:
|
||||
- Settings
|
||||
/api/settings/media:
|
||||
get:
|
||||
description: |-
|
||||
Returns the media settings the caller is allowed to see.
|
||||
Admins get the full settings, including ffmpeg and hardware options, so they can edit them.
|
||||
Everyone else gets a small read-only view: whether transcoding is on, how many
|
||||
transcodes they can run at once, which quality presets are enabled, and which image
|
||||
previews the server can generate. Keys in the view are the same for admins and users.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Media settings
|
||||
schema:
|
||||
$ref: '#/definitions/web.mediaSettingsResponse'
|
||||
summary: Get runtime media settings
|
||||
tags:
|
||||
- Settings
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22
|
||||
22.22.2
|
||||
+258
-134
@@ -1,140 +1,264 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!dist",
|
||||
"!public/config.generated.yaml",
|
||||
"!public/index.html",
|
||||
"!node_modules",
|
||||
"!test-results"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"root": "../"
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!dist",
|
||||
"!public/config.generated.yaml",
|
||||
"!public/index.html",
|
||||
"!node_modules",
|
||||
"!test-results"
|
||||
]
|
||||
},
|
||||
"formatter": { "enabled": false },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noAutofocus": "off",
|
||||
"noNoninteractiveElementToInteractiveRole": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useAriaPropsSupportedByRole": "off",
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
"noImportantStyles": "off",
|
||||
"noRedundantDefaultExport": "warn",
|
||||
"noUnnecessaryTemplateExpression": "error",
|
||||
"noUselessStringConcat": "warn",
|
||||
"useMathMinMax": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnknownPseudoClass": "off",
|
||||
"noVueImportCompilerMacros": "error",
|
||||
"noVueRefAsOperand": "error",
|
||||
"noVueVOnNumberValues": "error",
|
||||
"useVueValidVFor": "error",
|
||||
"noVueVIfWithVFor": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "error",
|
||||
"noImpliedEval": "error",
|
||||
"noJsonUnsafeValues": "error",
|
||||
"noMisusedPromises": "error",
|
||||
"noVueDeprecatedScopedSlots": "error",
|
||||
"noVueUndeclaredDirectives": "off",
|
||||
"useConsistentObjectKeys": "error",
|
||||
"useVueBaseImport": "warn",
|
||||
"noDoubleComparison": "on",
|
||||
"noNestedSwitch": "on",
|
||||
"noVueLifecycleAfterAwait": "error",
|
||||
"noVueShadowNativeEvents": "warn",
|
||||
"noVueVHtml": "error",
|
||||
"noVueVText": "warn",
|
||||
"noVueVTextVHtmlOnComponent": "error",
|
||||
"useSimplifiedBooleanReturn": "warn",
|
||||
"useVueConsistentEventHyphenation": "warn",
|
||||
"noPromiseExecutorReturn": "warn",
|
||||
"noArgumentsCallerOrCallee": "warn",
|
||||
"noVueRequiredPropWithDefault": "warn",
|
||||
"noBooleanSortComparator": "error",
|
||||
"noObsoleteAttributes": "error",
|
||||
"noUnsafeReturn": "warn",
|
||||
"noUselessSplice": "warn",
|
||||
"noVueWatchAfterAwait": "error",
|
||||
"useMathAbs": "warn",
|
||||
"useMathTrunc": "warn",
|
||||
"useVueComponentIs": "error",
|
||||
"useVueTypedRef": "error",
|
||||
"noReturnArrayPush": "warn",
|
||||
"noUselessRecursion": "error",
|
||||
"noVueComputedPropertiesInData": "error",
|
||||
"noVueDeprecatedDeleteSet": "error",
|
||||
"noVueDeprecatedRouterLinkTagProp": "error",
|
||||
"noVueMutatingProps": "error",
|
||||
"noVueMultipleSlotArgs": "warn",
|
||||
"noVueSideEffectsInComputed": "error",
|
||||
"noVueUnusedRefs": "error",
|
||||
"useVueTemplateRef": "warn",
|
||||
"useVueTypedProps": "warn",
|
||||
"useVueVModel": "warn",
|
||||
"useVueValidDefineEmits": "error",
|
||||
"useVueValidDefineProps": "error",
|
||||
"useVueValidVIs": "error",
|
||||
"useVueValidVModel": "error",
|
||||
"useVueValidVShow": "error",
|
||||
"useVueValidVSlot": "error",
|
||||
"noVueDeprecatedListeners": "error",
|
||||
"noArrayForEach": "error",
|
||||
"noChainedComparison": "error",
|
||||
"noDynamicDelete": "warn",
|
||||
"noLateEventControl": "error",
|
||||
"noObjectLiteralComparison": "error",
|
||||
"noPlaywrightUnsafeReferences": "error",
|
||||
"noUnnecessaryStringTrim": "warn",
|
||||
"noUselessLengthCheck": "info",
|
||||
"noUselessTypeAssertion": "warn",
|
||||
"noVueChildContent": "error",
|
||||
"noVueDeprecatedConfigKeyCodes": "info",
|
||||
"noVueDeprecatedEventsApi": "error",
|
||||
"noVueDeprecatedHtmlElementIs": "error",
|
||||
"noVueDeprecatedModelDefinition": "error",
|
||||
"noVueDeprecatedPropsDefaultThis": "error",
|
||||
"noVueDeprecatedVBindSync": "error",
|
||||
"noVueLoneTemplate": "error",
|
||||
"noVueMixin": "error",
|
||||
"noVueMultipleObjectsInClass": "warn",
|
||||
"noVueRefObjectReactivityLoss": "error",
|
||||
"noVueRestrictedCallAfterAwait": "error",
|
||||
"noVueRestrictedVOn": "warn",
|
||||
"noVueTemplateKey": "error",
|
||||
"noVueTemplateShadow": "warn",
|
||||
"noVueThisInTemplate": "warn",
|
||||
"noVueUndeclaredEmits": "warn",
|
||||
"noVueUndeclaredProperties": "error",
|
||||
"noVueUndeclaredSlots": "warn",
|
||||
"noVueUselessVBind": "warn",
|
||||
"noVueUselessTemplateAttributes": "error",
|
||||
"noVueVForTemplateKeyOnChild": "error",
|
||||
"useObjectFromEntries": "warn",
|
||||
"useVueBlockOrder": {
|
||||
"level": "on",
|
||||
"options": {
|
||||
"order": [
|
||||
"template",
|
||||
"script",
|
||||
"style"
|
||||
]
|
||||
}
|
||||
},
|
||||
"useVueBooleanPropTypeFirst": "warn",
|
||||
"useVueComputedPropertyReturn": "error",
|
||||
"useVueEmitValidator": "warn",
|
||||
"useVueEmitsValidatorReturn": "error",
|
||||
"useVuePropTypeConstructor": "error",
|
||||
"useVueRenderReturn": "error",
|
||||
"useVueSingleEventPayload": "warn",
|
||||
"useVueTypedObjectProps": "warn",
|
||||
"useVueValidDefaultProp": "error",
|
||||
"useVueValidNextTick": "error",
|
||||
"useVueValidDefineOptions": "error",
|
||||
"noRedundantTypeConstituents": "warn",
|
||||
"noUselessSpread": "warn",
|
||||
"noVueExposeAfterAwait": "error"
|
||||
},
|
||||
"performance": { "useVueVapor": "off" },
|
||||
"security": {
|
||||
"preset": "recommended",
|
||||
"useIframeSandbox": "off"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off",
|
||||
"noTopLevelLiterals": "error",
|
||||
"noVueOptionsApi": "off",
|
||||
"useVueConsistentDefinePropsDeclaration": "on",
|
||||
"useVueConsistentVBindStyle": "off",
|
||||
"useVueHyphenatedAttributes": "off",
|
||||
"useVueMultiWordComponentNames": "off",
|
||||
"useVueNextTickPromise": "warn",
|
||||
"useImportsFirst": "off",
|
||||
"useVarsOnTop": "on",
|
||||
"useDomQuerySelector": "warn",
|
||||
"useVueDefineMacrosOrder": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noEmptyObjectKeys": "error",
|
||||
"noExplicitAny": "warn",
|
||||
"noImportCycles": "off",
|
||||
"noNestedPromises": "error",
|
||||
"noParametersOnlyUsedInRecursion": "error",
|
||||
"noShadow": "off",
|
||||
"noUnnecessaryConditions": "off",
|
||||
"useArraySortCompare": "error",
|
||||
"useScopedStyles": "off",
|
||||
"noConfusingVoidType": "warn"
|
||||
}
|
||||
},
|
||||
"domains": { "vue": "all", "project": "all", "types": "all" }
|
||||
},
|
||||
"css": {
|
||||
"formatter": { "enabled": false },
|
||||
"linter": { "enabled": false },
|
||||
"assist": { "enabled": false }
|
||||
},
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": true,
|
||||
"parser": { "interpolation": true },
|
||||
"linter": { "enabled": true }
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["tests/playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": { "noPlaywrightUselessAwait": "error" },
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off",
|
||||
"noPlaywrightMissingAwait": "error",
|
||||
"noUnresolvedImports": "off",
|
||||
"usePlaywrightValidDescribeCallback": "error"
|
||||
},
|
||||
"style": {
|
||||
"noPlaywrightElementHandle": "error",
|
||||
"noPlaywrightEval": "error",
|
||||
"noPlaywrightWaitForSelector": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noPlaywrightForceOption": "error",
|
||||
"noPlaywrightNetworkidle": "error",
|
||||
"noPlaywrightPagePause": "error",
|
||||
"noPlaywrightWaitForNavigation": "error",
|
||||
"noPlaywrightWaitForTimeout": "warn",
|
||||
"noEmptyObjectKeys": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.js"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"nursery": { "noUnsafeReturn": "off" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"scripts/postcss-strip-legacy.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.performance.config.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUndeclaredDependencies": "off",
|
||||
"noUnresolvedImports": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "off" } }
|
||||
},
|
||||
"css": {
|
||||
"linter": { "enabled": false },
|
||||
"formatter": { "enabled": false },
|
||||
"assist": { "enabled": false }
|
||||
},
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": true,
|
||||
"parser": { "interpolation": true },
|
||||
"linter": { "enabled": true }
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"domains": { "vue": "all", "project": "all", "types": "all" },
|
||||
"rules": {
|
||||
"performance": {
|
||||
"useVueVapor": "off"
|
||||
},
|
||||
"security": {
|
||||
"preset": "recommended"
|
||||
},
|
||||
"a11y": {
|
||||
"noAutofocus": "off",
|
||||
"noNoninteractiveElementToInteractiveRole": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useAriaPropsSupportedByRole": "off",
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
"noImportantStyles": "off",
|
||||
"noRedundantDefaultExport": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnknownPseudoClass": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noEmptyObjectKeys": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noImpliedEval": "error",
|
||||
"noJsonUnsafeValues": "error",
|
||||
"noMisusedPromises": "error",
|
||||
"noTopLevelLiterals": "error",
|
||||
"noVueDeprecatedScopedSlots": "error",
|
||||
"noVueImportCompilerMacros": "error",
|
||||
"noVueRefAsOperand": "error",
|
||||
"noVueUndeclaredDirectives": "off",
|
||||
"noVueVOnNumberValues": "error",
|
||||
"useConsistentObjectKeys": "error",
|
||||
"useVueBaseImport": "warn",
|
||||
"useVueConsistentDefinePropsDeclaration": "off",
|
||||
"useVueNextTickPromise": "warn",
|
||||
"useVueValidVFor": "error"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off",
|
||||
"noVueOptionsApi": "off",
|
||||
"useVueConsistentVBindStyle": "off",
|
||||
"useVueHyphenatedAttributes": "off",
|
||||
"useVueMultiWordComponentNames": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"noImportCycles": "off",
|
||||
"noNestedPromises": "error",
|
||||
"noParametersOnlyUsedInRecursion": "error",
|
||||
"noShadow": "off",
|
||||
"noUnnecessaryConditions": "off",
|
||||
"useArraySortCompare": "error"
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "off",
|
||||
"useGraphqlSortedDefinitions": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["tests/playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off",
|
||||
"noUnresolvedImports": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noPlaywrightElementHandle": "error",
|
||||
"noPlaywrightEval": "error",
|
||||
"noPlaywrightForceOption": "error",
|
||||
"noPlaywrightMissingAwait": "error",
|
||||
"noPlaywrightNetworkidle": "error",
|
||||
"noPlaywrightPagePause": "error",
|
||||
"noPlaywrightUselessAwait": "error",
|
||||
"noPlaywrightWaitForNavigation": "error",
|
||||
"noPlaywrightWaitForSelector": "error",
|
||||
"noPlaywrightWaitForTimeout": "warn",
|
||||
"usePlaywrightValidDescribeCallback": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"scripts/postcss-strip-legacy.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.performance.config.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnresolvedImports": "off",
|
||||
"noUndeclaredDependencies": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { defineConfig } from "eslint/config";
|
||||
import * as tsParser from "@typescript-eslint/parser";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ["**/dist/**", "tests/playwright-files/**"] },
|
||||
|
||||
...pluginVue.configs["flat/essential"],
|
||||
...vueI18n.configs.recommended,
|
||||
|
||||
// js/ts is parsed for vue-i18n to check t() calls
|
||||
{ files: ["**/*.js", "**/*.ts"], languageOptions: { parser: tsParser } },
|
||||
{ files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tsParser } } },
|
||||
{
|
||||
settings: {
|
||||
"vue-i18n": {
|
||||
localeDir: "src/i18n/en.json",
|
||||
messageSyntaxVersion: "^11.0.0",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@intlify/vue-i18n/no-missing-keys": "error",
|
||||
"@intlify/vue-i18n/no-unused-keys": ["error", {
|
||||
src: "./src",
|
||||
extensions: [".js", ".vue", ".ts"],
|
||||
ignores: ["/^languages\\./"],
|
||||
}],
|
||||
"@intlify/vue-i18n/no-raw-text": ["error", {
|
||||
ignoreNodes: ["i", "v-icon"],
|
||||
}],
|
||||
"@intlify/vue-i18n/no-missing-keys-in-other-locales": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.vue"],
|
||||
rules: {
|
||||
// this are the most common ones that likely biome also has
|
||||
// we can add more to the list or if some if buggy in biome better enable it here.
|
||||
"vue/no-duplicate-attributes": "off",
|
||||
"vue/no-use-v-if-with-v-for": "off",
|
||||
"vue/require-v-for-key": "off",
|
||||
"vue/valid-template-root": "off",
|
||||
"vue/valid-v-bind": "off",
|
||||
"vue/valid-v-cloak": "off",
|
||||
"vue/valid-v-else": "off",
|
||||
"vue/valid-v-else-if": "off",
|
||||
"vue/valid-v-for": "off",
|
||||
"vue/valid-v-html": "off",
|
||||
"vue/valid-v-if": "off",
|
||||
"vue/valid-v-on": "off",
|
||||
"vue/valid-v-once": "off",
|
||||
"vue/valid-v-pre": "off",
|
||||
"vue/valid-v-text": "off",
|
||||
"vue/no-deprecated-v-on-number-modifiers": "off",
|
||||
"vue/no-dupe-keys": "off",
|
||||
"vue/no-reserved-keys": "off",
|
||||
"vue/no-reserved-props": "off",
|
||||
"vue/no-deprecated-data-object-declaration": "off",
|
||||
"vue/no-arrow-functions-in-watch": "off",
|
||||
"vue/no-ref-as-operand": "off",
|
||||
"vue/prefer-import-from-vue": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-reserved-component-names": "off",
|
||||
"vue/no-parsing-error": "off",
|
||||
"vue/no-unused-components": "warn",
|
||||
"vue/no-v-text-v-html-on-component": "warn",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+931
-3090
File diff suppressed because it is too large.
Load diff
+37
-43
@@ -4,8 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=9.0.0",
|
||||
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
|
||||
"npm": ">=12.0.0",
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
@@ -13,77 +13,71 @@
|
||||
"dev": "vite",
|
||||
"watch": "vite build --watch",
|
||||
"typecheck": "vue-tsc --build",
|
||||
"lint": "biome check && eslint src/",
|
||||
"lint:fix": "biome check --write && eslint --fix src/",
|
||||
"lint": "biome check && npm run i18n:check",
|
||||
"lint:fix": "biome check --write",
|
||||
"lint:biome": "biome check",
|
||||
"lint:biome:fix": "biome check --write",
|
||||
"lint:eslint": "eslint src/",
|
||||
"format": "biome format --write",
|
||||
"i18n:check": "node scripts/i18n/index.ts check",
|
||||
"i18n:check:strict": "node scripts/i18n/index.ts check --strict",
|
||||
"lint:css": "stylelint \"**/*.{css,vue}\"",
|
||||
"lint:css:fix": "stylelint --fix \"**/*.{css,vue}\"",
|
||||
"i18n:sync": "node ./scripts/sync-translations.js",
|
||||
"i18n:check": "node ./scripts/sync-translations.js --check",
|
||||
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
|
||||
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
|
||||
"i18n:types": "node scripts/i18n/index.ts types",
|
||||
"i18n:sync": "node scripts/i18n/index.ts sync",
|
||||
"i18n:cleanup": "node scripts/i18n/index.ts sync --cleanup",
|
||||
"i18n:enforce-order": "node scripts/i18n/index.ts sync --enforce-order",
|
||||
"icons:sync": "node ./scripts/build-icon-subset.js",
|
||||
"icons:check": "node ./scripts/build-icon-subset.js --check",
|
||||
"test": "vitest run "
|
||||
},
|
||||
"dependencies": {
|
||||
"@likecoin/epub-ts": "^0.7.2",
|
||||
"@likecoin/epub-ts": "^0.7.3",
|
||||
"@onlyoffice/document-editor-vue": "^1.7.0",
|
||||
"ace-builds": "^1.45.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"ace-builds": "^1.45.2",
|
||||
"chart.js": "^4.6.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"highlight.js": "^11.12.0",
|
||||
"hls.js": "^1.7.2",
|
||||
"hls.js": "^1.7.4",
|
||||
"js-yaml": "^5.5.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^18.1.1",
|
||||
"marked": "^18.2.0",
|
||||
"plyr": "^3.8.5",
|
||||
"qrcode.vue": "^3.11.0",
|
||||
"temml": "^0.14.0",
|
||||
"three": "^0.186.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.16",
|
||||
"vue-router": "^5.4.0"
|
||||
"three": "^0.187.0",
|
||||
"vue": "^3.5.45",
|
||||
"vue-i18n": "^11.4.19",
|
||||
"vue-router": "^5.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.6",
|
||||
"minimatch": "^11.0.8",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"mammoth": {
|
||||
"argparse": "^2.0.1"
|
||||
"argparse": "^3.0.4"
|
||||
},
|
||||
"@intlify/vue-i18n-extensions": {
|
||||
"vue-i18n": "$vue-i18n"
|
||||
},
|
||||
"@intlify/eslint-plugin-vue-i18n": {
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
"glob": "^14.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.5.1",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.7",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"deepl-node": "^1.28.1",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"@biomejs/biome": "2.6.7",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.8",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@types/node": "^26.6.6",
|
||||
"@vitejs/plugin-vue": "^6.0.11",
|
||||
"deepl-node": "^1.29.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"fs-extra": "^11.4.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"stylelint": "^17.16.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"jsdom": "^31.0.1",
|
||||
"stylelint": "^18.0.0",
|
||||
"stylelint-config-standard": "^41.0.0",
|
||||
"stylelint-config-standard-vue": "^2.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.4",
|
||||
"vite-plugin-checker": "^0.14.5",
|
||||
"vite-plugin-compression2": "^2.5.3",
|
||||
"vitest": "^5.0.5",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.13",
|
||||
"vite": "^8.3.8",
|
||||
"vite-plugin-checker": "^1.0.0",
|
||||
"vite-plugin-compression2": "^2.5.4",
|
||||
"vitest": "^5.1.0",
|
||||
"vue-tsc": "^3.3.14",
|
||||
"wawoff2": "^2.0.1"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const EXTRA_ICONS = [
|
||||
'create_new_folder',
|
||||
'do_not_disturb_on',
|
||||
'done',
|
||||
'equalizer',
|
||||
'error_outline',
|
||||
'file',
|
||||
'file_copy',
|
||||
@@ -141,7 +142,7 @@ function scanMimetype(icons) {
|
||||
}
|
||||
|
||||
function scanConstants(icons) {
|
||||
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.js'), 'utf8');
|
||||
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.ts'), 'utf8');
|
||||
const iconField = new RegExp(`\\bicon:\\s*["'](${ICON_NAME})["']`, 'g');
|
||||
for (const match of content.matchAll(iconField)) addIcon(icons, match[1]);
|
||||
}
|
||||
@@ -184,7 +185,7 @@ function collectIcons() {
|
||||
scanSearchFilters(icons);
|
||||
scanMarkdownViewer(icons);
|
||||
icons.delete('close_back');
|
||||
return [...icons].sort();
|
||||
return [...icons].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
}
|
||||
|
||||
function readInventory() {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: the tests need it */
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { maskJs, maskSource } from "../shared.ts";
|
||||
import { syncTranslations } from "../sync.ts";
|
||||
import { literalCalls } from "../types.ts";
|
||||
|
||||
// The sync reads and writes the locales folder: point it at a temporary one.
|
||||
const dir = await vi.hoisted(async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
return mkdtempSync(join(tmpdir(), "i18n-test-"));
|
||||
});
|
||||
|
||||
vi.mock("../shared.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../shared.ts")>();
|
||||
const { readdirSync } = await import("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
localesDir: dir,
|
||||
translationFiles: () => readdirSync(dir).filter((file) => file.endsWith(".json") && file !== "en.json").sort(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("deepl-node", () => ({
|
||||
DeepLClient: class {
|
||||
async translateText(text: string) {
|
||||
return { text: `[${text}]` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const write = (file: string, content: unknown) => fs.writeFileSync(path.join(dir, file), JSON.stringify(content));
|
||||
const read = (file: string): unknown => JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
||||
|
||||
describe("sync with a different shape in a locale", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEEPL_API_KEY = "test-key";
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const file of fs.readdirSync(dir)) fs.rmSync(path.join(dir, file));
|
||||
});
|
||||
|
||||
const run = (options: { enforceOrder?: boolean; cleanup?: boolean } = {}) =>
|
||||
syncTranslations({ enforceOrder: false, cleanup: false, ...options });
|
||||
|
||||
describe("an object where en.json has a string", () => {
|
||||
beforeEach(() => {
|
||||
write("en.json", { general: { title: "Title" } });
|
||||
write("de.json", { general: { title: { old: "Alt" } } });
|
||||
});
|
||||
|
||||
it("replaces the subtree with a translation", async () => {
|
||||
expect(await run()).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: { title: "[Title]" } });
|
||||
});
|
||||
|
||||
it("removes the obsolete subtree on cleanup", async () => {
|
||||
expect(await run({ cleanup: true })).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: {} });
|
||||
});
|
||||
|
||||
it("removes the obsolete subtree when enforcing the order", async () => {
|
||||
expect(await run({ enforceOrder: true })).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a string where en.json has an object", () => {
|
||||
beforeEach(() => {
|
||||
write("en.json", { group: { a: "A" } });
|
||||
write("de.json", { group: "legacy" });
|
||||
});
|
||||
|
||||
it("replaces the string with the translated keys", async () => {
|
||||
await run();
|
||||
expect(read("de.json")).toEqual({ group: { a: "[A]" } });
|
||||
});
|
||||
|
||||
it("removes the obsolete string on cleanup", async () => {
|
||||
await run({ cleanup: true });
|
||||
expect(read("de.json")).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskJs", () => {
|
||||
it("blanks comments and keeps every offset and newline", () => {
|
||||
const code = `a(); // t("x")\n/* t("y")\n */ b();`;
|
||||
const masked = maskJs(code);
|
||||
expect(masked).toHaveLength(code.length);
|
||||
expect(masked).not.toContain("t(");
|
||||
expect(masked.split("\n")).toHaveLength(3);
|
||||
expect(masked).toContain("a();");
|
||||
expect(masked).toContain("b();");
|
||||
});
|
||||
|
||||
it("keeps `//` inside strings", () => {
|
||||
expect(maskJs(`const u = "https://example.com"; t("k");`)).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("blanks the content of quoted strings only on request, and keeps template literals", () => {
|
||||
const code = `x("a 't(' b", 'c'); y(\`\${t("k")}\`);`;
|
||||
const bare = maskJs(code, true);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("a 't(' b");
|
||||
expect(bare).toContain('t("k")');
|
||||
expect(maskJs(code)).toContain("a 't(' b");
|
||||
});
|
||||
|
||||
it("does not take an apostrophe left open on a line for a string", () => {
|
||||
expect(maskJs(`don't\nt("k")`, true)).toContain('t("');
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskJs with template literals", () => {
|
||||
const masked = (code: string) => maskJs(code, true);
|
||||
|
||||
it("template literal keeps its ${} expressions", () => {
|
||||
const code = 'const example = `t("example.only") ${t("real.key")} end`;';
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("example.only");
|
||||
expect(bare).toContain('t("real.key")');
|
||||
expect(bare.startsWith("const example = `")).toBe(true);
|
||||
expect(bare.endsWith("`;")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the text of a template literal when the strings are not masked", () => {
|
||||
const code = 'const example = `t("example.only") ${t("real.key")}`; // t("c.d")';
|
||||
const plain = maskJs(code);
|
||||
expect(plain).toContain('`t("example.only") ${t("real.key")}`');
|
||||
expect(plain).not.toContain("c.d");
|
||||
});
|
||||
|
||||
it("keeps the offsets and the newlines of a template literal that spans lines", () => {
|
||||
const code = "const a = `first t(\"x.y\")\nsecond ${\n t(\"real.key\")\n} third`;\nb();";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare.split("\n")).toHaveLength(code.split("\n").length);
|
||||
expect(bare.indexOf('t("real.key")')).toBe(code.indexOf('t("real.key")'));
|
||||
expect(bare).not.toContain("x.y");
|
||||
expect(bare).toContain("b();");
|
||||
});
|
||||
|
||||
it("does not take `//` or `/*` in the text of a template for a comment", () => {
|
||||
const code = "const u = `https://example.com/*path*/` + t('k');";
|
||||
expect(maskJs(code)).toBe(code);
|
||||
expect(masked(code)).toMatch(/` \+ t\(' '\);$/);
|
||||
});
|
||||
|
||||
it("masks a comment inside an expression and the text of a template nested in it", () => {
|
||||
const code = "`${ /* t('c.d') */ cond ? `t(\"nested.text\")` : t('real.key') }`";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("c.d");
|
||||
expect(bare).not.toContain("nested.text");
|
||||
expect(bare).toContain("t('real.key')");
|
||||
});
|
||||
|
||||
it("closes an expression at its own brace, not at the brace of an object inside it", () => {
|
||||
const code = "`a ${ fn({ x: 1 }) } t(\"after.only\") ${ t('real.key') }`";
|
||||
const bare = masked(code);
|
||||
expect(bare).not.toContain("after.only");
|
||||
expect(bare).toContain("t('real.key')");
|
||||
});
|
||||
|
||||
it("treats an escaped `${` and an escaped backtick as text", () => {
|
||||
const code = "`\\${t('escaped.key')} \\` t('after.escape')` + t('real.key')";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("escaped.key");
|
||||
expect(bare).not.toContain("after.escape");
|
||||
expect(bare).toMatch(/` \+ t\(' +'\)$/);
|
||||
});
|
||||
|
||||
it("copes with a template or an expression that is never closed", () => {
|
||||
for (const code of ["const a = `t('x.y')", "const a = `${t('x.y')", "const a = `trailing \\"]) {
|
||||
expect(masked(code)).toHaveLength(code.length);
|
||||
expect(maskJs(code)).toHaveLength(code.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSource for .vue files", () => {
|
||||
const vue = [
|
||||
"<template>",
|
||||
" <!-- {{ t('a.b') }} -->",
|
||||
" <p>{{ t('c.d') }}</p>",
|
||||
"</template>",
|
||||
'<script setup lang="ts">',
|
||||
"// t('e.f')",
|
||||
"const x = t('g.h');",
|
||||
"</script>",
|
||||
"<style>",
|
||||
".a::after { content: 't(\"i.j\")'; }",
|
||||
"</style>",
|
||||
].join("\n");
|
||||
|
||||
it("drops comments and styles but keeps the template and script code at the same offsets", () => {
|
||||
const masked = maskSource("A.vue", vue);
|
||||
expect(masked).toHaveLength(vue.length);
|
||||
for (const dropped of ["a.b", "e.f", "i.j"]) expect(masked).not.toContain(dropped);
|
||||
for (const kept of ["c.d", "g.h"]) expect(masked.indexOf(kept)).toBe(vue.indexOf(kept));
|
||||
});
|
||||
});
|
||||
|
||||
describe("literalCalls", () => {
|
||||
const calls = (source: string) => [...literalCalls(maskJs(source), maskJs(source, true))];
|
||||
|
||||
it("reads a parameter that holds an escaped quote", () => {
|
||||
const [call] = calls(`t("prompts.uploadSkipped", { count: "a\\"b".length })`);
|
||||
expect(call?.params).toEqual({ names: new Set(["count"]), dynamic: false });
|
||||
});
|
||||
|
||||
it("skips a key that is concatenated with an expression", () => {
|
||||
expect(calls(`t('prompts.' + name)`)).toEqual([]);
|
||||
expect(calls(`t("a" + b, { x: 1 })`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores calls in comments and strings, and finds those in template literals", () => {
|
||||
const found = calls(`// t('example.key')\nconst s = "t('example.str')";\nconst u = \`\${t("real.key")}\`;`);
|
||||
expect(found.map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("does not check the text of a template literal as a call", () => {
|
||||
expect(calls('const example = `t("example.only")`;')).toEqual([]);
|
||||
expect(calls('const example = `see t("example.only") and ${t("real.key")}`;').map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("finds a call in an expression nested in a template, not in the template nested in that", () => {
|
||||
const found = calls("const a = `${ cond ? `t('nested.text')` : t('real.key', { count: 1 }) }`;");
|
||||
expect(found.map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("is not confused by a comment in the params", () => {
|
||||
const [call] = calls(`t("k", { count, /* it's */ other })`);
|
||||
expect(call?.params.names).toEqual(new Set(["count", "other"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
import { isTestFile, maskSource, readLocale, rel, sourceFiles } from "./shared.ts";
|
||||
|
||||
const IGNORED_KEYS = [/^languages\./];
|
||||
const IGNORED_ELEMENTS = new Set(["i", "v-icon", "kbd"]);
|
||||
const IGNORE_COMMENT = "i18n-ignore";
|
||||
|
||||
// Vue's NodeTypes values
|
||||
const ELEMENT = 1;
|
||||
const TEXT = 2;
|
||||
const COMMENT = 3;
|
||||
|
||||
interface TemplateNode {
|
||||
type: number;
|
||||
tag?: string;
|
||||
content?: unknown;
|
||||
props?: { name?: string; value?: { content: string } }[];
|
||||
children?: TemplateNode[];
|
||||
loc: { start: { line: number } };
|
||||
}
|
||||
|
||||
/** The code of a source file that can use a key -- comments and <style> blocks are dropped */
|
||||
const codeOf = (file: string) => maskSource(file, fs.readFileSync(file, "utf8"));
|
||||
|
||||
/** Every key in en.json must appear as a string literal in src/ or match a dynamic prefix */
|
||||
export function checkUnusedKeys(): string[] {
|
||||
const code = [...sourceFiles()].filter((file) => !isTestFile(file)).map(codeOf).join("\n");
|
||||
const prefixes = [...code.matchAll(/\bt\(\s*["'`]([\w.-]+\.)(?:\$\{|["'`]\s*\+)/g)].map((match) => match[1] ?? "");
|
||||
const isUsed = (key: string) =>
|
||||
["'", '"', "`"].some((quote) => code.includes(`${quote}${key}${quote}`)) ||
|
||||
prefixes.some((prefix) => key.startsWith(prefix));
|
||||
return Object.keys(readLocale("en.json"))
|
||||
.filter((key) => !IGNORED_KEYS.some((pattern) => pattern.test(key)) && !isUsed(key))
|
||||
.map((key) => `en.json: unused key ${key}`);
|
||||
}
|
||||
|
||||
const isIgnored = (node: TemplateNode) =>
|
||||
IGNORED_ELEMENTS.has(node.tag ?? "") ||
|
||||
(node.props ?? []).some((prop) => prop.name === "class" && prop.value?.content.includes("material-symbols"));
|
||||
|
||||
/** Templates shouldn't have plain text, to ignore them we can use <!-- i18n-ignore --> */
|
||||
export function checkRawText(): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of sourceFiles()) {
|
||||
if (!file.endsWith(".vue")) continue;
|
||||
const ast = parse(fs.readFileSync(file, "utf8"), { filename: file }).descriptor.template?.ast;
|
||||
const walk = (node: TemplateNode) => {
|
||||
if (node.type === ELEMENT && isIgnored(node)) return;
|
||||
if (node.type === TEXT && typeof node.content === "string" && /\p{L}/u.test(node.content)) {
|
||||
problems.push(`${rel(file)}:${node.loc.start.line}: raw text "${node.content.trim()}", use t()`);
|
||||
}
|
||||
let skip = false;
|
||||
for (const child of node.children ?? []) {
|
||||
if (!skip) walk(child);
|
||||
if (child.type === COMMENT) skip = typeof child.content === "string" && child.content.trim() === IGNORE_COMMENT;
|
||||
else if (child.type === ELEMENT || (child.type === TEXT && String(child.content).trim())) skip = false;
|
||||
}
|
||||
};
|
||||
if (ast) walk(ast as unknown as TemplateNode);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* npm run i18n:types regenerate messages.generated.ts
|
||||
* npm run i18n:sync regenerate the types, translate new keys (DEEPL_API_KEY)
|
||||
* npm run i18n:enforce-order regenerate the types, reorder locales like en.json
|
||||
* npm run i18n:cleanup regenerate the types, remove obsolete keys
|
||||
* npm run i18n:check translations, types, unused keys and raw text
|
||||
* npm run i18n:check:strict checks for raw `$t` and 'vue-i18n' imports, including JS files
|
||||
*/
|
||||
|
||||
import { checkRawText, checkUnusedKeys } from "./check.ts";
|
||||
import { report } from "./shared.ts";
|
||||
import { checkTranslations, syncTranslations } from "./sync.ts";
|
||||
import { checkTypes, writeTypes } from "./types.ts";
|
||||
|
||||
const [command, ...flags] = process.argv.slice(2);
|
||||
const has = (flag: string) => flags.includes(flag);
|
||||
|
||||
try {
|
||||
if (command === "types") {
|
||||
process.exitCode = writeTypes();
|
||||
} else if (command === "sync") {
|
||||
process.exitCode = writeTypes() || (await syncTranslations({ enforceOrder: has("--enforce-order"), cleanup: has("--cleanup") }));
|
||||
} else if (command === "check") {
|
||||
const translations = checkTranslations();
|
||||
const problems = [...translations, ...checkTypes(has("--strict")), ...checkUnusedKeys(), ...checkRawText()];
|
||||
if (problems.length) {
|
||||
report(problems);
|
||||
if (translations.length) console.error("Run: npm run i18n:sync (or i18n:cleanup)");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("✅ i18n checks passed");
|
||||
}
|
||||
} else {
|
||||
console.error("Usage: node scripts/i18n/index.ts <types | sync [--enforce-order | --cleanup] | check [--strict]>");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("\n❌ i18n script failed:", error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
|
||||
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
export const srcDir = path.join(root, "src");
|
||||
export const i18nDir = path.join(srcDir, "i18n");
|
||||
export const localesDir = path.join(i18nDir, "locales");
|
||||
export const generatedFile = path.join(i18nDir, "messages.generated.ts");
|
||||
|
||||
export type Messages = Record<string, string>;
|
||||
|
||||
/** Tests t/$t with throwaway keys */
|
||||
export const isTestFile = (file: string) => /\.test\.[jt]s$/.test(file);
|
||||
export const rel = (file: string) => path.relative(root, file);
|
||||
export const readJson = (file: string): unknown => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
/** Flat nested messages into `{ "a.b.c": "message" }`; null counts as empty message. */
|
||||
export function flatten(tree: unknown, prefix = ""): Messages {
|
||||
const result: Messages = {};
|
||||
for (const [key, value] of Object.entries(tree as Record<string, unknown>)) {
|
||||
const fullKey = prefix + key;
|
||||
if (typeof value === "string") {
|
||||
result[fullKey] = value;
|
||||
} else if (value === null) {
|
||||
result[fullKey] = "";
|
||||
} else if (typeof value === "object") {
|
||||
Object.assign(result, flatten(value, `${fullKey}.`));
|
||||
} else {
|
||||
throw new Error(`${fullKey}: messages must be strings or objects`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const readLocale = (file: string): Messages => flatten(readJson(path.join(localesDir, file)));
|
||||
|
||||
/** File names of every locale except en.json, sorted */
|
||||
export const translationFiles = (): string[] =>
|
||||
fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => file.endsWith(".json") && file !== "en.json")
|
||||
.sort();
|
||||
|
||||
/** .vue, .ts and .js in src/ without the locales and the generated types */
|
||||
export function* sourceFiles(skipI18nDir = false, dir = srcDir): Generator<string> {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (full !== localesDir && !(skipI18nDir && full === i18nDir)) yield* sourceFiles(skipI18nDir, full);
|
||||
} else if (/\.(vue|ts|js)$/.test(entry.name) && full !== generatedFile) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function report(problems: string[]): void {
|
||||
for (const problem of problems) console.error(`✗ ${problem}`);
|
||||
console.error(`\n${problems.length} problem(s) found.`);
|
||||
}
|
||||
|
||||
const blank = (text: string) => text.replace(/[^\n]/g, " ");
|
||||
|
||||
/** Replaces the comments of JS/TS code by spaces and, with `strings`, the content of '...' and "..." literals too */
|
||||
export function maskJs(text: string, strings = false): string {
|
||||
const scan = (start: number, inExpression: boolean): [string, number] => {
|
||||
let out = "";
|
||||
let i = start;
|
||||
let depth = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
const next = text.charAt(i + 1);
|
||||
if (ch === "/" && (next === "/" || next === "*")) {
|
||||
const close = next === "/" ? text.indexOf("\n", i) : text.indexOf("*/", i + 2);
|
||||
const end = close === -1 ? text.length : next === "/" ? close : close + 2;
|
||||
out += blank(text.slice(i, end));
|
||||
i = end;
|
||||
} else if (ch === "`") {
|
||||
const [masked, end] = maskTemplate(i);
|
||||
out += masked;
|
||||
i = end;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
let end = i + 1;
|
||||
while (end < text.length && text.charAt(end) !== ch) {
|
||||
if (text.charAt(end) === "\\") end++;
|
||||
else if (text.charAt(end) === "\n") break; // it's not a string
|
||||
end++;
|
||||
}
|
||||
const closed = text.charAt(end) === ch;
|
||||
const literal = text.slice(i, closed ? end + 1 : end);
|
||||
out += strings && !inExpression && closed ? ch + blank(literal.slice(1, -1)) + ch : literal;
|
||||
i += literal.length;
|
||||
} else {
|
||||
if (inExpression && ch === "{") depth++;
|
||||
else if (inExpression && ch === "}") {
|
||||
if (depth === 0) return [out, i];
|
||||
depth--;
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return [out, i];
|
||||
};
|
||||
|
||||
// A template literal from its opening backtick -- its `${...}` expressions are scanned
|
||||
const maskTemplate = (start: number): [string, number] => {
|
||||
const keep = (chunk: string) => (strings ? blank(chunk) : chunk);
|
||||
let out = "`";
|
||||
let i = start + 1;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (ch === "\\") {
|
||||
out += keep(text.slice(i, i + 2));
|
||||
i += 2;
|
||||
} else if (ch === "`") {
|
||||
return [`${out}\``, i + 1];
|
||||
} else if (ch === "$" && text.charAt(i + 1) === "{") {
|
||||
const [code, end] = scan(i + 2, true);
|
||||
out += `\${${code}`;
|
||||
i = end;
|
||||
if (text.charAt(i) === "}") {
|
||||
out += "}";
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
out += keep(ch);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return [out, i];
|
||||
};
|
||||
|
||||
return scan(0, false)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The code of a source file without comments at the same offsets as the file. In .vue files only the template and the scripts
|
||||
* are kept while styles no
|
||||
*/
|
||||
export function maskSource(file: string, text: string, strings = false): string {
|
||||
if (!file.endsWith(".vue")) return maskJs(text, strings);
|
||||
const { descriptor } = parse(text, { filename: file });
|
||||
let out = text;
|
||||
// each replacement keeps the length of what it replaces, so the blocks can be masked one after the other
|
||||
const mask = ({ start, end }: { start: { offset: number }; end: { offset: number } }, apply: (code: string) => string) => {
|
||||
out = out.slice(0, start.offset) + apply(text.slice(start.offset, end.offset)) + out.slice(end.offset);
|
||||
};
|
||||
for (const style of descriptor.styles) mask(style.loc, blank);
|
||||
for (const script of [descriptor.script, descriptor.scriptSetup]) {
|
||||
if (script) mask(script.loc, (code) => maskJs(code, strings));
|
||||
}
|
||||
if (descriptor.template) mask(descriptor.template.loc, (code) => code.replace(/<!--[\s\S]*?-->/g, blank));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DeepLClient, type TargetLanguageCode, type TranslateTextOptions } from "deepl-node";
|
||||
import { localesDir, readJson, readLocale, translationFiles } from "./shared.ts";
|
||||
|
||||
type Tree = { [key: string]: unknown };
|
||||
|
||||
const MASTER_LANGUAGE = "en";
|
||||
|
||||
const DEEPL_LANGUAGES: Record<string, string> = {
|
||||
"zh-cn": "ZH-HANS",
|
||||
"zh-tw": "ZH-HANT",
|
||||
pt: "PT-PT",
|
||||
"pt-br": "PT-BR",
|
||||
en: "EN",
|
||||
"en-us": "EN-US",
|
||||
"en-gb": "EN-GB",
|
||||
"sv-se": "SV",
|
||||
ua: "UK",
|
||||
"nl-be": "NL",
|
||||
cz: "CS",
|
||||
bg: "BG",
|
||||
};
|
||||
|
||||
let translator: DeepLClient | null = null;
|
||||
|
||||
const isTree = (value: unknown): value is Tree => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Reports what syncTranslations would change */
|
||||
export function checkTranslations(): string[] {
|
||||
const master = Object.keys(readLocale("en.json"));
|
||||
const problems: string[] = [];
|
||||
const translatable = (key: string) => !key.startsWith("languages.");
|
||||
for (const file of translationFiles()) {
|
||||
let target: Record<string, string>;
|
||||
try {
|
||||
target = readLocale(file);
|
||||
} catch (error) {
|
||||
problems.push(`${file}: cannot be read (${error instanceof Error ? error.message : error})`);
|
||||
continue;
|
||||
}
|
||||
for (const key of master.filter(translatable)) {
|
||||
if (!target[key]) problems.push(`${file}: missing "${key}"`);
|
||||
}
|
||||
for (const key of Object.keys(target).filter(translatable)) {
|
||||
if (!master.includes(key)) problems.push(`${file}: obsolete key "${key}"`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
async function translateText(text: string, targetLanguage: string, keyPath: string): Promise<string | null> {
|
||||
if (!text.trim()) {
|
||||
console.warn(`Skipping translation for empty text: "${text}"`);
|
||||
return text;
|
||||
}
|
||||
if (keyPath === "languages" || keyPath.startsWith("languages.")) return text;
|
||||
|
||||
const hasPlaceholders = /\{[^}]+\}/.test(text);
|
||||
let textToTranslate = text;
|
||||
const options: TranslateTextOptions = {};
|
||||
if (hasPlaceholders) {
|
||||
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
|
||||
textToTranslate = text.replace(/(\{[^}]+\})/g, "<ph>$1</ph>");
|
||||
options.tagHandling = "xml";
|
||||
options.ignoreTags = ["ph"];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Translating "${text}" from '${MASTER_LANGUAGE}' to '${targetLanguage}'...`);
|
||||
const deeplTarget = (DEEPL_LANGUAGES[targetLanguage.toLowerCase()] ?? targetLanguage.toUpperCase()) as TargetLanguageCode;
|
||||
const result = await translator?.translateText(textToTranslate, MASTER_LANGUAGE, deeplTarget, options);
|
||||
await new Promise((resolve) => {setTimeout(resolve, 100)}); // avoid rate-limiting
|
||||
let translated = result?.text ?? "";
|
||||
if (hasPlaceholders) {
|
||||
// the translator might add spaces around the tags
|
||||
translated = translated.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, "$1");
|
||||
}
|
||||
if (!translated.trim()) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}): empty result`);
|
||||
return null;
|
||||
}
|
||||
return translated;
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorders `value` to match the key order of `master`, dropping keys `master` lacks */
|
||||
function reorderObject(value: unknown, master: unknown): unknown {
|
||||
if (!isTree(value) || !isTree(master)) return value;
|
||||
const ordered: Tree = {};
|
||||
for (const key of Object.keys(master)) {
|
||||
if (Object.hasOwn(value, key)) ordered[key] = reorderObject(value[key], master[key]);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Add missing keys, remove obsolete ones */
|
||||
async function processKeys(
|
||||
master: Tree,
|
||||
target: Tree,
|
||||
lang: string,
|
||||
parents: string[],
|
||||
enforceOrder: boolean,
|
||||
cleanup: boolean,
|
||||
): Promise<boolean | "UNSUPPORTED"> {
|
||||
let changed = false;
|
||||
|
||||
for (const key of Object.keys(master)) {
|
||||
const masterValue = master[key];
|
||||
const keyPath = [...parents, key].join(".");
|
||||
|
||||
// the "languages" block is always copied from master
|
||||
if (key === "languages" && parents.length === 0 && !cleanup) {
|
||||
if (!enforceOrder) console.log(`Copying entire "languages" object from master to ${lang}.json`);
|
||||
target[key] = structuredClone(masterValue);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (Object.hasOwn(target, key) && isTree(masterValue) !== isTree(target[key])) {
|
||||
console.log(`🗑️ Removing "${keyPath}" from ${lang}.json: its structure differs from en.json`);
|
||||
Reflect.deleteProperty(target, key);
|
||||
changed = true;
|
||||
}
|
||||
if (isTree(masterValue)) {
|
||||
if (!cleanup && !isTree(target[key])) {
|
||||
console.log(`Creating missing object structure for "${keyPath}" in ${lang}.json`);
|
||||
target[key] = {};
|
||||
changed = true;
|
||||
}
|
||||
const child = target[key];
|
||||
if (isTree(child)) {
|
||||
const result = await processKeys(masterValue, child, lang, [...parents, key], enforceOrder, cleanup);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.log(`Skipping translation for "${lang}" due to unsupported structure.`);
|
||||
return result;
|
||||
}
|
||||
if (result) changed = true;
|
||||
}
|
||||
} else if (!cleanup) {
|
||||
if (typeof masterValue === "string") {
|
||||
const existing = target[key];
|
||||
if (!Object.hasOwn(target, key) || existing === "" || existing === null) {
|
||||
if (enforceOrder) {
|
||||
console.log(`Skipping new key "${keyPath}" for ${lang}.json`);
|
||||
} else {
|
||||
const translated = await translateText(masterValue, lang, keyPath);
|
||||
if (translated === null) return "UNSUPPORTED";
|
||||
if (target[key] !== translated) {
|
||||
target[key] = translated;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!Object.hasOwn(target, key)) {
|
||||
console.log(`Key "${keyPath}" (non-string) missing in ${lang}.json. Copying from English.`);
|
||||
target[key] = masterValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(master, key)) {
|
||||
console.log(`🗑️ Removing obsolete key "${[...parents, key].join(".")}" from ${lang}.json`);
|
||||
Reflect.deleteProperty(target, key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function syncTranslations({ enforceOrder, cleanup }: { enforceOrder: boolean; cleanup: boolean }): Promise<number> {
|
||||
const reorder = enforceOrder && !cleanup;
|
||||
|
||||
if (!enforceOrder && !cleanup) {
|
||||
const apiKey = process.env.DEEPL_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
return 1;
|
||||
}
|
||||
translator = new DeepLClient(apiKey);
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
} else if (cleanup) {
|
||||
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
|
||||
} else {
|
||||
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
|
||||
}
|
||||
|
||||
const master = readJson(path.join(localesDir, "en.json")) as Tree;
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const file of translationFiles()) {
|
||||
const targetFile = path.join(localesDir, file);
|
||||
const lang = path.basename(file, ".json");
|
||||
let original: Tree;
|
||||
try {
|
||||
const parsed = readJson(targetFile);
|
||||
if (!isTree(parsed)) throw new Error("the root must be an object");
|
||||
original = parsed;
|
||||
} catch (error) {
|
||||
console.error(`❌ Could not read ${targetFile}, leaving it unchanged: ${error instanceof Error ? error.message : error}`);
|
||||
failed.push(file);
|
||||
continue;
|
||||
}
|
||||
let target = structuredClone(original);
|
||||
|
||||
const result = await processKeys(master, target, lang, [], reorder, cleanup);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.error(`❌ ${targetFile} was left unchanged: a translation failed.`);
|
||||
failed.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
let orderChanged = false;
|
||||
if (reorder) {
|
||||
target = reorderObject(target, master) as Tree;
|
||||
orderChanged = JSON.stringify(original) !== JSON.stringify(target);
|
||||
if (orderChanged) console.log(`- Reordered keys in ${lang}.json`);
|
||||
}
|
||||
|
||||
if (result || orderChanged) {
|
||||
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`);
|
||||
if (!cleanup && !reorder) console.log(`Successfully updated ${targetFile}`);
|
||||
} else {
|
||||
console.log(`No changes needed for ${targetFile}`);
|
||||
}
|
||||
}
|
||||
if (failed.length) {
|
||||
console.error(`\n❌ Synchronization incomplete, ${failed.length} locale(s) were not changed: ${failed.join(", ")}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (cleanup) console.log("\n✅ Cleanup complete (obsolete keys removed).");
|
||||
else if (reorder) console.log("\n✅ Enforce order complete.");
|
||||
else console.log("\n✅ Translation synchronization complete (via DeepL).");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import fs from "node:fs";
|
||||
import { generatedFile, isTestFile, maskSource, type Messages, readLocale, rel, sourceFiles, translationFiles } from "./shared.ts";
|
||||
|
||||
const OPTIONAL_PLACEHOLDERS = new Set(["suffix", "prefix"]);
|
||||
const NAMED_PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
|
||||
/** vue-i18n literal interpolation, like {'|'} to print a pipe or @ */
|
||||
const LITERAL_INTERPOLATION = /\{'[^']*'\}/g;
|
||||
const REGENERATE = "Run: npm run i18n:types";
|
||||
|
||||
/** Named placeholders of a message in order */
|
||||
function placeholders(message: string): string[] {
|
||||
const names: string[] = [];
|
||||
for (const match of message.matchAll(NAMED_PLACEHOLDER)) {
|
||||
const name = match[1] ?? "";
|
||||
if (!names.includes(name)) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Returns a description of unsupported vue-i18n syntax, or null. */
|
||||
function unsupportedSyntax(message: string): string | null {
|
||||
const rest = message.replace(LITERAL_INTERPOLATION, "").replace(NAMED_PLACEHOLDER, "");
|
||||
if (/[{}]/.test(rest)) return "braces other than {name} placeholders";
|
||||
if (rest.includes("|")) return "plural separator '|'";
|
||||
if (/@[.:]/.test(rest)) return "linked message '@:' / '@.'";
|
||||
return null;
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const messages = readLocale("en.json");
|
||||
const problems: string[] = [];
|
||||
const lines: string[] = [];
|
||||
for (const [key, message] of Object.entries(messages)) {
|
||||
const unsupported = unsupportedSyntax(message);
|
||||
if (unsupported) problems.push(`en.json: ${key} uses ${unsupported}, which the types do not support`);
|
||||
const names = placeholders(message);
|
||||
const params = names.length ? names.map((name) => JSON.stringify(name)).join(" | ") : "never";
|
||||
lines.push(` ${JSON.stringify(key)}: ${params};`);
|
||||
}
|
||||
const content = `// This file is generated by scripts/i18n from src/i18n/locales/en.json.
|
||||
// Run \`make generate-translation-types\` \`npm run i18n:types\` after changing en.json. Do not edit manually.
|
||||
|
||||
/** Every message in en.json mapped to the named placeholders. Used to type-check \`t()\` calls */
|
||||
export interface MessageParams {
|
||||
${lines.join("\n")}
|
||||
}
|
||||
|
||||
/** Placeholders that callers may omit */
|
||||
export type OptionalParam = ${[...OPTIONAL_PLACEHOLDERS].map((name) => JSON.stringify(name)).join(" | ")};
|
||||
`;
|
||||
return { content, messages, problems };
|
||||
}
|
||||
|
||||
/** Regenerates messages.generated.ts; returns the exit code. */
|
||||
export function writeTypes(): number {
|
||||
const { content, messages, problems } = generate();
|
||||
if (problems.length) {
|
||||
for (const problem of problems) console.error(`✗ ${problem}`);
|
||||
return 1;
|
||||
}
|
||||
fs.writeFileSync(generatedFile, content);
|
||||
console.log(`Wrote ${rel(generatedFile)} (${Object.keys(messages).length} messages).`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function checkGeneratedFile(expected: string): string[] {
|
||||
if (!fs.existsSync(generatedFile)) return [`${rel(generatedFile)} is missing. ${REGENERATE}`];
|
||||
if (fs.readFileSync(generatedFile, "utf8") !== expected) return [`${rel(generatedFile)} is out of date. ${REGENERATE}`];
|
||||
return [];
|
||||
}
|
||||
|
||||
function checkLocalePlaceholders(enMessages: Messages): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of translationFiles()) {
|
||||
for (const [key, message] of Object.entries(readLocale(file))) {
|
||||
const english = enMessages[key];
|
||||
if (english === undefined) continue;
|
||||
const expected = new Set(placeholders(english));
|
||||
const actual = new Set(placeholders(message));
|
||||
for (const name of actual) {
|
||||
if (!expected.has(name)) problems.push(`${file}: ${key} uses {${name}}, which en.json does not provide`);
|
||||
}
|
||||
for (const name of expected) {
|
||||
if (!actual.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`${file}: ${key} is missing {${name}}`);
|
||||
}
|
||||
const unsupported = unsupportedSyntax(message);
|
||||
if (unsupported) problems.push(`${file}: ${key} uses ${unsupported}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** .ts files and .vue files with <script lang="ts"> are checked by vue-tsc */
|
||||
function isTypeScript(file: string, text: string): boolean {
|
||||
if (file.endsWith(".ts")) return true;
|
||||
return file.endsWith(".vue") && /<script[^>]*lang=["']ts["']/.test(text);
|
||||
}
|
||||
|
||||
const BYPASS_RULES = [
|
||||
{
|
||||
pattern: /from\s+["']vue-i18n["']/,
|
||||
message: "import useI18n from '@/i18n/index.ts' instead of 'vue-i18n'",
|
||||
inStrings: true,
|
||||
},
|
||||
{
|
||||
pattern: /(^|[^\w$])\$t\b/,
|
||||
message: "use t(...) from useI18n() in '@/i18n/index.ts' instead of $t(...), so the key and params are type-checked",
|
||||
inStrings: false,
|
||||
},
|
||||
];
|
||||
|
||||
// The start of a t(...) call: t(, $t(, this.$t( or i18n.global.t(
|
||||
const CALL_START = /(?:(?<![\w$.])(?:\$t|t|i18n\.global\.t)|\bthis\.\$t)\(/g;
|
||||
|
||||
interface Params {
|
||||
names: Set<string>;
|
||||
dynamic: boolean;
|
||||
}
|
||||
|
||||
const lineOf = (text: string, index: number) => text.slice(0, index).split("\n").length;
|
||||
|
||||
function skipSpaces(text: string, index: number): number {
|
||||
let i = index;
|
||||
while (i < text.length && /\s/.test(text.charAt(i))) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Reads a string literal starting at `index`; returns null for anything else or a dynamic template */
|
||||
function readStringLiteral(text: string, index: number): { value: string; end: number } | null {
|
||||
const quote = text.charAt(index);
|
||||
if (quote !== '"' && quote !== "'" && quote !== "`") return null;
|
||||
let i = index + 1;
|
||||
let value = "";
|
||||
while (i < text.length && text.charAt(i) !== quote) {
|
||||
if (text.charAt(i) === "\\") {
|
||||
value += text.charAt(i + 1);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (quote === "`" && text.charAt(i) === "$" && text.charAt(i + 1) === "{") return null;
|
||||
value += text.charAt(i);
|
||||
i++;
|
||||
}
|
||||
return i < text.length ? { value, end: i + 1 } : null;
|
||||
}
|
||||
|
||||
const isQuote = (ch: string) => ch === '"' || ch === "'" || ch === "`";
|
||||
|
||||
function skipString(text: string, index: number): number {
|
||||
const quote = text.charAt(index);
|
||||
let i = index + 1;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) return i + 1;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const CLOSERS: Record<string, string> = { "{": "}", "[": "]", "(": ")" };
|
||||
|
||||
/** Finds the index of the bracket closing the one at `index`, skipping strings. */
|
||||
function findClosing(text: string, index: number): number {
|
||||
const stack = [CLOSERS[text.charAt(index)]];
|
||||
let i = index + 1;
|
||||
while (i < text.length && stack.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (isQuote(ch)) {
|
||||
const end = skipString(text, i);
|
||||
if (end === -1) return -1;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
const closer = CLOSERS[ch];
|
||||
if (closer) stack.push(closer);
|
||||
else if (ch === stack.at(-1)) stack.pop();
|
||||
i++;
|
||||
}
|
||||
return stack.length ? -1 : i - 1;
|
||||
}
|
||||
|
||||
/** Property names of the object literal at `index`; `dynamic` when a spread or computed key hides some */
|
||||
function readObjectKeys(text: string, index: number): Params | null {
|
||||
const close = findClosing(text, index);
|
||||
if (close === -1) return null;
|
||||
const names = new Set<string>();
|
||||
let dynamic = false;
|
||||
let segmentStart = index + 1;
|
||||
let i = index + 1;
|
||||
const flush = (end: number) => {
|
||||
const segment = text.slice(segmentStart, end).trim();
|
||||
if (!segment) return;
|
||||
if (segment.startsWith("...") || segment.startsWith("[")) {
|
||||
dynamic = true;
|
||||
return;
|
||||
}
|
||||
const match = segment.match(/^(?:([A-Za-z_$][\w$]*)|'([^']*)'|"([^"]*)")\s*(?::|$)/);
|
||||
const name = match?.[1] ?? match?.[2] ?? match?.[3];
|
||||
if (name === undefined) dynamic = true;
|
||||
else names.add(name);
|
||||
};
|
||||
while (i < close) {
|
||||
const ch = text.charAt(i);
|
||||
if (isQuote(ch)) {
|
||||
i = skipString(text, i);
|
||||
continue;
|
||||
}
|
||||
if (ch === "{" || ch === "[" || ch === "(") {
|
||||
i = findClosing(text, i) + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ",") {
|
||||
flush(i);
|
||||
segmentStart = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
flush(close);
|
||||
return { names, dynamic };
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields every t(...) call whose first argument is a literal key. `text` is the source without
|
||||
* comments, `bare` the same without the content of its strings
|
||||
*/
|
||||
export function* literalCalls(text: string, bare: string): Generator<{ key: string; params: Params; index: number }> {
|
||||
for (const match of bare.matchAll(CALL_START)) {
|
||||
const keyStart = skipSpaces(text, match.index + match[0].length);
|
||||
const key = readStringLiteral(text, keyStart);
|
||||
if (!key) continue; // only vue-tsc can check it
|
||||
let i = skipSpaces(text, key.end);
|
||||
if (text.charAt(i) !== "," && text.charAt(i) !== ")") continue; // 'prefix.' + name if the key isn't literal
|
||||
let params: Params = { names: new Set(), dynamic: false };
|
||||
if (text.charAt(i) === ",") {
|
||||
i = skipSpaces(text, i + 1);
|
||||
if (text.charAt(i) === "{") params = readObjectKeys(text, i) ?? params;
|
||||
else if (text.charAt(i) !== ")") params = { names: new Set(), dynamic: true };
|
||||
}
|
||||
yield { key: key.value, params, index: match.index };
|
||||
}
|
||||
}
|
||||
|
||||
function checkCall(key: string, params: Params, messages: Messages): string[] {
|
||||
const expected = messages[key];
|
||||
if (expected === undefined) return [`unknown translation key "${key}"`];
|
||||
if (params.dynamic) return []; // spread or variable: names unknown
|
||||
const known = new Set(placeholders(expected));
|
||||
const problems: string[] = [];
|
||||
for (const name of params.names) {
|
||||
if (!known.has(name)) problems.push(`"${key}" does not take a {${name}} param`);
|
||||
}
|
||||
for (const name of known) {
|
||||
if (!params.names.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`"${key}" is missing its {${name}} param`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function checkTypedUsage(messages: Messages, strict: boolean): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of sourceFiles(true)) {
|
||||
if (isTestFile(file)) continue; // tests t/$t with throwaway keys
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const code = maskSource(file, text);
|
||||
const bare = maskSource(file, text, true);
|
||||
if (strict || isTypeScript(file, text)) {
|
||||
const codeLines = code.split("\n");
|
||||
const bareLines = bare.split("\n");
|
||||
for (const rule of BYPASS_RULES) {
|
||||
const lines = rule.inStrings ? codeLines : bareLines;
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (rule.pattern.test(line)) problems.push(`${rel(file)}:${index + 1}: ${rule.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const call of literalCalls(code, bare)) {
|
||||
for (const problem of checkCall(call.key, call.params, messages)) {
|
||||
problems.push(`${rel(file)}:${lineOf(text, call.index)}: ${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Checks the generated types, the placeholders of every locale and the t() calls in src/. With `strict`, raw `$t` and 'vue-i18n' */
|
||||
export function checkTypes(strict: boolean): string[] {
|
||||
const { content, messages, problems } = generate();
|
||||
return [...problems, ...checkGeneratedFile(content), ...checkLocalePlaceholders(messages), ...checkTypedUsage(messages, strict)];
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
// scripts/sync-translations.js
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Translator } from 'deepl-node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const checkOnly = args.includes('--check') || args.includes('-c');
|
||||
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
|
||||
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
|
||||
|
||||
// --- Configuration ---
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const localesDir = path.resolve(__dirname, '../src/i18n');
|
||||
const masterLocaleFile = path.join(localesDir, 'en.json');
|
||||
const masterLanguageCode = 'en';
|
||||
const targetLocaleFiles = globSync(path.join(localesDir, '*.json'))
|
||||
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
|
||||
|
||||
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
|
||||
if (requireApiKey && !DEEPL_API_KEY) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const translator = requireApiKey ? new Translator(DEEPL_API_KEY) : null;
|
||||
|
||||
const deeplLangMap = {
|
||||
'zh-cn': 'ZH-HANS',
|
||||
'zh-tw': 'ZH-HANT',
|
||||
'pt': 'PT-PT', // or 'PT-BR' if you want Brazilian Portuguese
|
||||
'pt-br': 'PT-BR',
|
||||
'en': 'EN',
|
||||
'en-us': 'EN-US',
|
||||
'en-gb': 'EN-GB',
|
||||
'sv-se': 'SV',
|
||||
'ua': 'UK',
|
||||
'nl-be': 'NL',
|
||||
'cz': 'CS',
|
||||
'bg': 'BG',
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
// --- Translation Function using DeepL ---
|
||||
async function translateText(text, targetLanguage, keyPath = '') {
|
||||
if (checkOnly) {
|
||||
// In check mode, just return the original text to detect changes
|
||||
return text;
|
||||
}
|
||||
|
||||
if (!text || typeof text !== 'string' || text.trim() === '') {
|
||||
console.warn(`Skipping translation for empty or non-string text: "${text}"`);
|
||||
return text;
|
||||
}
|
||||
|
||||
if (keyPath === 'languages' || keyPath.startsWith('languages.')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const hasPlaceholders = /\{[^}]+\}/.test(text);
|
||||
let textToTranslate = text;
|
||||
const options = {};
|
||||
|
||||
if (hasPlaceholders) {
|
||||
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
|
||||
textToTranslate = text.replace(/(\{[^}]+\})/g, '<ph>$1</ph>');
|
||||
options.tagHandling = 'xml';
|
||||
options.ignoreTags = ['ph'];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Translating "${text}" from '${masterLanguageCode}' to '${targetLanguage}'...`);
|
||||
|
||||
const deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
|
||||
|
||||
const result = await translator.translateText(textToTranslate, masterLanguageCode, deeplTargetLang, options);
|
||||
|
||||
// Delay to avoid rate-limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
let translatedText = result.text;
|
||||
|
||||
if (hasPlaceholders) {
|
||||
// Unwrap the placeholders. The translator might add spaces around tags.
|
||||
translatedText = translatedText.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, '$1');
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, err.message || err);
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder objects to match eng (master lang) key order
|
||||
function reorderObject(obj, masterObj) {
|
||||
// If obj is not an object, return as is
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
|
||||
const newObj = {};
|
||||
for (const key in masterObj) {
|
||||
if (Object.hasOwn(masterObj, key)) {
|
||||
if (Object.hasOwn(obj, key)) {
|
||||
newObj[key] = reorderObject(obj[key], masterObj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
|
||||
// --- Recursive key processor ---
|
||||
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
|
||||
let changesMade = false;
|
||||
let meaningfulChanges = 0; // Only count meaningful changes
|
||||
|
||||
// First pass: Add/update keys from master to target
|
||||
for (const key in masterObj) {
|
||||
if (Object.hasOwn(masterObj, key)) {
|
||||
const currentPathPartsNext = [...currentPathParts, key];
|
||||
const currentKeyPath = currentPathPartsNext.join('.');
|
||||
|
||||
const masterValue = masterObj[key];
|
||||
|
||||
// Special handling for "languages" key - always copy the entire object from master
|
||||
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
|
||||
if (!checkOnly && !doEnforceOrder) {
|
||||
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
|
||||
}
|
||||
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
|
||||
changesMade = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
|
||||
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Creating new object structures is meaningful
|
||||
// In check mode, create a temporary object for processing
|
||||
targetObj[key] = {};
|
||||
} else {
|
||||
console.log(`Creating missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
|
||||
targetObj[key] = {};
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (targetObj[key] && typeof targetObj[key] === 'object') {
|
||||
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
|
||||
doEnforceOrder, doCleanupOnly);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
if (typeof result === 'number') {
|
||||
meaningfulChanges += result;
|
||||
changesMade = true;
|
||||
} else if (result) {
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
} else if (!doCleanupOnly) {
|
||||
// Only handle non‑object values
|
||||
if (typeof masterValue === 'string') {
|
||||
const existingTargetValue = targetObj[key];
|
||||
const isMissing = !Object.hasOwn(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
|
||||
|
||||
if (isMissing) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Translation is meaningful
|
||||
} else if (doEnforceOrder) {
|
||||
// Skip translations for new keys
|
||||
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
} else {
|
||||
// Sync translation
|
||||
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
|
||||
if (result === "") {
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
targetObj[key] = result;
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!Object.hasOwn(targetObj, key)) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Copying non-string values is meaningful
|
||||
} else {
|
||||
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
|
||||
targetObj[key] = masterValue;
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Remove obsolete keys that exist in target but not in master
|
||||
const keysToRemove = [];
|
||||
for (const key in targetObj) {
|
||||
if (Object.hasOwn(targetObj, key)) {
|
||||
if (!Object.hasOwn(masterObj, key)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
const currentKeyPath = [...currentPathParts, key].join('.');
|
||||
if (checkOnly) {
|
||||
console.log(`Would remove obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Removing obsolete keys is meaningful
|
||||
} else {
|
||||
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
|
||||
delete targetObj[key];
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
return checkOnly ? meaningfulChanges : changesMade;
|
||||
}
|
||||
|
||||
// --- Main synchronization ---
|
||||
async function syncAllTranslations() {
|
||||
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const isEnforceOrder = enforceOrder && !cleanupOnly;
|
||||
const isCleanup = cleanupOnly;
|
||||
|
||||
if (checkOnly) {
|
||||
console.log("--- Checking for translation changes (no translations will be performed) ---");
|
||||
} else if (isCleanup) {
|
||||
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
|
||||
} else if (isEnforceOrder) {
|
||||
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
|
||||
} else if (isSync) {
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
}
|
||||
|
||||
if (!await fs.pathExists(masterLocaleFile)) {
|
||||
console.error(`Master locale file not found: ${masterLocaleFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const masterContent = await fs.readJson(masterLocaleFile);
|
||||
console.log(`Loaded master translations from ${masterLocaleFile}`);
|
||||
|
||||
let meaningfulChanges = 0;
|
||||
let hasMeaningfulChanges = false;
|
||||
|
||||
for (const targetFile of targetLocaleFiles) {
|
||||
const targetLangCode = path.basename(targetFile, '.json');
|
||||
let targetContent = {};
|
||||
let originalTargetContent = null;
|
||||
const fileExisted = await fs.pathExists(targetFile);
|
||||
|
||||
if (fileExisted) {
|
||||
try {
|
||||
originalTargetContent = await fs.readJson(targetFile);
|
||||
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
|
||||
targetContent = {};
|
||||
originalTargetContent = null;
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) continue;
|
||||
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
|
||||
}
|
||||
|
||||
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
|
||||
|
||||
let reorderedContent = targetContent;
|
||||
let orderChanged = false;
|
||||
if (isEnforceOrder && originalTargetContent && !checkOnly) {
|
||||
reorderedContent = reorderObject(targetContent, masterContent);
|
||||
// Compare the reordered content with the original to see if order changed
|
||||
const origStr = JSON.stringify(originalTargetContent);
|
||||
const reorderedStr = JSON.stringify(reorderedContent);
|
||||
if (origStr !== reorderedStr) {
|
||||
orderChanged = true;
|
||||
console.log(`- Reordered keys in ${targetLangCode}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
let wouldChange = false;
|
||||
if (typeof result === 'number' && result > 0) {
|
||||
meaningfulChanges += result;
|
||||
wouldChange = true;
|
||||
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
|
||||
}
|
||||
if (!fileExisted && !isCleanup) {
|
||||
wouldChange = true;
|
||||
console.log(`Would create new file for ${targetLangCode}.json`);
|
||||
}
|
||||
if (wouldChange) {
|
||||
hasMeaningfulChanges = true;
|
||||
}
|
||||
} else {
|
||||
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
|
||||
if (needsWrite) {
|
||||
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
|
||||
try {
|
||||
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
|
||||
if (!isCleanup && !isEnforceOrder) {
|
||||
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${targetFile}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(`No changes needed for ${targetFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
if (hasMeaningfulChanges) {
|
||||
console.log(`\n⚠️ Found ${meaningfulChanges} meaningful translation changes needed. -- update via "make sync-translations"`);
|
||||
return 1; // Exit code 1 for meaningful changes needed
|
||||
} else {
|
||||
console.log('\n✅ No meaningful translation changes needed.');
|
||||
return 0; // Exit code 0 for no meaningful changes
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) {
|
||||
console.log('\n✅ Cleanup complete (obsolete keys removed).');
|
||||
} else if (isEnforceOrder) {
|
||||
console.log('\n✅ Enforce order complete.');
|
||||
} else {
|
||||
console.log('\n✅ Translation synchronization complete (via DeepL).');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
syncAllTranslations()
|
||||
.then(exitCode => {
|
||||
process.exit(exitCode);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("\n❌ An error occurred during translation synchronization:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<script>
|
||||
import { onMounted } from "vue";
|
||||
import { mutations } from "@/store"; // Import your store's mutations
|
||||
import { scheduleFullIcons } from "@/utils/loadFullIcons";
|
||||
import { mutations } from "@/store/index.ts"; // Import your store's mutations
|
||||
import { scheduleFullIcons } from "@/utils/loadFullIcons.js";
|
||||
|
||||
mutations.setLoading("main-app", true);
|
||||
export default {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getApiPath, getParentDir } from "@/utils/url.js";
|
||||
import { invalidateDirMetadataCache, invalidateDirMetadataItem } from "@/utils/cache/metadataCache.ts";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
import { invalidateDirMetadataItem, markDirMetadataItemChanged } 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),
|
||||
});
|
||||
invalidateDirMetadataCache({ source: toSource || fromSource, path: getParentDir(destination) });
|
||||
markDirMetadataItemChanged({ source: toSource || fromSource }, 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) {
|
||||
invalidateDirMetadataCache({ source: toSource || fromSource, path: destination });
|
||||
invalidateDirMetadataItem({ source: toSource || fromSource }, destination);
|
||||
if (deleteAfter) {
|
||||
invalidateDirMetadataItem({ source: fromSource }, path);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
// - /api/tools/ -> tools.js
|
||||
// - /api/office/ -> office.js
|
||||
// - /api/wopi/ -> wopi.js
|
||||
// - /api/media/ -> media.js
|
||||
// - /api/media/ -> media.ts
|
||||
// - /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.js";
|
||||
import * as mediaApi from "./media.ts";
|
||||
import * as officeApi from "./office.js";
|
||||
import * as resourcesApi from "./resources.js";
|
||||
import * as settingsApi from "./settings.js";
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import { notify } from "@/notify/index.ts"
|
||||
import { getters, mutations, state } from "@/store/index.ts"
|
||||
import { globalVars } from "@/utils/constants.js"
|
||||
import { globalVars } from "@/utils/constants.ts"
|
||||
import { downloadManager } from "@/utils/downloadManager.ts"
|
||||
import {
|
||||
notifyDownloadComplete,
|
||||
notifyDownloadError,
|
||||
} from "@/utils/appNotifications.js"
|
||||
import { renew } from "@/utils/auth.js"
|
||||
import { getApiPath, getPublicApiPath, getParentDir } from '@/utils/url.js'
|
||||
import { getApiPath, getPublicApiPath } 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, getStreamURLPublic } from "./media.js"
|
||||
import { getStreamURL } from "./media.ts"
|
||||
import {
|
||||
invalidateDirMetadataCache,
|
||||
invalidateDirMetadataItem,
|
||||
markDirMetadataItemChanged,
|
||||
removeDirMetadataItem
|
||||
@@ -35,7 +34,7 @@ function parseMockDataCount(value, defaultCount) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultCount;
|
||||
}
|
||||
const n = parseInt(String(value), 10);
|
||||
const n = Math.trunc(Number(value));
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return defaultCount;
|
||||
}
|
||||
@@ -1026,7 +1025,7 @@ export async function moveCopy(
|
||||
removeDirMetadataItem({ source: item.fromSource }, item.from)
|
||||
}
|
||||
if (rename) {
|
||||
invalidateDirMetadataCache({ source: item.toSource, path: getParentDir(item.to) })
|
||||
invalidateDirMetadataItem({ source: item.toSource }, item.to)
|
||||
} else {
|
||||
markDirMetadataItemChanged({ source: item.toSource }, item.isDir ? `${item.to}/` : item.to)
|
||||
}
|
||||
@@ -1147,9 +1146,6 @@ 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) {
|
||||
@@ -1665,7 +1661,7 @@ export async function moveCopyPublic(
|
||||
removeDirMetadataItem({ isShare: true, hash }, item.from)
|
||||
}
|
||||
if (rename) {
|
||||
invalidateDirMetadataCache({ isShare: true, hash, path: getParentDir(item.to) })
|
||||
invalidateDirMetadataItem({ isShare: true, hash }, item.to)
|
||||
} else {
|
||||
markDirMetadataItemChanged({ isShare: true, hash }, item.isDir ? `${item.to}/` : item.to)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ 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,6 +1,6 @@
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { normalizeMediaPlayerSettings } from "@/utils/mediaSettings.js";
|
||||
import { normalizeSharedMediaSettings } from "@/utils/mediaSettings.js";
|
||||
import { fetchJSON, fetchURL, requestTimeoutSignal } from "./utils.ts";
|
||||
|
||||
const analyticsRequestTimeoutMs = 5000;
|
||||
@@ -124,13 +124,14 @@ export function getMediaSettings() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective media settings for playback. Every authenticated user may read this;
|
||||
* admins receive the full settings object and everyone else the player subset.
|
||||
* @returns {Promise<{ enabled: boolean, profiles: string[], maxConcurrentPerUser: number, hardwareAcceleration: boolean }>}
|
||||
* Shared runtime media state (player + general UI), normalized to a flat shape.
|
||||
* Non-admins receive the PUBLIC UserMedia payload; admins get the full payload on the
|
||||
* same endpoint, which carries the same transcode and imagePreview keys.
|
||||
* @returns {Promise<{ enabled: boolean, profiles: string[], maxConcurrentPerUser: number, imagePreview: object }>}
|
||||
*/
|
||||
export async function getMediaPlayerSettings() {
|
||||
export async function getSharedMediaSettings() {
|
||||
const data = await fetchJSON(getApiPath("settings/media"));
|
||||
return normalizeMediaPlayerSettings(data);
|
||||
return normalizeSharedMediaSettings(data);
|
||||
}
|
||||
|
||||
export async function patchMediaSettings(partial) {
|
||||
|
||||
@@ -51,7 +51,7 @@ function readCacheForScope(scope) {
|
||||
if (!parsed?.viewToken || !parsed?.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
return { viewToken: parsed.viewToken, expiresAt: parsed.expiresAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
close: multistate === 'close',
|
||||
}"
|
||||
>
|
||||
<title id="toggle-navbar-title">{{ $t('general.close') }}</title>
|
||||
<title id="toggle-navbar-title">{{ t('general.close') }}</title>
|
||||
<path class="line top" d="M 30,33 H 70" />
|
||||
<path class="line middle" d="M 30,50 H 70" />
|
||||
<path class="line bottom" d="M 30,67 H 70" />
|
||||
@@ -56,8 +56,9 @@ export function actionIconClass(icon: string, customIconClass?: string): string
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getters, mutations } from "@/store";
|
||||
import type { Prompt } from "@/store/types";
|
||||
import { getters, mutations } from "@/store/index.ts";
|
||||
import type { Prompt } from "@/store/types.ts";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -82,6 +83,8 @@ const emit = defineEmits<{
|
||||
action: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const multistate = computed(() => getters.multibuttonState());
|
||||
const iconClass = computed(() => actionIconClass(props.icon, props.customIconClass));
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"button-clicked": (_value) => true,
|
||||
"remove-button-clicked": (_value) => true,
|
||||
disableAll: () => true,
|
||||
enableAll: () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeButton: null, // Initially no button is active
|
||||
@@ -77,12 +83,11 @@ export default {
|
||||
this.$emit("remove-button-clicked", value);
|
||||
} else {
|
||||
// Emit remove-button-clicked for all other indexes
|
||||
this.buttons.forEach((button, idx) => {
|
||||
for (const [idx, button] of this.buttons.entries()) {
|
||||
if (idx !== index) {
|
||||
this.$emit("remove-button-clicked", button.value);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
this.activeButton = index;
|
||||
this.$emit("button-clicked", value);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,10 @@ vi.mock("@/store", () => ({
|
||||
isSplitViewActive: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/i18n/index.ts", () => ({
|
||||
default: { global: { t: (key: string) => key } },
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<p v-if="newFileTemplates.length === 0 && isLoggedIn" class="context-menu-empty-message">
|
||||
{{ $t('prompts.noTemplate') }}
|
||||
{{ t('prompts.noTemplate') }}
|
||||
</p>
|
||||
<button
|
||||
v-for="(item, index) in newFileTemplateItems"
|
||||
@@ -39,12 +39,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="action no-select"
|
||||
:aria-label="$t('files.emptyFile')"
|
||||
:title="$t('files.emptyFile')"
|
||||
:aria-label="t('files.emptyFile')"
|
||||
:title="t('files.emptyFile')"
|
||||
@click="createEmptyFile"
|
||||
>
|
||||
<i :class="actionIconClass('insert_drive_file')">insert_drive_file</i>
|
||||
<span>{{ $t('files.emptyFile') }}</span>
|
||||
<span>{{ t('files.emptyFile') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
@mouseleave="hideInteractiveTooltip()"
|
||||
@mouseenter="showHoverTooltip($t('buttons.selectedCount'), $event)"
|
||||
@mouseenter="showHoverTooltip(t('buttons.selectedCount'), $event)"
|
||||
class="button selected-count-header"
|
||||
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
|
||||
>
|
||||
@@ -112,21 +112,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { useRouter } from "vue-router";
|
||||
import { resourcesApi, shareApi, usersApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store/index.js";
|
||||
import { resourcesApi, shareApi, usersApi } from "@/api/index.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import type { FileListItem, ReqObject } from "@/store/types.ts";
|
||||
import { hideInteractiveTooltip, showHoverTooltip } from "@/utils/tooltipHelp.js";
|
||||
import { goToItem, getParentDir, removeLastDir } from "@/utils/url.js";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import downloadFiles from "@/utils/download";
|
||||
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare";
|
||||
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype";
|
||||
import { isMediaRequest } from "@/utils/mediaFile";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import downloadFiles from "@/utils/download.js";
|
||||
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare.js";
|
||||
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype.js";
|
||||
import { isMediaRequest } from "@/utils/mediaFile.js";
|
||||
import { actionIconClass } from "@/components/Action.vue";
|
||||
import { popEnter, popLeave } from "@/utils/transitions";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
|
||||
type MenuItem = FileListItem & { isDir?: boolean; from?: string };
|
||||
|
||||
@@ -495,8 +495,12 @@ 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: firstSelected.value } });
|
||||
mutations.showPrompt({ name: "info", props: { item } });
|
||||
}
|
||||
|
||||
function navToItem() {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
createLightningStrike,
|
||||
prefersReducedMotion,
|
||||
type LightningStrike,
|
||||
} from "@/utils/lightning-bolt";
|
||||
} from "@/utils/lightning-bolt.ts";
|
||||
|
||||
type Bolt = LightningStrike & { id: number };
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
export default {
|
||||
name: "load-failed",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@keydown.esc.prevent="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
<div ref="scroll" class="menu-option-list-scroll" :style="scrollStyle">
|
||||
<div class="menu-option-list-scroll" :style="scrollStyle">
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="optionKey(option.value)"
|
||||
@@ -24,7 +24,7 @@
|
||||
}"
|
||||
:aria-selected="isSelected(option.value) ? 'true' : 'false'"
|
||||
:disabled="option.disabled"
|
||||
@click="$emit('select', option)"
|
||||
@click="$emit('option-select', option)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
@@ -69,9 +69,11 @@ export default {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["select", "update:searchQuery", "close"],
|
||||
|
||||
emits: {
|
||||
"option-select": (_option) => true,
|
||||
"update:searchQuery": (_query) => true,
|
||||
close: () => true,
|
||||
},
|
||||
computed: {
|
||||
resolvedSearchPlaceholder() {
|
||||
return this.searchPlaceholder || this.$t("general.search");
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
export default {
|
||||
name: "notifications",
|
||||
|
||||
@@ -47,12 +47,15 @@ export default {
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: [Number, String],
|
||||
default: 100,
|
||||
},
|
||||
"val-background": {
|
||||
valBackground: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
"val-text": {
|
||||
valText: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
unit: {
|
||||
@@ -60,23 +63,24 @@ export default {
|
||||
default: "",
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
// either a number (pixel width/height) or 'tiny', 'small',
|
||||
// 'medium', 'large', 'huge', 'massive' for common sizes
|
||||
default: "big",
|
||||
},
|
||||
"bg-color": {
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: "#eee",
|
||||
},
|
||||
"bar-color": {
|
||||
barColor: {
|
||||
type: String,
|
||||
default: "var(--primaryColor)", // match .blue color to Material Design's 'Blue 500' color
|
||||
},
|
||||
"bar-transition": {
|
||||
barTransition: {
|
||||
type: String,
|
||||
default: "all 0.5s ease",
|
||||
},
|
||||
"bar-border-radius": {
|
||||
barBorderRadius: {
|
||||
type: Number,
|
||||
default: 8, // ~0.5em at default font size
|
||||
},
|
||||
@@ -88,19 +92,19 @@ export default {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
"text-align": {
|
||||
textAlign: {
|
||||
type: String,
|
||||
default: "center", // 'left', 'right'
|
||||
},
|
||||
"text-position": {
|
||||
textPosition: {
|
||||
type: String,
|
||||
default: "inside", // 'bottom', 'top', 'middle', 'inside'
|
||||
},
|
||||
"font-size": {
|
||||
fontSize: {
|
||||
type: Number,
|
||||
default: 13,
|
||||
},
|
||||
"text-fg-color": {
|
||||
textFgColor: {
|
||||
type: String,
|
||||
default: "#000",
|
||||
},
|
||||
@@ -108,7 +112,7 @@ export default {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
"help-text": {
|
||||
helpText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
@@ -133,7 +137,7 @@ export default {
|
||||
displayed_text() {
|
||||
// Use valText if provided (for hybrid mode text override), otherwise use val
|
||||
const displayVal = this.valText !== null ? this.valText : this.val;
|
||||
|
||||
|
||||
if (!isNumber(displayVal)) return displayVal;
|
||||
|
||||
// Cap % like the bar fill so nested-mount indexed size > partition total
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<!-- Search input section -->
|
||||
<div class="search-input-container">
|
||||
<!-- Close button visible when search is active -->
|
||||
<button v-if="active" type="button" class="action" @click="close" :aria-label="$t('general.close')"
|
||||
:title="$t('general.close')">
|
||||
<button v-if="active" type="button" class="action" @click="close" :aria-label="t('general.close')"
|
||||
:title="t('general.close')">
|
||||
<i class="material-symbols">close</i>
|
||||
</button>
|
||||
<!-- Search icon when search is not active -->
|
||||
@@ -12,13 +12,13 @@
|
||||
<!-- Input field for search -->
|
||||
<input id="search-input" type="text"
|
||||
@keyup.exact="keyup" @input="onInput" ref="input" :autofocus="active" v-model.trim="value"
|
||||
aria-label="search input" :placeholder="$t('general.search', { suffix: '...' })" />
|
||||
aria-label="search input" :placeholder="t('general.search', { suffix: '...' })" />
|
||||
<div v-if="eventTheme === 'halloween'" class="halloween-eyes" aria-hidden="true">
|
||||
<div class="eye"><div class="pupil"></div></div>
|
||||
<div class="eye right"><div class="pupil"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="active" id="results" ref="result">
|
||||
<div v-show="active" id="results">
|
||||
<div class="inputWrapper">
|
||||
<ExpandDropdown
|
||||
v-if="multipleSources"
|
||||
@@ -27,13 +27,13 @@
|
||||
transparent
|
||||
:options="searchSourceOptions"
|
||||
all-value="__all__"
|
||||
:all-selected-label="$t('general.all')"
|
||||
:all-selected-label="t('general.all')"
|
||||
aria-label="search sources dropdown"
|
||||
@update:model-value="updateSource"
|
||||
/>
|
||||
|
||||
<!-- Formatted display of selected value -->
|
||||
<div class="searchContext">{{ $t("search.searchContext", { context: getContext }) }}</div>
|
||||
<div class="searchContext">{{ t("search.searchContext", { context: getContext }) }}</div>
|
||||
</div>
|
||||
|
||||
<div id="result-list" ref="resultListEl">
|
||||
@@ -48,8 +48,8 @@
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="showPreviewImages"
|
||||
:name="$t('search.showPreviewImages')"
|
||||
:description="$t('search.showPreviewImagesDescription')"
|
||||
:name="t('search.showPreviewImages')"
|
||||
:description="t('search.showPreviewImagesDescription')"
|
||||
/>
|
||||
</template>
|
||||
</SearchFilters>
|
||||
@@ -90,7 +90,6 @@
|
||||
:thumbnail-url="showPreviewImages ? getThumbnailUrl(s) : ''"
|
||||
/>
|
||||
<span class="text-container">
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ basePath(s.path, s.type === "directory") }}{{ baseName(s.path) }}
|
||||
</span>
|
||||
<div class="filesize">{{ humanSize(s.size) }}</div>
|
||||
@@ -105,7 +104,7 @@
|
||||
class="button open-advanced-search-button"
|
||||
@click.stop.prevent="openInAdvancedSearch"
|
||||
>
|
||||
{{ $t("search.openInAdvancedSearch") }}
|
||||
{{ t("search.openInAdvancedSearch") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,26 +112,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useListingContextMenu } from "@/composables/useListingContextMenu";
|
||||
import { useSearchFilters } from "@/composables/useSearchFilters";
|
||||
import { useSearchRequest } from "@/composables/useSearchRequest";
|
||||
import { resourcesApi, toolsApi } from "@/api";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, useTemplateRef, watch, type Ref } from "vue";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { useListingContextMenu } from "@/composables/useListingContextMenu.ts";
|
||||
import { useSearchFilters } from "@/composables/useSearchFilters.ts";
|
||||
import { useSearchRequest } from "@/composables/useSearchRequest.ts";
|
||||
import { resourcesApi, toolsApi } from "@/api/index.js";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import SearchFilters from "@/components/tools/SearchFilters.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { router } from "@/router";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import type { FileListItem } from "@/store/types";
|
||||
import { url } from "@/utils/";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { router } from "@/router/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import type { FileListItem } from "@/store/types.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { globalVars } from "@/utils/constants.ts";
|
||||
import { debounce } from "@/utils/debounce.js";
|
||||
import { sortedItems } from "@/utils/sort.ts";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js";
|
||||
import { shouldIgnoreOutsideTap } from "@/utils/tooltipHelp.js";
|
||||
|
||||
@@ -150,8 +149,8 @@ interface SearchResult {
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const resultListEl = ref<HTMLElement | null>(null);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const resultListEl = useTemplateRef<HTMLElement>("resultListEl");
|
||||
const input = useTemplateRef<HTMLInputElement>("input");
|
||||
const noneMessage = ref(t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }));
|
||||
const showPreviewImages = ref(false);
|
||||
const advancedOptionsExpanded = ref(false);
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
import Breadcrumbs from "@/components/files/Breadcrumbs.vue";
|
||||
import ListingHeader from "@/components/files/ListingHeader.vue";
|
||||
import DuplicateFinderActions from "@/components/tools/DuplicateFinderActions.vue";
|
||||
import { getters, state } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { getters, state } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
|
||||
export default {
|
||||
name: "Shelf",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
max="24"
|
||||
step="1"
|
||||
/>
|
||||
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<!-- i18n-ignore -->
|
||||
<span class="size-label">{{ editorFontSize }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,8 +45,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
|
||||
export default {
|
||||
name: "StatusBar",
|
||||
|
||||
@@ -12,14 +12,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getIconClass } from "@/utils/material-symbols";
|
||||
import { getIconClass } from "@/utils/material-symbols.js";
|
||||
|
||||
export default {
|
||||
name: "Toast",
|
||||
props: {
|
||||
toasts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { state } from "@/store";
|
||||
import { state } from "@/store/index.ts";
|
||||
import { hideInteractiveTooltip } from "@/utils/tooltipHelp.js";
|
||||
|
||||
const margin = 15;
|
||||
const adjustedX = ref(0);
|
||||
const adjustedY = ref(0);
|
||||
const tooltipRef = ref<HTMLElement | null>(null);
|
||||
const tooltipRef = useTemplateRef<HTMLElement>("tooltipRef");
|
||||
const route = useRoute();
|
||||
|
||||
const tooltip = computed(() => state.tooltip);
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
<template>
|
||||
<div class="audio-side-panel">
|
||||
<div class="panel-tabs">
|
||||
<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>
|
||||
<SlidingTabs v-model="activeTab" :tabs="panelTabs" />
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
@@ -37,7 +18,7 @@
|
||||
position-mode="absolute"
|
||||
:edge-offset="{ top: '0.5em', right: '0.5em' }"
|
||||
:auto-hide="false"
|
||||
@click="lyricsScrollLocked = !lyricsScrollLocked"
|
||||
@press="lyricsScrollLocked = !lyricsScrollLocked"
|
||||
:label="lyricsLockToggleLabel"
|
||||
/>
|
||||
<!-- Scrollable area -->
|
||||
@@ -80,7 +61,7 @@
|
||||
position-mode="absolute"
|
||||
:edge-offset="{ top: '0.5em', right: '0.5em' }"
|
||||
:auto-hide="false"
|
||||
@click="showVisualizerSettings"
|
||||
@press="showVisualizerSettings"
|
||||
:label="visualizerSettingsLabel"
|
||||
/>
|
||||
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
|
||||
@@ -90,9 +71,10 @@
|
||||
</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";
|
||||
import { mutations, state } from "@/store/index.ts";
|
||||
import { visualizerConfig } from "@/utils/visualizerConfig.js";
|
||||
|
||||
const LAST_TAB_KEY = 'plyrSidePanelActiveTab';
|
||||
@@ -119,7 +101,7 @@ const FREQ_LABELS = [
|
||||
|
||||
export default {
|
||||
name: "AudioPanel",
|
||||
components: { PlaybackQueue, FloatingActionButton },
|
||||
components: { SlidingTabs, PlaybackQueue, FloatingActionButton },
|
||||
props: {
|
||||
lyrics: { type: Array, default: () => [] },
|
||||
lyricsMeta: { type: String, default: '' },
|
||||
@@ -129,7 +111,10 @@ export default {
|
||||
audioContext: { type: Object, default: null },
|
||||
audioSource: { type: Object, default: null }, // MediaElementAudioSourceNode, see https://developer.mozilla.org/en-US/docs/Web/API/MediaElementAudioSourceNode
|
||||
},
|
||||
emits: ["seek", "needs-audio-graph"],
|
||||
emits: {
|
||||
seek: (timestamp) => typeof timestamp === "number",
|
||||
"needs-audio-graph": () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeTab: (() => {
|
||||
@@ -137,6 +122,7 @@ export default {
|
||||
return stored && ['queue', 'lyrics', 'visualizer'].includes(stored) ? stored : 'queue';
|
||||
})(),
|
||||
lyricsScrollLocked: false,
|
||||
resizeObserver: null,
|
||||
// Visualizer state
|
||||
visualizerAnalyserLeft: null,
|
||||
visualizerAnalyserRight: null,
|
||||
@@ -183,12 +169,18 @@ export default {
|
||||
visualizerSettingsLabel() {
|
||||
return this.$t('player.visualizer.settings');
|
||||
},
|
||||
// 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}%` };
|
||||
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') },
|
||||
];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -256,19 +248,8 @@ export default {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
});
|
||||
this.windowResizeHandler = () => {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
};
|
||||
window.addEventListener('resize', this.windowResizeHandler);
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (this.activeTab !== 'visualizer') return;
|
||||
if (document.hidden) {
|
||||
this.stopVisualizer();
|
||||
} else {
|
||||
this.startVisualizer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
|
||||
window.addEventListener('resize', this.onWindowResize);
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
await this.$nextTick();
|
||||
const container = this.$el?.querySelector('.tab-visualizer');
|
||||
if (container) this.resizeObserver.observe(container);
|
||||
@@ -276,12 +257,23 @@ export default {
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
this.resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', this.windowResizeHandler);
|
||||
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
|
||||
window.removeEventListener('resize', this.onWindowResize);
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.stopVisualizer();
|
||||
this.fullCleanup();
|
||||
},
|
||||
methods: {
|
||||
onWindowResize() {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
},
|
||||
onVisibilityChange() {
|
||||
if (this.activeTab !== 'visualizer') return;
|
||||
if (document.hidden) {
|
||||
this.stopVisualizer();
|
||||
} else {
|
||||
this.startVisualizer();
|
||||
}
|
||||
},
|
||||
scrollToActiveLine() {
|
||||
if (this.lyricsScrollLocked) return;
|
||||
const el = this.$refs.lyricsScrollable;
|
||||
@@ -788,62 +780,6 @@ 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;
|
||||
@@ -974,11 +910,6 @@ export default {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.active) {
|
||||
color: var(--primaryColor);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.tab-visualizer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { resourcesApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { url } from "@/utils";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
|
||||
export default {
|
||||
name: "breadcrumbs",
|
||||
@@ -48,7 +48,12 @@ export default {
|
||||
dragOverItem: null, // Track which breadcrumb is being dragged over
|
||||
};
|
||||
},
|
||||
props: ["noLink"],
|
||||
props: {
|
||||
noLink: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updatePaths();
|
||||
window.addEventListener('dragend', this.clearDragState);
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
ref="extraMenuTrigger"
|
||||
type="button"
|
||||
class="editor-toolbar-btn"
|
||||
:title="$t('editor.md.moreOptions')"
|
||||
:aria-label="$t('editor.md.moreOptions')"
|
||||
:title="t('editor.md.moreOptions')"
|
||||
:aria-label="t('editor.md.moreOptions')"
|
||||
@mousedown.prevent
|
||||
@click="toggleMenu('extra')"
|
||||
>
|
||||
@@ -134,16 +134,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, useTemplateRef, watch } from "vue";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import ace from "ace-builds";
|
||||
import type { Ace } from "ace-builds";
|
||||
import { mutations, state, getters } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { mutations, state, getters } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { removeLastDir } from "@/utils/url.js";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
interface AnchorRange {
|
||||
start: Ace.Anchor;
|
||||
@@ -221,8 +221,8 @@ const lastColors = reactive(new Map<string, string>([
|
||||
const colorInputRefs = new Map<string, HTMLInputElement>();
|
||||
const iconMenuTriggerEls = new Map<"align" | "clipboard", HTMLElement>();
|
||||
const iconMenuEls = new Map<"align" | "clipboard", HTMLElement>();
|
||||
const extraMenuTrigger = ref<HTMLElement | null>(null);
|
||||
const extraMenu = ref<HTMLElement | null>(null);
|
||||
const extraMenuTrigger = useTemplateRef<HTMLElement>("extraMenuTrigger");
|
||||
const extraMenu = useTemplateRef<HTMLElement>("extraMenu");
|
||||
|
||||
const toolbarButtons = computed((): ToolbarButton[] => {
|
||||
// alwaysAvailable are for buttons that will be available for all filetypes, not just markdown.
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
v-if="thumbnailSrc && !fullImageLoaded && !isTiff"
|
||||
:src="thumbnailSrc"
|
||||
class="image-ex-img"
|
||||
ref="thumbnail"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -38,13 +37,13 @@
|
||||
|
||||
<script>
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from '@/store';
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { acquireBestCachedThumbnailBlob, getBestCachedImage, releaseThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype.js";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
|
||||
import throttle from "@/utils/throttle";
|
||||
import throttle from "@/utils/throttle.js";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -56,8 +55,14 @@ export default {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"navigate-previous": () => true,
|
||||
"navigate-next": () => true,
|
||||
"close-preview": () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
thumbBlobKey: null, // only read in lifecycle/sync code, never during render
|
||||
moveDisabledTime: 200,
|
||||
classList: [],
|
||||
zoomStep: 0.25,
|
||||
@@ -122,7 +127,40 @@ export default {
|
||||
isLoaded() {
|
||||
return !("preview-img" in (state?.loading || {}));
|
||||
},
|
||||
// Which cached entry to look, or null when no thumbnail should be shown
|
||||
isHeicOrHeif() {
|
||||
const type = state.req?.type;
|
||||
return type === "image/heic" || type === "image/heif";
|
||||
},
|
||||
/** Full-resolution source for the viewer (Safari HEIC/HEIF uses the native file, not size=original). */
|
||||
fullImageSrc() {
|
||||
if (!this.isHeicOrHeif || !state.isSafari || !state.req) {
|
||||
return this.src;
|
||||
}
|
||||
const viewToken = state.req.viewToken;
|
||||
const typeHint = state.req.type || state.req.name;
|
||||
if (getters.isShare()) {
|
||||
return resourcesApi.getViewURL(
|
||||
state.req.source,
|
||||
state.req.path,
|
||||
viewToken,
|
||||
{
|
||||
path: state.shareInfo.subPath,
|
||||
hash: state.shareInfo.hash,
|
||||
},
|
||||
false,
|
||||
typeHint,
|
||||
);
|
||||
}
|
||||
return resourcesApi.getViewURL(
|
||||
state.req.source,
|
||||
state.req.path,
|
||||
viewToken,
|
||||
null,
|
||||
false,
|
||||
typeHint,
|
||||
);
|
||||
},
|
||||
// Cached preview thumbs (from listing's preview API) shown while the full image loads.
|
||||
thumbnailLookup() {
|
||||
if (!state?.req) {
|
||||
return null;
|
||||
@@ -130,11 +168,9 @@ export default {
|
||||
if (!this.path || !state.req.hasPreview) {
|
||||
return null;
|
||||
}
|
||||
// Don't use thumbnail when we show original embedded preview (HEIC/HEIF or raw)
|
||||
const isHeicOrHeif = state.req?.type === "image/heic" || state.req?.type === "image/heif";
|
||||
const useOriginalForHeic = isHeicOrHeif && (state.isSafari || (globalVars.ffmpegAvailable && globalVars.enableHeicConversion));
|
||||
const useOriginalForRaw = isRawImageMimeType(state.req?.type);
|
||||
if (useOriginalForHeic || useOriginalForRaw) {
|
||||
const skipHeicThumbnail = this.isHeicOrHeif && !state.isSafari && state.req.hasPreview;
|
||||
const skipRawThumbnail = isRawImageMimeType(state.req?.type);
|
||||
if (skipHeicThumbnail || skipRawThumbnail) {
|
||||
return null;
|
||||
}
|
||||
// For shares, use shareInfo.hash as the source; otherwise use this.source
|
||||
@@ -182,9 +218,6 @@ export default {
|
||||
return this.edgeDy >= this.edgeCommitY && ay >= ax;
|
||||
},
|
||||
},
|
||||
beforeCreate() {
|
||||
this.thumbBlobKey = null;
|
||||
},
|
||||
created() {
|
||||
this.syncThumbnailBlob();
|
||||
},
|
||||
@@ -299,7 +332,8 @@ export default {
|
||||
}
|
||||
},
|
||||
async loadFullImage() {
|
||||
if (!this.src) return;
|
||||
const fullSrc = this.fullImageSrc;
|
||||
if (!fullSrc) return;
|
||||
mutations.setLoading("preview-img", true);
|
||||
|
||||
// Set src directly via JavaScript to avoid Vue's HTML entity encoding in template bindings
|
||||
@@ -307,7 +341,7 @@ export default {
|
||||
await this.$nextTick();
|
||||
if (this.$refs.imgex && 'src' in this.$refs.imgex) {
|
||||
// Decode any HTML entities (Vue shouldn't encode props, but decode just in case)
|
||||
const cleanSrc = String(this.src).replace(/&/g, '&');
|
||||
const cleanSrc = String(fullSrc).replace(/&/g, '&');
|
||||
this.$refs.imgex.src = cleanSrc;
|
||||
}
|
||||
},
|
||||
@@ -493,7 +527,7 @@ export default {
|
||||
this.showDismissHint = false;
|
||||
} else {
|
||||
this.dragOffsetX = 0;
|
||||
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
|
||||
const downward = Math.max(this.edgeDy, 0);
|
||||
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
|
||||
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
|
||||
this.showNavHint = false;
|
||||
@@ -517,7 +551,7 @@ export default {
|
||||
this.showDismissHint = false;
|
||||
} else {
|
||||
this.dragOffsetX = 0;
|
||||
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
|
||||
const downward = Math.max(this.edgeDy, 0);
|
||||
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
|
||||
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
|
||||
this.showNavHint = false;
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<!-- Source Selection Dropdown (hidden when browsing a fixed source, e.g. user scope row) -->
|
||||
<div v-if="!hidePathChrome && showSourceSelector" class="source-selector" style="margin-bottom: 1rem;">
|
||||
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
{{ $t("prompts.destinationSource") }}
|
||||
{{ t("prompts.destinationSource") }}
|
||||
</label>
|
||||
<ExpandDropdown
|
||||
input-id="destinationSource"
|
||||
v-model="currentSource"
|
||||
:options="sourceOptions"
|
||||
:aria-label="$t('prompts.destinationSource')"
|
||||
:aria-label="t('prompts.destinationSource')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sortable Column Header (opt-in via sortable prop, e.g. destination pickers) -->
|
||||
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.pinned" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
|
||||
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.sticky" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
|
||||
<!-- Current Path Display -->
|
||||
<div v-if="!hidePathChrome && !fileList" aria-label="filelist-path" class="searchContext button clickable">
|
||||
{{ $t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
|
||||
{{ t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
|
||||
</div>
|
||||
<slot name="sticky" />
|
||||
<ListingHeader v-if="sortable && !loading" :use-picker-sorting="!fileList" :use-navigation-sorting="!!fileList" />
|
||||
@@ -68,26 +68,27 @@
|
||||
</div>
|
||||
<h2 v-if="filterQuery && !visibleItems.some((item) => item.name !== '..')" class="no-results">
|
||||
<i class="material-symbols-outlined">search_off</i>
|
||||
<span>{{ $t("tools.advancedSearch.noResults") }}</span>
|
||||
<span>{{ t("tools.advancedSearch.noResults") }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue";
|
||||
import { state, mutations, getters } from "@/store/index.ts";
|
||||
import type { FileListItem, MediaMetadata } from "@/store/types.ts";
|
||||
import { url } from "@/utils";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import ListingHeader from "@/components/files/ListingHeader.vue";
|
||||
import ListingItem from "@/components/files/ListingItem.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import { sortedItems } from "@/utils/sort.ts";
|
||||
import { findScrollParents, findScroller, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
|
||||
type ItemInfo = Omit<FileListItem, "metadata"> & { metadata?: MediaMetadata; isDir?: boolean; hasDuration?: boolean };
|
||||
type ItemInfo = FileListItem & { isDir?: boolean; hasDuration?: boolean };
|
||||
|
||||
export interface ListEntry {
|
||||
name: string;
|
||||
@@ -98,7 +99,7 @@ export interface ListEntry {
|
||||
size?: number;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
metadata?: unknown;
|
||||
metadata?: MediaMetadata;
|
||||
isDirectory?: boolean;
|
||||
originalItem?: ItemInfo;
|
||||
}
|
||||
@@ -161,8 +162,14 @@ const emit = defineEmits<{
|
||||
"update:filterQuery": [value: string];
|
||||
}>();
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
defineSlots<{
|
||||
sticky?(): unknown;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const root = useTemplateRef<HTMLElement>("root");
|
||||
const list = useTemplateRef<HTMLElement>("list");
|
||||
|
||||
const fallbackModified = new Date().toISOString();
|
||||
const initialSource = props.browseSource || state.req.source;
|
||||
@@ -330,7 +337,8 @@ onMounted(async () => {
|
||||
if (currentSource.value !== state.req.source || props.browsePath) {
|
||||
void withLoading(() => resourcesApi.fetchFiles(sourceToUse, pathToUse).then(fillOptions));
|
||||
} else {
|
||||
fillOptions(initialReq);
|
||||
const pending = fillOptions(initialReq);
|
||||
if (pending) void withLoading(() => pending);
|
||||
}
|
||||
}
|
||||
attachScrollListener();
|
||||
@@ -372,7 +380,7 @@ async function withLoading(operation: () => Promise<unknown>) {
|
||||
} finally {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, 200 - elapsed);
|
||||
await new Promise((resolve) => setTimeout(resolve, remaining));
|
||||
await new Promise((resolve) => { setTimeout(resolve, remaining); });
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -417,7 +425,22 @@ function resetToShare(newHash: string) {
|
||||
void withLoading(() => resourcesApi.fetchFilesPublic("/", newHash).then(fillOptions));
|
||||
}
|
||||
|
||||
function fillOptions(req: { source?: string; items?: FileListItem[] }) {
|
||||
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);
|
||||
}
|
||||
// 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;
|
||||
@@ -462,6 +485,16 @@ function fillOptions(req: { source?: 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[]) {
|
||||
@@ -625,8 +658,9 @@ function isContextItem(item: ListEntry) {
|
||||
}
|
||||
|
||||
function attachScrollListener() {
|
||||
const el = root.value?.closest<HTMLElement>(".floating-window > .card-content");
|
||||
if (!el) return;
|
||||
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;
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
scrollContainer = el;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="tree-error">
|
||||
<i class="material-symbols">error</i>
|
||||
<span>{{ $t("prompts.error") }}</span>
|
||||
<span>{{ t("prompts.error") }}</span>
|
||||
</div>
|
||||
<div v-if="rootNodes.length" ref="listEl" class="tree-list">
|
||||
<div data-virtual-group="rows" :style="virtual.group('rows').style">
|
||||
@@ -60,7 +60,7 @@
|
||||
</a>
|
||||
<div v-else-if="row.kind === 'empty'" class="tree-empty-folder">
|
||||
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
|
||||
<span>{{ $t('files.lonely') }}</span>
|
||||
<span>{{ t('files.lonely') }}</span>
|
||||
</div>
|
||||
<div v-else-if="row.kind === 'loading'" class="tree-loading">
|
||||
<LoadingSpinner size="small" mode="placeholder" />
|
||||
@@ -74,24 +74,24 @@
|
||||
<!-- Empty state -->
|
||||
<div v-if="rootNodes.length === 0 && !loading && !error" class="tree-empty">
|
||||
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
|
||||
{{ $t('files.lonely') }}
|
||||
{{ t('files.lonely') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { resourcesApi } from '@/api';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import Icon from '@/components/files/Icon.vue';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner.vue';
|
||||
import i18n from '@/i18n/index.ts';
|
||||
import { state, getters, mutations } from '@/store';
|
||||
import { eventBus } from '@/store/eventBus';
|
||||
import { state, getters, mutations } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import type { FileListItem } from '@/store/types.ts';
|
||||
import { buildItemUrl, goToItem, joinPath } from '@/utils/url';
|
||||
import { goToItemNotificationButton } from '@/utils/notificationActions';
|
||||
import { notify } from '@/notify';
|
||||
import { notifyMoveCopyFailure } from '@/utils/appNotifications';
|
||||
import { buildItemUrl, goToItem, joinPath } from "@/utils/url.js";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { sortedItems } from '@/utils/sort.ts';
|
||||
import { findScrollParents, useListingVirtualization } from '@/composables/useListingVirtualization.ts';
|
||||
@@ -162,8 +162,8 @@ const props = withDefaults(
|
||||
|
||||
const t = i18n.global.t.bind(i18n.global);
|
||||
|
||||
const treeEl = ref<HTMLElement | null>(null);
|
||||
const listEl = ref<HTMLElement | null>(null);
|
||||
const treeEl = useTemplateRef<HTMLElement>("treeEl");
|
||||
const listEl = useTemplateRef<HTMLElement>("listEl");
|
||||
const rootNodes = ref<TreeNode[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
:fbdata="threeDFbdata"
|
||||
:is-thumbnail="true"
|
||||
:add-load-delay="true"
|
||||
@error="handle3DError" />
|
||||
@load-error="handle3DError" />
|
||||
<TextPreview v-else-if="shouldUseTextPreview"
|
||||
:url="textPreviewUrl"
|
||||
:language="textPreviewLanguage"
|
||||
:modified="modified"
|
||||
@error="handleTextPreviewError" />
|
||||
@load-error="handleTextPreviewError" />
|
||||
</span>
|
||||
<!-- Regular material icon (no preview) -->
|
||||
<span v-else ref="tileEl" class="image-preview" @mouseenter="hasTextPreview && handleMouseEnter()" @mouseleave="hasTextPreview && handleMouseLeave()">
|
||||
@@ -40,11 +40,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, watch, type Ref } from "vue";
|
||||
import { fetchPreviewImage } from "@/api/resources";
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, useTemplateRef, watch, type Ref } from "vue";
|
||||
import { fetchPreviewImage } from "@/api/resources.js";
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { getTypeInfo } from "@/utils/mimetype.js";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import {
|
||||
acquireThumbnailBlob,
|
||||
getThumbnailBlobKey,
|
||||
@@ -234,7 +234,7 @@ const textPreviewLanguage = computed(() => languageFromPath(props.filename));
|
||||
|
||||
// Measured tile width; only observed for text-previewable files so image tiles pay nothing.
|
||||
const tileWidth = ref(0);
|
||||
const tileEl = ref<HTMLElement | null>(null);
|
||||
const tileEl = useTemplateRef<HTMLElement>("tileEl");
|
||||
let tileResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function observeTileWidth(el: HTMLElement | null): void {
|
||||
@@ -319,7 +319,7 @@ function clearPopupHoverTimeout(): void {
|
||||
|
||||
function cancelPreloadRequests(): void {
|
||||
clearPopupHoverTimeout();
|
||||
previewTimeouts.forEach(clearTimeout);
|
||||
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
|
||||
previewTimeouts = [];
|
||||
for (const controller of preloadAbortControllers) {
|
||||
controller.abort();
|
||||
@@ -483,7 +483,7 @@ function showPopupPreview(): void {
|
||||
|
||||
const updateThumbnail = () => {
|
||||
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== props.path) {
|
||||
previewTimeouts.forEach(clearTimeout);
|
||||
for (const timeoutId of previewTimeouts) clearTimeout(timeoutId);
|
||||
previewTimeouts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state } from "@/store";
|
||||
import { fromNow } from "@/utils/moment";
|
||||
import { state } from "@/store/index.ts";
|
||||
import { fromNow } from "@/utils/moment.ts";
|
||||
import SettingsTable from "@/components/settings/Table.vue";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
:aria-label="open ? $t('general.cancel') : $t('general.search')"
|
||||
:title="open ? $t('general.cancel') : $t('general.search')"
|
||||
>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<i class="material-symbols">{{ open ? "close" : "search" }}</i>
|
||||
</button>
|
||||
<input
|
||||
@@ -37,7 +36,9 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
emits: {
|
||||
"update:modelValue": (value) => typeof value === "string",
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { state, getters, mutations } from "@/store/index.ts";
|
||||
|
||||
export default {
|
||||
name: "ListingHeader",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
class="material-symbols overlay-icon overlay-icon--bottom clickable"
|
||||
aria-label="Download"
|
||||
@click.stop.prevent="downloadFile"
|
||||
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
>file_download</button>
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
@@ -93,23 +93,23 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, shallowRef } from "vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import downloadFiles from "@/utils/download";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getKindKey } from "@/utils/mimetype";
|
||||
import { isTextPreviewableType } from "@/utils/textPreview";
|
||||
import { computed, provide, shallowRef } from "vue";
|
||||
import { globalVars } from "@/utils/constants.ts";
|
||||
import downloadFiles from "@/utils/download.js";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
import { getKindKey } from "@/utils/mimetype.js";
|
||||
import { isTextPreviewableType } from "@/utils/textPreview.ts";
|
||||
import { hasCachedThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { formatDuration } from "@/utils/files.js";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { resourcesApi } from "@/api";
|
||||
import { checkConflict } from "@/utils/upload";
|
||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||
import { url } from "@/utils";
|
||||
import { formatTimestamp, fromNow } from "@/utils/moment";
|
||||
import { notify } from "@/notify";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { checkConflict } from "@/utils/upload.ts";
|
||||
import { state, getters, mutations } from "@/store/index.ts"; // Import your custom store
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { formatTimestamp, fromNow } from "@/utils/moment.ts";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
|
||||
const selectedIndexSet = computed(() => {
|
||||
@@ -204,25 +204,29 @@ export default {
|
||||
localSelected: false,
|
||||
};
|
||||
},
|
||||
beforeCreate() {
|
||||
// runs before provide(), non-reactive holder on purpose
|
||||
this.viewportRef = shallowRef(false);
|
||||
emits: {
|
||||
"item-select": (payload) => payload != null && "index" in payload,
|
||||
"select-range": (payload) => payload != null && "startIndex" in payload && "endIndex" in payload,
|
||||
},
|
||||
provide() {
|
||||
// the Icons inside this item inject it, see Icon.vue
|
||||
return { inView: this.viewportRef };
|
||||
},
|
||||
created() {
|
||||
// non-reactive on purpose, they're never read by the template
|
||||
this.touches = 0;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.isLongPress = false;
|
||||
this.isSwipe = false;
|
||||
this.contextTimeout = null;
|
||||
this.observedEl = null;
|
||||
this.observer = null;
|
||||
this.isUnmounted = false;
|
||||
setup() {
|
||||
// The Icons inside this item inject it with Icon.vue
|
||||
// we're using setup for now temporarily as is more easy than convert the whole component to script setup.
|
||||
const viewportRef = shallowRef(false);
|
||||
provide("inView", viewportRef);
|
||||
return {
|
||||
setInView(visible) {
|
||||
viewportRef.value = visible;
|
||||
},
|
||||
touches: 0,
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
isLongPress: false,
|
||||
isSwipe: false,
|
||||
contextTimeout: null,
|
||||
observedEl: null,
|
||||
observer: null,
|
||||
isUnmounted: false,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
name: String,
|
||||
@@ -491,7 +495,7 @@ export default {
|
||||
if (this.$el && this.$el instanceof Element) {
|
||||
this.observedEl = this.$el;
|
||||
viewportCallbacks.set(this.$el, (visible) => {
|
||||
this.viewportRef.value = visible;
|
||||
this.setInView(visible);
|
||||
if (visible) {
|
||||
this.isThumbnailInView = true;
|
||||
}
|
||||
@@ -516,7 +520,7 @@ export default {
|
||||
downloadFiles(state.selected);
|
||||
} else {
|
||||
// Emit selection event for local handling
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
this.$emit('item-select', { index: this.index, selected: true });
|
||||
}
|
||||
},
|
||||
/** @param {TouchEvent} event */
|
||||
@@ -614,7 +618,7 @@ export default {
|
||||
} else {
|
||||
// Emit selection event for local handling
|
||||
if (!this.localSelected) {
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
this.$emit('item-select', { index: this.index, selected: true });
|
||||
}
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
@@ -687,11 +691,7 @@ export default {
|
||||
const destinationDir = this.path;
|
||||
|
||||
// If destination dir is the same as or contains the source path, skip
|
||||
if (fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return !(fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`));
|
||||
});
|
||||
|
||||
// If all items were filtered out, silently skip the operation
|
||||
@@ -805,7 +805,7 @@ export default {
|
||||
mutations.resetSelected();
|
||||
mutations.addSelected(this.index);
|
||||
} else {
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
this.$emit('item-select', { index: this.index, selected: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -899,7 +899,7 @@ export default {
|
||||
// Local selection handling - emit events instead of updating global state
|
||||
if (event.shiftKey) {
|
||||
// Shift-click: select range (emit event for parent to handle)
|
||||
this.$emit('selectRange', {
|
||||
this.$emit('select-range', {
|
||||
startIndex: state.lastSelectedIndex !== null ? state.lastSelectedIndex : this.index,
|
||||
endIndex: this.index
|
||||
});
|
||||
@@ -908,7 +908,7 @@ export default {
|
||||
|
||||
// Always toggle - parent component will handle the toggle logic
|
||||
// Just emit the index, parent will check current state and toggle
|
||||
this.$emit('select', { index: this.index });
|
||||
this.$emit('item-select', { index: this.index });
|
||||
}
|
||||
},
|
||||
open() {
|
||||
|
||||
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user