Compare commits

..
81 changed files with 1088 additions and 757 deletions

No files matched your search

+3 -1
View File
@@ -10,7 +10,9 @@ backend/tmp
backend/dist
backend/backend*
backend/vendor/**
backend/internal/web/dist/*
!backend/internal/web/dist/.gitkeep
frontend/dist/**
frontend/node_modules/**
frontend/pkg/**
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: dist
path: backend/internal/web/dist
path: frontend/dist
build_backend:
name: Build backend
runs-on: ubuntu-24.04-arm
@@ -97,7 +97,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: dist
path: backend/internal/web/dist
path: frontend/dist
- name: Download backend build
uses: actions/download-artifact@v4
with:
+1 -1
View File
@@ -39,7 +39,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
+1 -1
View File
@@ -34,7 +34,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+1 -1
View File
@@ -68,7 +68,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
RUN filebrowser version && ffmpeg -version && ffprobe -version
+1 -1
View File
@@ -12,7 +12,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# generate duplicate 1.1MB files
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
+1 -1
View File
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser user set testuser1 --password testuser1
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -7,7 +7,7 @@ WORKDIR /app/backend/
RUN mkdir -p ./internal/web/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
WORKDIR /app/frontend
+1 -1
View File
@@ -23,7 +23,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
ENV CI=true
+1 -1
View File
@@ -13,7 +13,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1 -1
View File
@@ -6,7 +6,7 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
+1 -1
View File
@@ -10,7 +10,7 @@ COPY [ "./backend/filebrowser", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+1 -1
View File
@@ -13,7 +13,7 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -11,7 +11,7 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/dist/", "./internal/web/dist/"]
COPY [ "./frontend/dist/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+1 -1
View File
@@ -33,7 +33,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+1 -1
View File
@@ -32,7 +32,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/frontend/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+1 -1
View File
@@ -21,7 +21,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.0
github.com/gtsteffaniak/go-media v1.2.1
github.com/kovidgoyal/imaging v1.9.0
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
+2
View File
@@ -167,6 +167,8 @@ github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcd
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-media v1.2.0 h1:lbNL/G86uzzH8NaT45DBHKm1r0P7yndIo9pNC7gdqQs=
github.com/gtsteffaniak/go-media v1.2.0/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
github.com/gtsteffaniak/go-media v1.2.1 h1:SjrPqgQTHnenAFuVh8fTRxk8+qXFEaCxiADx3zkM1OI=
github.com/gtsteffaniak/go-media v1.2.1/go.mod h1:EH+5KAVLWfFl0g4W0Xni0qJJH8MUDIhAw4wPcC0mhxY=
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=
-167
View File
@@ -1,167 +0,0 @@
// Package folio is the pure-Go document preview fast path using gen2brain/folio.
// When folio cannot handle a file, callers fall back to the office converter (when configured).
package folio
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
"path/filepath"
"strings"
"sync"
"github.com/gen2brain/folio/doc"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
var renderMu sync.Mutex
// RenderOptions controls thumbnail sizing for one rendered page.
type RenderOptions struct {
MaxWidth int
MaxHeight int
Fill bool
JpegQuality int
Original bool
}
// Enabled reports whether the folio fast path may run.
func Enabled() bool {
return !settings.AlwaysUseOnlyOfficeEnabled()
}
// SupportsExtension reports whether folio targets the file extension (including leading dot).
func SupportsExtension(ext string) bool {
ext = strings.ToLower(ext)
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return iteminfo.FolioConvertable[ext]
}
func logFallback(op, path string, err error) {
if errors.Is(err, doc.ErrUnsupported) {
logger.Warningf("folio does not support %s for %s (%v); using office converter if configured", op, path, err)
return
}
logger.Errorf("folio %s failed for %s: %v; falling back to office converter if configured", op, path, err)
}
// RenderPageJPEG renders one document page to JPEG. ok is true when folio returned bytes.
// name and mimetype should match the file being previewed; they control eligibility for
// folio (content sniffing does not depend on the path extension).
func RenderPageJPEG(ctx context.Context, path string, pageNumber int, opts RenderOptions, name, mimetype string) (data []byte, ok bool, err error) {
if !Enabled() {
return nil, false, nil
}
if name == "" {
name = filepath.Base(path)
}
if !SupportsPreview(name, mimetype) {
return nil, false, nil
}
if ctx.Err() != nil {
return nil, false, ctx.Err()
}
type result struct {
data []byte
err error
}
done := make(chan result, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- result{err: fmt.Errorf("folio render panicked: %v", r)}
}
}()
data, err := renderPageJPEG(ctx, path, name, mimetype, pageNumber, opts)
done <- result{data: data, err: err}
}()
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case res := <-done:
if res.err != nil {
if !errors.Is(res.err, context.Canceled) && !errors.Is(res.err, context.DeadlineExceeded) {
logFallback("render", path, res.err)
}
return nil, false, res.err
}
if len(res.data) == 0 {
return nil, false, fmt.Errorf("folio render returned empty jpeg")
}
return res.data, true, nil
}
}
func renderPageJPEG(ctx context.Context, path, name, mimetype string, pageNumber int, opts RenderOptions) ([]byte, error) {
renderMu.Lock()
defer renderMu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
d, err := openDocument(path, name, mimetype)
if err != nil {
return nil, err
}
defer d.Close()
if pageNumber < 0 || pageNumber >= d.NumPages() {
return nil, fmt.Errorf("invalid page number %d (pages: %d)", pageNumber, d.NumPages())
}
p, err := d.Page(pageNumber)
if err != nil {
return nil, err
}
var img image.Image
if opts.Original || opts.MaxWidth <= 0 || opts.MaxHeight <= 0 {
const maxEdge = 4096
rgba, err := p.ImageDPI(150)
if err != nil {
return nil, err
}
img = rgba
w, h := rgba.Bounds().Dx(), rgba.Bounds().Dy()
if w > maxEdge || h > maxEdge {
scale := min(float64(maxEdge)/float64(w), float64(maxEdge)/float64(h))
newW := max(1, int(float64(w)*scale))
newH := max(1, int(float64(h)*scale))
img = imaging.Resize(img, newW, newH, imaging.Lanczos)
}
} else {
rgba, err := p.ImageDPI(150)
if err != nil {
return nil, err
}
img = rgba
if opts.Fill {
img = imaging.Fill(img, opts.MaxWidth, opts.MaxHeight, imaging.Center, imaging.Lanczos)
} else {
b := img.Bounds()
if b.Dx() > opts.MaxWidth || b.Dy() > opts.MaxHeight {
img = imaging.Fit(img, opts.MaxWidth, opts.MaxHeight, imaging.Lanczos)
}
}
}
q := opts.JpegQuality
if q <= 0 {
q = 85
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+2 -2
View File
@@ -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
+20 -8
View File
@@ -5,16 +5,26 @@ import (
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
const (
// docPreviewTimeout bounds small, large and xlarge page renders.
docPreviewTimeout = 2 * time.Second
// docOriginalTimeout bounds "original" renders, which are higher resolution.
docOriginalTimeout = 10 * time.Second
)
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, pageNumber int, previewSize string) ([]byte, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
timeout := docPreviewTimeout
if previewSize == "original" {
timeout = docOriginalTimeout
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
opts, err := folioRenderOptions(previewSize)
@@ -22,7 +32,7 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, err
}
imageBytes, ok, err := folio.RenderPageJPEG(timeoutCtx, file.RealPath, pageNumber, opts, file.Name, file.Type)
imageBytes, ok, err := renderDocPageJPEG(timeoutCtx, file.RealPath, pageNumber, opts, file.Name, file.Type)
if !ok {
if err != nil {
return nil, err
@@ -30,20 +40,22 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, nil
}
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
return nil, fmt.Errorf("document preview generation timed out after %s for '%s'", timeout, file.Name)
}
return imageBytes, nil
}
func folioRenderOptions(previewSize string) (folio.RenderOptions, error) {
// folioRenderOptions maps a preview size onto the same dimensions, fill mode and JPEG quality
// the image pipeline uses (see getPreviewOptions); "original" renders at full resolution.
func folioRenderOptions(previewSize string) (docRenderOptions, error) {
if previewSize == "original" {
return folio.RenderOptions{Original: true, JpegQuality: QualityHigh.jpegQuality()}, nil
return docRenderOptions{Original: true, JpegQuality: QualityHigh.jpegQuality()}, nil
}
opts, err := getPreviewOptions(previewSize)
if err != nil {
return folio.RenderOptions{}, err
return docRenderOptions{}, err
}
return folio.RenderOptions{
return docRenderOptions{
MaxWidth: opts.Width,
MaxHeight: opts.Height,
Fill: opts.ResizeMode == ResizeModeFill,
@@ -5,7 +5,6 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/stretchr/testify/require"
)
@@ -17,7 +16,7 @@ func TestFolioRender_utf8TruncatedPlaywrightFile(t *testing.T) {
}
opts, err := folioRenderOptions("small")
require.NoError(t, err)
data, ok, err := folio.RenderPageJPEG(t.Context(), realPath, 0, opts, "utf8-truncated.txt", "text/plain")
data, ok, err := renderDocPageJPEG(t.Context(), realPath, 0, opts, "utf8-truncated.txt", "text/plain")
require.NoError(t, err)
require.True(t, ok, "err=%v", err)
require.NotEmpty(t, data)
@@ -29,7 +28,7 @@ func TestFolioRender_cssAsTxt(t *testing.T) {
require.NoError(t, os.WriteFile(cssPath, []byte("body { color: red; }\n"), 0o644))
opts, err := folioRenderOptions("small")
require.NoError(t, err)
data, ok, err := folio.RenderPageJPEG(t.Context(), cssPath, 0, opts, "styles.css", "text/css")
data, ok, err := renderDocPageJPEG(t.Context(), cssPath, 0, opts, "styles.css", "text/css")
require.NoError(t, err)
require.True(t, ok, "err=%v", err)
require.NotEmpty(t, data)
+208
View File
@@ -0,0 +1,208 @@
// folio.go is the pure-Go document preview fast path using gen2brain/folio.
// When folio cannot handle a file, callers fall back to the office converter (when configured).
package preview
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
"path/filepath"
"sync"
"github.com/gen2brain/folio/doc"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
const (
// docSupersample renders a page at this multiple of the target box before the
// final downscale. Rasterized text needs the extra pixels to stay crisp.
docSupersample = 2
// docOriginalDPI is the resolution used for "original" page previews.
docOriginalDPI = 200
// docMaxRenderEdge caps the longest edge of a rendered page, in pixels.
docMaxRenderEdge = 4096
// docMinDPI and docMaxDPI bound the computed render resolution.
docMinDPI = 36.0
docMaxDPI = 600.0
)
var renderMu sync.Mutex
// docRenderOptions controls the size and quality of one rendered document page.
type docRenderOptions struct {
MaxWidth int
MaxHeight int
Fill bool
JpegQuality int
Original bool
}
// folioEnabled reports whether the folio fast path may run.
func folioEnabled() bool {
return !settings.AlwaysUseOnlyOfficeEnabled()
}
func logFolioFallback(op, path string, err error) {
if errors.Is(err, doc.ErrUnsupported) {
logger.Warningf("folio does not support %s for %s (%v); using office converter if configured", op, path, err)
return
}
logger.Errorf("folio %s failed for %s: %v; falling back to office converter if configured", op, path, err)
}
// renderDocPageJPEG renders one document page to JPEG. ok is true when folio returned bytes.
// name and mimetype should match the file being previewed; they control eligibility for
// folio (content sniffing does not depend on the path extension).
func renderDocPageJPEG(ctx context.Context, path string, pageNumber int, opts docRenderOptions, name, mimetype string) (data []byte, ok bool, err error) {
if !folioEnabled() {
return nil, false, nil
}
if name == "" {
name = filepath.Base(path)
}
if !folioSupportsPreview(name, mimetype) {
return nil, false, nil
}
if ctx.Err() != nil {
return nil, false, ctx.Err()
}
type result struct {
data []byte
err error
}
done := make(chan result, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- result{err: fmt.Errorf("folio render panicked: %v", r)}
}
}()
data, err := renderPageJPEG(ctx, path, name, mimetype, pageNumber, opts)
done <- result{data: data, err: err}
}()
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case res := <-done:
if res.err != nil {
if !errors.Is(res.err, context.Canceled) && !errors.Is(res.err, context.DeadlineExceeded) {
logFolioFallback("render", path, res.err)
}
return nil, false, res.err
}
if len(res.data) == 0 {
return nil, false, fmt.Errorf("folio render returned empty jpeg")
}
return res.data, true, nil
}
}
func renderPageJPEG(ctx context.Context, path, name, mimetype string, pageNumber int, opts docRenderOptions) ([]byte, error) {
renderMu.Lock()
defer renderMu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
d, err := openDocument(path, name, mimetype)
if err != nil {
return nil, err
}
defer d.Close()
if pageNumber < 0 || pageNumber >= d.NumPages() {
return nil, fmt.Errorf("invalid page number %d (pages: %d)", pageNumber, d.NumPages())
}
p, err := d.Page(pageNumber)
if err != nil {
return nil, err
}
boxed := !opts.Original && opts.MaxWidth > 0 && opts.MaxHeight > 0
var dpi float64
if boxed {
dpi = docBoxRenderDPI(p, d.Kind(), opts.MaxWidth, opts.MaxHeight, opts.Fill)
} else {
dpi = docOriginalRenderDPI(p, d.Kind())
}
rendered, err := p.ImageDPI(dpi)
if err != nil {
return nil, err
}
if rendered == nil {
return nil, fmt.Errorf("folio rendered no image for page %d", pageNumber)
}
var img image.Image = rendered
if boxed {
// The page was rendered at docSupersample times the target, so this
// single high-quality downscale produces the anti-aliased final image.
if opts.Fill {
img = imaging.Fill(img, opts.MaxWidth, opts.MaxHeight, imaging.Center, imaging.Lanczos)
} else {
img = imaging.Fit(img, opts.MaxWidth, opts.MaxHeight, imaging.Lanczos)
}
}
q := opts.JpegQuality
if q <= 0 {
q = 85
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// docUnitDPI is the resolution at which folio's ImageDPI renders page units one to one:
// 72 for PDF and book pages, 96 for drawings (SVG).
func docUnitDPI(kind doc.Kind) float64 {
if kind == doc.KindSVG {
return 96
}
return 72
}
// docMaxEdgeDPI is the highest resolution at which the longest page edge stays within docMaxRenderEdge.
func docMaxEdgeDPI(p doc.Page, kind doc.Kind) float64 {
b := p.Bounds()
longest := float64(max(b.X1-b.X0, b.Y1-b.Y0))
if longest <= 0 {
return docMinDPI
}
return docUnitDPI(kind) * docMaxRenderEdge / longest
}
// docBoxRenderDPI picks the resolution for a page that will be scaled into a boxW x boxH target.
// Fill covers the box; fit stays inside it. The page is rendered at docSupersample times the target.
func docBoxRenderDPI(p doc.Page, kind doc.Kind, boxW, boxH int, fill bool) float64 {
b := p.Bounds()
pw, ph := float64(b.X1-b.X0), float64(b.Y1-b.Y0)
if pw <= 0 || ph <= 0 {
return docMinDPI
}
// Scale (pixels per page unit) needed for the supersampled render to reach the box on each axis.
sx := float64(boxW*docSupersample) / pw
sy := float64(boxH*docSupersample) / ph
s := min(sx, sy)
if fill {
s = max(sx, sy)
}
dpi := min(s*docUnitDPI(kind), docMaxEdgeDPI(p, kind))
return min(max(dpi, docMinDPI), docMaxDPI)
}
// docOriginalRenderDPI picks the resolution for "original" previews: docOriginalDPI, capped by docMaxRenderEdge.
func docOriginalRenderDPI(p doc.Page, kind doc.Kind) float64 {
return min(docOriginalDPI, docMaxEdgeDPI(p, kind))
}
@@ -1,4 +1,4 @@
package folio
package preview
import (
"fmt"
@@ -17,9 +17,9 @@ const folioTextCheckWindow = 4096 // html.readsAsText validation window
// maxTextPreviewLoad is the most plain-text we read for a first-page thumbnail.
const maxTextPreviewLoad = 32 << 10 // 32 KiB
// SupportsPreview reports whether folio should attempt this file (name + MIME).
// folioSupportsPreview reports whether folio should attempt this file (name + MIME).
// doc.Open sniffs content; extensions like .css are not in FolioConvertable but still render.
func SupportsPreview(name, mimetype string) bool {
func folioSupportsPreview(name, mimetype string) bool {
ext := strings.ToLower(filepath.Ext(name))
if iteminfo.FolioConvertable[ext] {
return true
@@ -1,4 +1,4 @@
package folio
package preview
import (
"os"
@@ -8,12 +8,12 @@ import (
"github.com/stretchr/testify/require"
)
func TestRenderPageJPEG_txt(t *testing.T) {
func TestRenderDocPageJPEG_txt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hello.txt")
require.NoError(t, os.WriteFile(path, []byte("Hello folio preview"), 0o644))
data, ok, err := RenderPageJPEG(t.Context(), path, 0, RenderOptions{
data, ok, err := renderDocPageJPEG(t.Context(), path, 0, docRenderOptions{
MaxWidth: 256,
MaxHeight: 256,
Fill: true,
@@ -26,8 +26,8 @@ func TestRenderPageJPEG_txt(t *testing.T) {
require.Equal(t, byte(0xd8), data[1])
}
func TestRenderPageJPEG_unsupportedExtension(t *testing.T) {
data, ok, err := RenderPageJPEG(t.Context(), "/nonexistent/file.xyz", 0, RenderOptions{}, "file.xyz", "application/octet-stream")
func TestRenderDocPageJPEG_unsupportedExtension(t *testing.T) {
data, ok, err := renderDocPageJPEG(t.Context(), "/nonexistent/file.xyz", 0, docRenderOptions{}, "file.xyz", "application/octet-stream")
require.NoError(t, err)
require.False(t, ok)
require.Nil(t, data)
@@ -1,4 +1,4 @@
package folio
package preview
import (
"os"
@@ -37,22 +37,17 @@ func TestSanitizeFolioText_stripsNUL(t *testing.T) {
raw := []byte("hello\x00world")
got := sanitizeFolioText(raw)
require.Equal(t, []byte("hello world"), got)
opts, err := folioRenderOptionsForTest()
require.NoError(t, err)
opts := docRenderOptions{MaxWidth: 256, MaxHeight: 256, Fill: true, JpegQuality: 80}
dir := t.TempDir()
path := filepath.Join(dir, "nul.txt")
require.NoError(t, os.WriteFile(path, raw, 0o600))
data, ok, err := RenderPageJPEG(t.Context(), path, 0, opts, "nul.txt", "text/plain")
data, ok, err := renderDocPageJPEG(t.Context(), path, 0, opts, "nul.txt", "text/plain")
require.NoError(t, err)
require.True(t, ok, "err=%v", err)
require.NotEmpty(t, data)
}
func folioRenderOptionsForTest() (RenderOptions, error) {
return RenderOptions{MaxWidth: 256, MaxHeight: 256, Fill: true, JpegQuality: 80}, nil
}
func TestReadTextPreviewPrefix_cap(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.log")
+2 -3
View File
@@ -18,7 +18,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/embeddedpreview"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/folio"
"github.com/gtsteffaniak/filebrowser/backend/internal/imagemeta"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
@@ -481,7 +480,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// Folio document previews render at target size in-process; they skip imageSem.
previewTypeEarly := determinePreviewType(file)
skipImageSem := previewTypeEarly == previewTypeDocument && folio.Enabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)
skipImageSem := previewTypeEarly == previewTypeDocument && folioEnabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)
// True when the semaphore block below holds a permit for this whole function.
imagePermitHeld := !skipImageSem
if !skipImageSem {
@@ -576,7 +575,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// Video thumbnails come from go-media or ffmpeg already sized to the preview
// box, so they are final and need no ffmpeg gate here.
if !fromEmbeddedPreview && (previewType == previewTypeImage ||
(previewType == previewTypeDocument && folio.Enabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)) ||
(previewType == previewTypeDocument && folioEnabled() && iteminfo.HasDocConvertableExtension(file.Name, file.Type)) ||
previewType == previewTypeVideo) {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
+20 -10
View File
@@ -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)
@@ -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"])
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ func initRuntime(deps Deps, assets fs.FS) {
initSettingsSecretMarkers()
}
// Embed the frontend build output under internal/web/dist (see frontend `npm run build`).
// Embed the frontend build output under internal/web/dist (see `make embed-frontend` after `npm run build` in frontend/).
//
//go:embed dist/*
var assets embed.FS
+15 -28
View File
@@ -455,31 +455,26 @@ func settingsShareDefaultsPatchHandler(w http.ResponseWriter, r *http.Request, 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).
// @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]
// 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 {
@@ -492,11 +487,14 @@ func mediaSettingsLockMeta() (paths []string, message string) {
// metadata, subtitles, and video thumbnails without it. ffmpeg-only controls
// (transcoding, hardware acceleration) report their own availability.
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 +504,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 +512,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.
+13 -10
View File
@@ -151,7 +151,14 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
externalLinks = newExternalLinks
}
defaultThemeColor := settings.DefaultBackgroundColor()
// Single source for the instance default dark mode. It drives the server-rendered
// <html> class (htmlVars.defaultDarkMode), the theme-color meta, and globalVars.darkMode,
// so the first paint and the SPA agree.
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
@@ -245,11 +252,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
manifestURL = staticURL + "/site.webmanifest?" + query.Encode()
}
// only include constant values that don't change during a users session
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": template.CSS(settings.Config.Frontend.Styling.CustomCSSRaw),
"userSelectedTheme": template.CSS(userSelectedTheme),
"defaultDarkMode": settings.DefaultDarkMode(),
"defaultDarkMode": defaultDark,
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
@@ -272,12 +280,13 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"devMode": settings.Env.IsDevMode,
}
// 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,
"darkMode": defaultDark,
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
@@ -297,17 +306,10 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"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(),
"textPreviewExtensions": iteminfo.TextPreviewExtensionsForSPA(),
"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,
@@ -324,6 +326,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"playwrightTest": settings.Env.IsPlaywright,
"officeProduct": officeProductForSPA(),
"officeExtensions": wopiExtensionsForSPA(),
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
}
cspNonce, err := utils.CSPNonce()
+2 -4
View File
@@ -5,8 +5,6 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
@@ -101,10 +99,10 @@ func serveTextPreview(w http.ResponseWriter, r *http.Request, info *iteminfo.Ext
if info.Type == "directory" {
return http.StatusUnsupportedMediaType, fmt.Errorf("directories have no text preview")
}
kind := iteminfo.TextPreviewKind(strings.ToLower(filepath.Ext(info.Name)))
if kind == "" {
if !iteminfo.IsTextPreviewable(info.Type) {
return http.StatusUnsupportedMediaType, fmt.Errorf("unsupported file type for text preview")
}
kind := iteminfo.TextPreviewRenderKind(info.Name)
// Bound concurrent reads and RTF conversions with the preview service's shared
// image-processor semaphore, so a listing of many text files cannot open an
// unbounded number of file handles at once.
@@ -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)
+1 -1
View File
@@ -1597,7 +1597,7 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
}
// Text-like files are rendered by the text preview endpoint (not the image pipeline),
// so this runs before Folio/OnlyOffice. SVG is not a text preview type and stays an image.
if iteminfo.IsTextPreviewable(fileInfo.Name) {
if iteminfo.IsTextPreviewable(fileInfo.Type) {
fileInfo.HasPreview = true
return
}
+88 -61
View File
@@ -271,57 +271,57 @@ var compressedFile = map[string]bool{
// Raw camera image: extension -> MIME type (single source for IsRawImage + type detection)
var rawImageMimeType = map[string]string{
".cr2": "image/x-canon-cr2",
".cr3": "image/x-canon-cr3",
".nef": "image/x-nikon-nef",
".nrw": "image/x-nikon-nef",
".arw": "image/x-sony-arw",
".srf": "image/x-sony-arw",
".sr2": "image/x-sony-arw",
".orf": "image/x-olympus-orf",
".rw2": "image/x-panasonic-rw2",
".raw": "image/x-panasonic-raw",
".dng": "image/x-adobe-dng",
".raf": "image/x-fuji-raf",
".pef": "image/x-pentax-pef",
".ptx": "image/x-pentax-pef",
".rwl": "image/x-leica-rwl",
".3fr": "image/x-hasselblad-3fr",
".fff": "image/x-hasselblad-fff",
".erf": "image/x-epson-erf",
".mrw": "image/x-minolta-mrw",
".dcr": "image/x-kodak-dcr",
".kdc": "image/x-kodak-dcr",
".dc2": "image/x-kodak-dc2",
".x3f": "image/x-sigma-x3f",
".iiq": "image/x-phaseone-iiq",
".nkc": "image/x-kodak-nkc",
".r3d": "image/x-red-r3d",
".cr2": "image/x-canon-cr2",
".cr3": "image/x-canon-cr3",
".nef": "image/x-nikon-nef",
".nrw": "image/x-nikon-nef",
".arw": "image/x-sony-arw",
".srf": "image/x-sony-arw",
".sr2": "image/x-sony-arw",
".orf": "image/x-olympus-orf",
".rw2": "image/x-panasonic-rw2",
".raw": "image/x-panasonic-raw",
".dng": "image/x-adobe-dng",
".raf": "image/x-fuji-raf",
".pef": "image/x-pentax-pef",
".ptx": "image/x-pentax-pef",
".rwl": "image/x-leica-rwl",
".3fr": "image/x-hasselblad-3fr",
".fff": "image/x-hasselblad-fff",
".erf": "image/x-epson-erf",
".mrw": "image/x-minolta-mrw",
".dcr": "image/x-kodak-dcr",
".kdc": "image/x-kodak-dcr",
".dc2": "image/x-kodak-dc2",
".x3f": "image/x-sigma-x3f",
".iiq": "image/x-phaseone-iiq",
".nkc": "image/x-kodak-nkc",
".r3d": "image/x-red-r3d",
}
// 3D model extension -> MIME type (single source for Is3DModel + type detection)
var model3DMimeType = map[string]string{
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".fbx": "model/fbx",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
".3mf": "model/3mf",
".3ds": "model/3ds",
".usdz": "model/vnd.usdz+zip",
".usd": "model/vnd.usd+zip",
".usda": "model/vnd.usd+zip",
".usdc": "model/vnd.usd+zip",
".amf": "model/x-amf",
".vrml": "model/vrml",
".wrl": "model/vrml",
".vtk": "model/vtk",
".vtp": "model/vtk",
".pcd": "model/pcd",
".xyz": "model/xyz",
".vox": "model/vox",
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".fbx": "model/fbx",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
".3mf": "model/3mf",
".3ds": "model/3ds",
".usdz": "model/vnd.usdz+zip",
".usd": "model/vnd.usd+zip",
".usda": "model/vnd.usd+zip",
".usdc": "model/vnd.usd+zip",
".amf": "model/x-amf",
".vrml": "model/vrml",
".wrl": "model/vrml",
".vtk": "model/vtk",
".vtp": "model/vtk",
".pcd": "model/pcd",
".xyz": "model/xyz",
".vox": "model/vox",
}
var audioMetadataTypes = map[string]bool{
@@ -560,20 +560,47 @@ func TextPreviewKind(ext string) string {
return textPreviewKinds[ext]
}
// IsTextPreviewable reports whether a file name uses the text preview endpoint.
func IsTextPreviewable(name string) bool {
return textPreviewKinds[strings.ToLower(filepath.Ext(name))] != ""
// textPreviewMimeTypes are non-text/* mimetypes that textTypes extensions resolve to
// (see the custom table in backend/internal/adapters/fs/files/mime.go). The frontend
// receives this list through globalVars (backend/internal/web/static.go), so it is
// the single source of truth.
// Do not add mimetypes shared with binaries (e.g. application/x-msdownload for .exe).
var textPreviewMimeTypes = map[string]bool{
"application/json": true,
"application/yaml": true,
"application/x-yaml": true,
"application/sql": true,
"application/x-sql": true,
"application/x-sh": true,
"application/x-php": true,
"application/x-powershell": true,
"application/x-tex": true,
"application/toml": true,
"application/x-ruby": true,
}
// TextPreviewExtensionsForSPA groups text preview extensions (without dots) by renderer kind.
// The frontend uses this to decide which items get a text preview container.
func TextPreviewExtensionsForSPA() map[string][]string {
out := make(map[string][]string)
for ext, kind := range textPreviewKinds {
out[kind] = append(out[kind], strings.TrimPrefix(ext, "."))
}
for kind := range out {
sort.Strings(out[kind])
}
return out
// IsTextPreviewable reports whether an item uses the text preview endpoint. The decision
// is the item's mimetype: every text/* type and the textPreviewMimeTypes allowlist.
func IsTextPreviewable(mimetype string) bool {
return strings.HasPrefix(mimetype, "text/") || textPreviewMimeTypes[mimetype]
}
// TextPreviewMimeTypes returns the non-text/* allowlist in sorted order, for the
// frontend's globalVars. Sorting keeps the served value stable across restarts.
func TextPreviewMimeTypes() []string {
types := make([]string, 0, len(textPreviewMimeTypes))
for mt := range textPreviewMimeTypes {
types = append(types, mt)
}
sort.Strings(types)
return types
}
// TextPreviewRenderKind returns the renderer kind for a text-previewable file: the
// extension-specific kind when one exists, otherwise "plain".
func TextPreviewRenderKind(name string) string {
if kind := TextPreviewKind(strings.ToLower(filepath.Ext(name))); kind != "" {
return kind
}
return "plain"
}
@@ -1,7 +1,7 @@
package iteminfo
import (
"slices"
"sort"
"testing"
"github.com/stretchr/testify/require"
@@ -22,21 +22,30 @@ func TestTextPreviewKinds(t *testing.T) {
}
func TestIsTextPreviewable(t *testing.T) {
require.True(t, IsTextPreviewable("Notes.TXT"))
require.True(t, IsTextPreviewable("dir/readme.md"))
require.False(t, IsTextPreviewable("image.svg"))
require.False(t, IsTextPreviewable("noext"))
}
func TestTextPreviewExtensionsForSPA(t *testing.T) {
groups := TextPreviewExtensionsForSPA()
require.Contains(t, groups["markdown"], "md")
require.Contains(t, groups["rtf"], "rtf")
require.True(t, slices.Contains(groups["code"], "go"))
require.False(t, slices.Contains(groups["code"], "svg"))
for _, exts := range groups {
for _, ext := range exts {
require.NotContains(t, ext, ".", "extensions must not have a leading dot")
}
for _, mt := range []string{"text/plain", "text/markdown", "text/lyrics", "text/x-go", "text/html"} {
require.True(t, IsTextPreviewable(mt), mt)
}
for _, mt := range []string{"application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"} {
require.True(t, IsTextPreviewable(mt), mt)
}
// Mimetypes shared with binaries (.exe, .bat via application/x-msdownload) must stay non-previewable.
for _, mt := range []string{"", "image/svg+xml", "application/pdf", "application/x-sqlite3", "application/postscript", "application/backup", "application/wasm", "application/x-msdownload", "application/x-msdos-program", "application/octet-stream"} {
require.False(t, IsTextPreviewable(mt), mt)
}
}
func TestTextPreviewRenderKind(t *testing.T) {
require.Equal(t, "markdown", TextPreviewRenderKind("notes.md"))
require.Equal(t, "plain", TextPreviewRenderKind("50. Holy Water.lrc"))
require.Equal(t, "plain", TextPreviewRenderKind("cert.pem"))
}
func TestTextPreviewMimeTypesIsSortedAndMatchesAllowlist(t *testing.T) {
types := TextPreviewMimeTypes()
require.NotEmpty(t, types)
require.True(t, sort.StringsAreSorted(types), "list must be sorted for a stable globalVars value")
for _, mt := range types {
require.True(t, IsTextPreviewable(mt), mt)
}
require.Contains(t, types, "application/json")
}
+11 -9
View File
@@ -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 {
@@ -1073,14 +1073,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).
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)
+9 -9
View File
@@ -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
+15 -16
View File
@@ -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)
+55 -9
View File
@@ -209,8 +209,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,24 +220,68 @@ 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
+11 -12
View File
@@ -25,16 +25,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 +66,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 +224,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(),
},
@@ -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")
}
}
+49 -29
View File
@@ -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.
+62 -48
View File
@@ -6299,6 +6299,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 +6692,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": {
@@ -8942,7 +8949,7 @@ 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).",
"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.",
"type": "object",
"properties": {
"accelDevice": {
@@ -8985,6 +8992,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"
},
@@ -8998,7 +9012,7 @@ const docTemplate = `{
"description": "HLS transcoding for media playback",
"allOf": [
{
"$ref": "#/definitions/settings.MediaTranscode"
"$ref": "#/definitions/settings.FullAdminTranscode"
}
]
}
+62 -48
View File
@@ -6288,6 +6288,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 +6681,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": {
@@ -8931,7 +8938,7 @@
}
},
"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).",
"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.",
"type": "object",
"properties": {
"accelDevice": {
@@ -8974,6 +8981,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"
},
@@ -8987,7 +9001,7 @@
"description": "HLS transcoding for media playback",
"allOf": [
{
"$ref": "#/definitions/settings.MediaTranscode"
"$ref": "#/definitions/settings.FullAdminTranscode"
}
]
}
+53 -40
View File
@@ -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:
@@ -2411,9 +2416,12 @@ 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).
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.'
properties:
accelDevice:
description: 'acceleration device: "auto", "software"/"cpu", "igpu", "dgpu",
@@ -2446,6 +2454,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:
@@ -2454,7 +2467,7 @@ definitions:
type: array
transcode:
allOf:
- $ref: '#/definitions/settings.MediaTranscode'
- $ref: '#/definitions/settings.FullAdminTranscode'
description: HLS transcoding for media playback
type: object
web.pinnedItemPatchRequest:
+7 -6
View File
@@ -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) {
@@ -132,7 +132,7 @@ export default {
}
// 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 useOriginalForHeic = isHeicOrHeif && (state.isSafari || state.mediaSettings?.imagePreview?.heic === true);
const useOriginalForRaw = isRawImageMimeType(state.req?.type);
if (useOriginalForHeic || useOriginalForRaw) {
return null;
+2 -2
View File
@@ -53,7 +53,7 @@ import {
storeThumbnailBlob,
} from "@/utils/cache/imageCache.ts";
import { queuedFetchPreview } from "@/utils/previewRequests.ts";
import { isTextPreviewableName, languageFromPath, MIN_TEXT_PREVIEW_WIDTH_PX } from "@/utils/textPreview.ts";
import { isTextPreviewableType, languageFromPath, MIN_TEXT_PREVIEW_WIDTH_PX } from "@/utils/textPreview.ts";
const props = withDefaults(
defineProps<{
@@ -226,7 +226,7 @@ const shouldUse3DPreview = computed(() => {
/** True when the filename has an extension the text preview endpoint supports. */
const isTextPreviewable = computed(
() => props.mimetype !== "directory" && isTextPreviewableName(props.filename),
() => props.mimetype !== "directory" && isTextPreviewableType(props.mimetype),
);
/** Extension used for syntax highlighting and the CSV/TSV delimiter. */
@@ -98,7 +98,7 @@ import { globalVars } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { getKindKey } from "@/utils/mimetype";
import { isTextPreviewableName } from "@/utils/textPreview";
import { isTextPreviewableType } from "@/utils/textPreview";
import { hasCachedThumbnailBlob } from "@/utils/cache/imageCache.ts";
import { formatDuration } from "@/utils/files.js";
import { getObjectProperty } from '@/utils/object.js';
@@ -383,7 +383,7 @@ export default {
}
// Only text-previewable names have a preview; other items must never build a URL
// (a DuplicateFinder row can pass a text-like name with no source).
if (!isTextPreviewableName(this.name)) {
if (!isTextPreviewableType(this.type)) {
return "";
}
let previewPath;
+1 -1
View File
@@ -83,7 +83,7 @@ import { useListingVirtualization } from "@/composables/useListingVirtualization
import url from "@/utils/url";
import { removeFromFolderLists } from "@/utils/cache/listingCache.ts";
interface DeleteItem {
export interface DeleteItem {
source?: string;
path: string;
type?: string;
@@ -138,7 +138,7 @@ export default {
if (!skipped) {
this.media = media;
}
void mutations.syncMediaPlayerSettings();
void mutations.syncMediaSettings();
notify.showSuccess(this.$t("general.saved"));
eventBus.emit(MEDIA_SETTINGS_CHANGED_EVENT);
this.close();
@@ -238,7 +238,7 @@ export default {
if (!skipped) {
this.media = media;
}
void mutations.syncMediaPlayerSettings();
void mutations.syncMediaSettings();
notify.showSuccess(this.$t("general.saved"));
eventBus.emit(MEDIA_SETTINGS_CHANGED_EVENT);
this.close();
+1 -1
View File
@@ -16,7 +16,7 @@ const { store, router, notify } = vi.hoisted(() => {
setCurrentUser: vi.fn((user) => { state.user = user; }),
setSession: vi.fn(),
syncEnforcedUserDefaults: vi.fn(),
syncMediaPlayerSettings: vi.fn(),
syncMediaSettings: vi.fn(),
syncSidebarLinkDefaultsPolicy: vi.fn(),
syncToolAccessDefaultsPolicy: vi.fn(),
syncShareDefaultsPolicy: vi.fn(),
@@ -194,7 +194,7 @@ export default {
computed: {
maxConcurrentTranscodesLimit() {
// Prefer the live media settings; globalVars are static page constants.
const live = state.mediaPlayerSettings?.maxConcurrentPerUser;
const live = state.mediaSettings?.maxConcurrentPerUser;
if (Number.isFinite(live) && live > 0) {
return live;
}
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/store', () => ({ state: { mediaPlayerSettings: null } }));
vi.mock('@/store', () => ({ state: { mediaSettings: null } }));
vi.mock('@/utils/constants', () => ({
globalVars: { ffmpegAvailable: true, transcodeEnabled: true },
globalVars: { ffmpegAvailable: true },
}));
import { state } from '@/store/index.ts';
@@ -11,35 +11,31 @@ import { isTranscodeAvailable } from '@/plyr/transcodeMode.js';
describe('isTranscodeAvailable', () => {
beforeEach(() => {
state.mediaPlayerSettings = null;
state.mediaSettings = null;
globalVars.ffmpegAvailable = true;
globalVars.transcodeEnabled = true;
});
it('falls back to the page constant before settings load', () => {
expect(isTranscodeAvailable()).toBe(true);
globalVars.transcodeEnabled = false;
it('stays off until live media settings load', () => {
expect(isTranscodeAvailable()).toBe(false);
});
it('prefers the live media settings over the page constant', () => {
state.mediaPlayerSettings = { enabled: false };
it('follows the live media settings', () => {
state.mediaSettings = { enabled: false };
expect(isTranscodeAvailable()).toBe(false);
state.mediaPlayerSettings = { enabled: true };
state.mediaSettings = { enabled: true };
expect(isTranscodeAvailable()).toBe(true);
});
it('stays off when the server has no ffmpeg', () => {
globalVars.ffmpegAvailable = false;
globalVars.transcodeEnabled = false;
state.mediaSettings = { enabled: true };
expect(isTranscodeAvailable()).toBe(false);
});
it('stays off when ffmpeg is missing even if settings claim enabled', () => {
globalVars.ffmpegAvailable = false;
state.mediaPlayerSettings = { enabled: true };
state.mediaSettings = { enabled: true };
expect(isTranscodeAvailable()).toBe(false);
});
});
+6 -9
View File
@@ -4,20 +4,17 @@ import { state } from '@/store/index.ts';
import { globalVars } from '@/utils/constants.js';
/**
* `globalVars` are static page constants, and `ffmpegAvailable` mirrors ffmpeg
* availability, which cannot change without a restart. Transcoding is ffmpeg-only,
* so it gates the feature outright. The transcode toggle itself is read from the
* live media settings so an admin change applies without a page reload.
* `ffmpegAvailable` is a static page constant that mirrors ffmpeg availability, which
* cannot change without a restart. Transcoding is ffmpeg-only, so it gates the feature
* outright. The transcode toggle is read only from the live media settings, so an admin
* change applies without a page reload. Before those settings load (or for anonymous
* users and shares, which never load them) transcoding reports unavailable.
*/
export function isTranscodeAvailable() {
if (globalVars?.ffmpegAvailable !== true) {
return false;
}
const live = state?.mediaPlayerSettings;
if (live) {
return live.enabled === true;
}
return globalVars.transcodeEnabled === true;
return state?.mediaSettings?.enabled === true;
}
/** Supported transcode playback modes in the player UI. */
+9 -9
View File
@@ -3,7 +3,7 @@ import { markRaw } from "vue";
import { resourcesApi, usersApi } from "@/api/index.js";
import {
getEnforcedUserDefaults,
getMediaPlayerSettings,
getSharedMediaSettings,
getShareDefaultsPolicy,
getSidebarLinkDefaultsPolicy,
getToolAccessDefaultsPolicy,
@@ -622,25 +622,25 @@ export const mutations = {
}
},
/**
* Load the effective media settings for playback. `globalVars` are static
* page constants, so runtime changes to media settings are read from the API
* (available to every authenticated user) instead.
* Load the runtime media settings. Runtime admin changes are read from the API
* rather than static page constants. Called at app start so the state is present
* in every view, not only the player.
*/
syncMediaPlayerSettings: async () => {
syncMediaSettings: async () => {
if (!getters.isLoggedIn() || getters.isShare() || state.user?.username === "anonymous") {
state.mediaPlayerSettings = null;
state.mediaSettings = null;
return;
}
try {
const settings = await getMediaPlayerSettings();
const settings = await getSharedMediaSettings();
// The request can resolve after logout/share transition — don't write
// media settings into a logged-out state.
if (!getters.isLoggedIn() || getters.isShare() || state.user?.username === "anonymous") {
return;
}
state.mediaPlayerSettings = settings;
state.mediaSettings = settings;
} catch {
state.mediaPlayerSettings = null;
state.mediaSettings = null;
}
},
syncSidebarLinkDefaultsPolicy: async () => {
+2 -2
View File
@@ -33,8 +33,8 @@ export const state: StoreState = reactive({
showSidebar: false,
displayPreferences: {},
enforcedUserDefaults: {},
/** Effective media settings for playback; null until the API reports them. */
mediaPlayerSettings: null,
/** Runtime media state shared by the player and general UI; null until loaded (logged-in users only). */
mediaSettings: null,
sidebarLinkDefaultsPolicy: { items: [] },
toolAccessDefaultsPolicy: { items: [] },
shareDefaultsPolicy: { values: {}, enforced: {} },
+4 -3
View File
@@ -330,12 +330,13 @@ export interface StoreState {
newFileTemplate?: boolean;
};
};
/** Effective media settings for playback, or null before the API reports them. */
mediaPlayerSettings: {
/** Runtime media state for all UI (player, listings, previews); null until loaded. */
mediaSettings: {
enabled: boolean;
profiles: string[];
maxConcurrentPerUser: number;
hardwareAcceleration: boolean;
/** Per image preview type (e.g. heic, jxl): conversion active on this server. */
imagePreview: Partial<Record<'heic' | 'jxl' | 'jpeg', boolean>>;
} | null;
sidebarLinkDefaultsPolicy: {
items?: Array<{
+1 -1
View File
@@ -88,7 +88,7 @@ export async function validateLogin(isPublicRoute = false) {
const userInfo = await res.json();
await mutations.setCurrentUser(userInfo);
await mutations.syncEnforcedUserDefaults();
await mutations.syncMediaPlayerSettings();
await mutations.syncMediaSettings();
await mutations.syncSidebarLinkDefaultsPolicy();
await mutations.syncToolAccessDefaultsPolicy();
await mutations.syncShareDefaultsPolicy();
+1 -1
View File
@@ -8,7 +8,7 @@ const { storeMock } = vi.hoisted(() => ({
isLoggedIn: vi.fn(() => true),
isShare: vi.fn(() => false),
},
mutations: { setCurrentUser: vi.fn(), setSession: vi.fn(), syncEnforcedUserDefaults: vi.fn(), syncMediaPlayerSettings: vi.fn(), syncSidebarLinkDefaultsPolicy: vi.fn(), syncToolAccessDefaultsPolicy: vi.fn(), syncShareDefaultsPolicy: vi.fn() },
mutations: { setCurrentUser: vi.fn(), setSession: vi.fn(), syncEnforcedUserDefaults: vi.fn(), syncMediaSettings: vi.fn(), syncSidebarLinkDefaultsPolicy: vi.fn(), syncToolAccessDefaultsPolicy: vi.fn(), syncShareDefaultsPolicy: vi.fn() },
},
}));
+8 -5
View File
@@ -88,13 +88,16 @@ export function enabledTranscodeProfileIds(media) {
* the full object to admins and a player-only subset to everyone else, so both
* shapes collapse to the same fields here.
*/
export function normalizeMediaPlayerSettings(data) {
export function normalizeSharedMediaSettings(data) {
// Both payloads share the transcode keys: enabled, maxConcurrentPerUser, and the
// derived enabledProfiles. Admin payloads also carry the full profiles object, so
// fall back to deriving the ids from it when enabledProfiles is absent.
const transcode = data?.transcode || {};
return {
enabled: Boolean(data?.enabled ?? transcode.enabled),
profiles: Array.isArray(data?.profiles) ? data.profiles : enabledTranscodeProfileIds(data),
maxConcurrentPerUser: Number(data?.maxConcurrentPerUser ?? transcode.maxConcurrentPerUser) || 1,
hardwareAcceleration: Boolean(data?.hardwareAcceleration),
enabled: Boolean(transcode.enabled),
profiles: Array.isArray(transcode.enabledProfiles) ? transcode.enabledProfiles : enabledTranscodeProfileIds(data),
maxConcurrentPerUser: Number(transcode.maxConcurrentPerUser) || 1,
imagePreview: { ...(data?.imagePreview || {}) },
};
}
+10 -13
View File
@@ -8,7 +8,7 @@ import { patchMediaSettings } from '@/api/settings.js';
import {
enabledTranscodeProfileIds,
mergeMediaSettings,
normalizeMediaPlayerSettings,
normalizeSharedMediaSettings,
saveMediaSettingsPatch,
stripLockedMediaPaths,
} from '@/utils/mediaSettings.js';
@@ -104,7 +104,7 @@ describe('mediaSettings', () => {
});
});
describe('normalizeMediaPlayerSettings', () => {
describe('normalizeSharedMediaSettings', () => {
it('derives enabled profiles in menu order from the admin settings shape', () => {
const media = {
transcode: {
@@ -116,37 +116,34 @@ describe('mediaSettings', () => {
datasaver: { enabled: true },
},
},
hardwareAcceleration: true,
};
expect(enabledTranscodeProfileIds(media)).toEqual(['quality', 'datasaver']);
expect(normalizeMediaPlayerSettings(media)).toEqual({
expect(normalizeSharedMediaSettings(media)).toEqual({
enabled: true,
profiles: ['quality', 'datasaver'],
maxConcurrentPerUser: 2,
hardwareAcceleration: true,
imagePreview: {},
});
});
it('accepts the player-only shape returned to non-admin users', () => {
expect(normalizeMediaPlayerSettings({
enabled: false,
profiles: ['quality'],
maxConcurrentPerUser: 3,
hardwareAcceleration: false,
expect(normalizeSharedMediaSettings({
transcode: { enabled: false, maxConcurrentPerUser: 3, enabledProfiles: ['quality'] },
imagePreview: { heic: true },
})).toEqual({
enabled: false,
profiles: ['quality'],
maxConcurrentPerUser: 3,
hardwareAcceleration: false,
imagePreview: { heic: true },
});
});
it('falls back to a per-user limit of one and disabled when values are absent', () => {
expect(normalizeMediaPlayerSettings(undefined)).toEqual({
expect(normalizeSharedMediaSettings(undefined)).toEqual({
enabled: false,
profiles: [],
maxConcurrentPerUser: 1,
hardwareAcceleration: false,
imagePreview: {},
});
});
});
+54 -7
View File
@@ -1,9 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// The backend serves the text allowlist through globalVars (backend/internal/web/static.go).
const { globalsMock } = vi.hoisted(() => ({
globalsMock: {
textPreviewMimeTypes: [
"application/json",
"application/yaml",
"application/x-yaml",
"application/sql",
"application/x-sql",
"application/x-sh",
"application/x-php",
"application/x-powershell",
"application/x-tex",
"application/toml",
"application/x-ruby",
] as string[],
},
}));
vi.mock("@/utils/constants.js", async (importOriginal) => ({
...(await importOriginal<object>()),
globalVars: globalsMock,
}));
import {
clearTextPreviewCache,
fetchTextPreview,
getRenderedTextPreview,
isTextPreviewableName,
isTextPreviewableType,
languageFromPath,
languageFromPreviewUrl,
MIN_TEXT_PREVIEW_WIDTH_PX,
@@ -33,12 +58,34 @@ describe("languageFromPath", () => {
});
});
describe("isTextPreviewableName", () => {
it("is true for extensions in the backend kind map", () => {
// The vitest setup stubs globalVars.textPreviewExtensions.
expect(isTextPreviewableName("notes.md")).toBe(true);
expect(isTextPreviewableName("image.png")).toBe(false);
expect(isTextPreviewableName("noext")).toBe(false);
describe("isTextPreviewableType", () => {
it("is true for text/* mimetypes, with or without parameters", () => {
for (const mt of ["text/markdown", "text/lyrics", "text/plain; charset=utf-8", "text/x-go"]) {
expect(isTextPreviewableType(mt), mt).toBe(true);
}
});
it("is true for text-extension mimetypes that are not text/*", () => {
for (const mt of ["application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"]) {
expect(isTextPreviewableType(mt), mt).toBe(true);
}
});
it("follows the allowlist served in globalVars", () => {
const original = globalsMock.textPreviewMimeTypes;
try {
globalsMock.textPreviewMimeTypes = ["application/x-custom"];
expect(isTextPreviewableType("application/x-custom")).toBe(true);
expect(isTextPreviewableType("application/json")).toBe(false);
} finally {
globalsMock.textPreviewMimeTypes = original;
}
});
it("is false for non-text mimetypes and missing types", () => {
for (const mt of ["application/x-sqlite3", "application/postscript", "application/wasm", "application/backup", "application/x-msdownload", "application/octet-stream", "image/svg+xml", "image/png", "directory", "", null, undefined]) {
expect(isTextPreviewableType(mt), String(mt)).toBe(false);
}
});
});
+21 -15
View File
@@ -1,5 +1,5 @@
import { state } from "@/store/index.ts";
import { globalVars } from "@/utils/constants.js";
import { state } from "@/store/index.ts";
import type { TextPreviewPayload } from "@/utils/textPreviewRender.ts";
// Text previews are small (about 16KB), so the frontend keeps a bounded in-memory cache.
@@ -27,23 +27,29 @@ export function languageFromPath(path: string | null | undefined): string | null
return name.slice(dot + 1).toLowerCase();
}
/**
* Every extension the text preview endpoint supports, from the backend's kind map.
* Built once per page rather than once per listing item.
*/
let textPreviewExtSet: Set<string> | null = null;
export function getTextPreviewExtensions(): Set<string> {
if (textPreviewExtSet === null) {
const groups = (globalVars.textPreviewExtensions ?? {}) as Record<string, string[]>;
textPreviewExtSet = new Set(Object.values(groups).flat());
// Non-text/* mimetypes (JSON, YAML, SQL, shell, ...) served by the backend in globalVars.
// The backend is the single source of truth; the Set is rebuilt only when the served array changes.
let allowlistSource: string[] | undefined;
let allowlistSet = new Set<string>();
function textPreviewMimeTypes(): Set<string> {
const source = globalVars?.textPreviewMimeTypes as string[] | undefined;
if (source !== allowlistSource) {
allowlistSource = source;
allowlistSet = new Set(source ?? []);
}
return textPreviewExtSet;
return allowlistSet;
}
/** True when the filename has an extension the text preview endpoint supports. */
export function isTextPreviewableName(filename: string | null | undefined): boolean {
const ext = languageFromPath(filename);
return ext !== null && getTextPreviewExtensions().has(ext);
/**
* True when an item's mimetype is text/* or on the backend's text allowlist. The backend uses
* the same rule, so an item the listing marks as text is the one the text preview endpoint
* renders. Binary types such as sqlite, postscript, wasm and backup never reach the endpoint.
*/
export function isTextPreviewableType(mimetype: string | null | undefined): boolean {
// split always yields at least one element; the ?? "" satisfies noUncheckedIndexedAccess.
const base = (mimetype ?? "").split(";")[0] ?? "";
return base.startsWith("text/") || textPreviewMimeTypes().has(base);
}
// Text fetches are queued so a folder full of text files does not fire dozens of
+2 -2
View File
@@ -211,7 +211,7 @@ export default {
// Viewable when we can get embedded/original preview: (media + heic conversion) or Safari native
isHeicAndViewable() {
if (state.isSafari) return true;
if (globalVars.ffmpegAvailable && globalVars.enableHeicConversion) return true;
if (state.mediaSettings?.imagePreview?.heic === true) return true;
return false;
},
pdfConvertable() {
@@ -262,7 +262,7 @@ export default {
}
const getRawPreview = isRawImageMimeType(state.req.type);
const getHeicPreview = isHeicOrHeif && globalVars.ffmpegAvailable && globalVars.enableHeicConversion;
const getHeicPreview = isHeicOrHeif && state.mediaSettings?.imagePreview?.heic === true;
if (this.pdfConvertable || getRawPreview || getHeicPreview) {
if (getters.isShare()) {
const previewPath = removeTrailingSlash(state.req.path);
+3 -10
View File
@@ -850,15 +850,8 @@ export default {
return needsTranscodeFirst(this.transcodeProbeParams());
},
transcodeProfileOptions() {
const live = state.mediaPlayerSettings?.profiles;
let configured;
if (Array.isArray(live)) {
configured = live;
} else {
configured = Array.isArray(globalVars.transcodeProfiles)
? globalVars.transcodeProfiles
: PLYR_TRANSCODE_HLS_MODES;
}
const live = state.mediaSettings?.profiles;
const configured = Array.isArray(live) ? live : PLYR_TRANSCODE_HLS_MODES;
const ids = configured.filter((id) => PLYR_TRANSCODE_HLS_MODES.includes(id));
return ids.length ? ids : PLYR_TRANSCODE_HLS_MODES;
},
@@ -875,7 +868,7 @@ export default {
const base = ['captions', 'captionSize', 'quality', 'speed', 'playback', 'loop'];
// Register the slot whenever authenticated video can show it later.
// Plyr only builds settings panels at mount; applyCustomSettings toggles
// visibility when media/live settings settle (e.g. after syncMediaPlayerSettings).
// visibility when media/live settings settle (e.g. after syncMediaSettings).
if (this.previewType === 'video' && getters.isLoggedIn() && !getters.isShare()) {
return [...base, 'transcodeMode'];
}
+3 -3
View File
@@ -121,8 +121,8 @@ export default {
mounted() {
this.mediaSettingsHandler = () => {
void this.load({ silent: true });
// Keep the player's live view of media settings in sync after a save.
void mutations.syncMediaPlayerSettings();
// Keep the shared live view of media settings in sync after a save.
void mutations.syncMediaSettings();
};
eventBus.on(MEDIA_SETTINGS_CHANGED_EVENT, this.mediaSettingsHandler);
void this.load();
@@ -200,7 +200,7 @@ export default {
return;
}
this.media = media;
void mutations.syncMediaPlayerSettings();
void mutations.syncMediaSettings();
notify.showSuccessToast(this.$t("settings.settingsUpdated"));
} catch (e) {
notify.showError(e?.message || this.$t("media.updateFailed"));
-8
View File
@@ -86,14 +86,6 @@ vi.mock('@/utils/constants', () => {
return {
globalVars: {
baseURL: "/files/",
textPreviewExtensions: {
markdown: ["md"],
code: ["go", "js", "ts"],
csv: ["csv", "tsv"],
html: ["html"],
plain: ["txt"],
rtf: ["rtf"],
},
},
baseURL: "/files/",
};
@@ -11,8 +11,9 @@ seg/00000.m4s
`;
/**
* Force the static app-config (and live media settings API) to advertise
* transcoding so the plyr mode menu appears even when the CI image lacks ffmpeg.
* Force ffmpeg availability in the static app-config and advertise transcoding via
* the live media settings API, so the plyr mode menu appears even when the CI image
* lacks ffmpeg. Transcode state is read only from the live API.
*
* Hook getElementById so the patch runs when constants.js reads `#app-config`.
* Only rewrite the JSON text — do not replace `window.globalVars` with a new
@@ -27,10 +28,6 @@ export async function enableTranscodeUi(page: Page) {
try {
const cfg = JSON.parse(el.textContent);
cfg.ffmpegAvailable = true;
cfg.transcodeEnabled = true;
if (!Array.isArray(cfg.transcodeProfiles) || cfg.transcodeProfiles.length === 0) {
cfg.transcodeProfiles = ["quality", "balanced", "datasaver"];
}
el.textContent = JSON.stringify(cfg);
el.dataset.transcodeUiPatched = "1";
} catch (_) {
@@ -48,11 +45,14 @@ export async function enableTranscodeUi(page: Page) {
await route.fulfill({
status: 200,
contentType: "application/json",
// Matches the PUBLIC UserMedia payload from GET /api/settings/media.
body: JSON.stringify({
enabled: true,
profiles: ["quality", "balanced", "datasaver"],
maxConcurrentPerUser: 1,
hardwareAcceleration: false,
transcode: {
enabled: true,
maxConcurrentPerUser: 1,
enabledProfiles: ["quality", "balanced", "datasaver"],
},
imagePreview: {},
}),
});
});
-2
View File
@@ -8,7 +8,6 @@ import stripLegacyCSS from "./scripts/postcss-strip-legacy.ts";
import protectTemplateStyles from "./scripts/vite-protect-template-styles.ts";
const isDevBuild = process.env.DEV_BUILD === "true";
const backendWebDist = path.resolve(import.meta.dirname, "../backend/internal/web/dist");
// Docker builds skip vue-tsc here; CI type-checks in regular-tests.yaml.
const skipTypecheck = process.env.SKIP_TYPECHECK === "1";
@@ -122,7 +121,6 @@ export default defineConfig(({ command }) => {
base: "",
define,
build: {
outDir: backendWebDist,
emptyOutDir: true,
// Optimize for watch mode stability
watch: isDevBuild ? {
+16 -7
View File
@@ -19,7 +19,7 @@ backend_dev_tool = $$(cd $(BACKEND_BUILD) && go tool -n $(1))
.SILENT:
.PHONY: setup update build build-docker build-docker-media build-backend build-frontend dev run generate-docs
.PHONY: setup update build build-docker build-docker-media build-backend build-frontend embed-frontend dev run generate-docs
.PHONY: lint-frontend lint-backend lint test test-backend test-frontend check-all
.PHONY: check-translations sync-translations test-playwright test-playwright-project test-playwright-performance playwright-base playwright-perf-base perf-check perf-smoke perf-baseline perf-dashboard run-proxy screenshots
.PHONY: check-icons generate-icons sync-icons
@@ -52,7 +52,13 @@ build-docker-slim:
build-docker-media:
DOCKER_BUILDKIT=1 docker build --build-arg="VERSION=testing" --build-arg="REVISION=n/a" -t gtstef/filebrowser-media -f _docker/Dockerfile.media .
build-backend:
embed-frontend:
@test -d frontend/dist || (echo "Missing frontend/dist; run 'make build-frontend' first." >&2 && exit 1)
mkdir -p backend/internal/web/dist
find backend/internal/web/dist -mindepth 1 ! -name '.gitkeep' ! -name 'config.generated.yaml' -delete
cp -r frontend/dist/. backend/internal/web/dist/
build-backend: embed-frontend
@echo "Building backend..."
cd backend && go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=testing'"
@echo "✓ Backend built successfully"
@@ -64,6 +70,7 @@ dev: generate-docs generate-icons
bash ./scripts/dev.sh
run: build-frontend generate-docs
$(MAKE) embed-frontend
cd backend && $(call backend_dev_tool,swag) init --output swagger/docs
@if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' '/func init/,+3d' backend/swagger/docs/docs.go; \
@@ -138,7 +145,7 @@ PLAYWRIGHT_PROJECTS := sharing settings noauth general jwt proxy previews oidc n
# make test-playwright-project PROJECT=previews
# or:
# make test-playwright PROJECT=previews
test-playwright: build-frontend
test-playwright: build-frontend embed-frontend
cd backend && GOOS=linux go build -o filebrowser .
ifeq ($(PROJECT),)
$(foreach p,$(PLAYWRIGHT_PROJECTS),docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-$(p) . ;)
@@ -166,7 +173,7 @@ playwright-perf-base:
--build-arg PLAYWRIGHT_BROWSERS="$(PERF_PLAYWRIGHT_BROWSERS)" \
-f _docker/Dockerfile.playwright-perf-base .
test-playwright-performance: build-frontend
test-playwright-performance: build-frontend embed-frontend
cd backend && GOOS=linux go build -o filebrowser .
DOCKER_BUILDKIT=1 docker build --target ci -t filebrowser-playwright-performance \
-f _docker/Dockerfile.playwright-performance .
@@ -182,7 +189,9 @@ PERF_BROWSERS ?= chromium,firefox,webkit
# Only chromium feeds the CI baseline; the others are advisory insight.
perf-check:
ifndef PERF_SKIP_BUILD
$(MAKE) build-frontend
$(MAKE) build-frontend embed-frontend
else
$(MAKE) embed-frontend
endif
cd backend && GOOS=linux go build -o filebrowser .
ifndef PERF_SKIP_PLAYWRIGHT_BASE
@@ -213,7 +222,7 @@ perf-dashboard:
# get version from environment variable, for example
# cd frontend && npm i @playwright/test && npx playwright install --with-deps chromium
# make PLAYWRIGHT_TEST=settings test-playwright-ui
test-playwright-ui: build-frontend
test-playwright-ui: build-frontend embed-frontend
docker stop local-playwright-tests || true
docker rm local-playwright-tests || true
rm -rf _docker/src/tmp/ || true && mkdir -p _docker/src/tmp/
@@ -234,7 +243,7 @@ run-jwt: build-frontend
# optional: install playwright locally
# once local playwright server is running, you can also watch the tests interactively with:
# cd frontend && npx playwright test --project dark-screenshots --ui
screenshots: build-frontend
screenshots: build-frontend embed-frontend
cd backend && GOOS=linux go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=latest'" .
@echo "Running screenshots..."
cd _docker && docker compose down && docker compose up --build local-playwright-screenshots