Compare commits

...
Author SHA1 Message Date
Graham Steffaniak 1a515f1ed2 updated swagger 2026-11-06 14:40:29 -06:00
Graham Steffaniak 4bebf74f51 cleanup static variables 2026-11-06 14:38:50 -06:00
Graham Steffaniak 210bf9c162 Merge branch 'dev/v2.2.0' of github.com:gtsteffaniak/filebrowser into dev/v2.2.0 2026-11-06 13:47:48 -06:00
Graham Steffaniakandcoderabbitai[bot] 28f067f808 Leftover folio docs fix (#3347)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-11-06 13:47:39 -06:00
29 changed files with 328 additions and 161 deletions

No files matched your search

@@ -90,15 +90,13 @@ func TestSettingsMediaHandlerFullPayloadForAdmin(t *testing.T) {
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"} {
// Admin payload carries the full config surface plus the same shared fields
// every user receives, so the UI sees one consistent media state.
for _, key := range []string{"convert", "debug", "hardwareAcceleration", "enabled", "profiles", "maxConcurrentPerUser", "imagePreview"} {
if _, ok := body[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")
}
}
// Disabling transcoding at runtime must be visible to non-admin players.
+38 -16
View File
@@ -464,20 +464,36 @@ func settingsShareDefaultsPatchHandler(w http.ResponseWriter, r *http.Request, d
// mediaSettingsResponse is the runtime media settings plus config-lock metadata.
type mediaSettingsResponse struct {
settings.MediaSettings
LockedFromConfigPaths []string `json:"lockedFromConfigPaths,omitempty"`
LockMessage string `json:"lockMessage,omitempty"`
AccelDeviceOptions []ffmpeg.AccelDeviceOption `json:"accelDeviceOptions,omitempty"`
EncoderBackendOptions []ffmpeg.EncoderBackendOption `json:"encoderBackendOptions,omitempty"`
// Shared fields, identical to the ones every user receives (see mediaSettings).
Enabled bool `json:"enabled"`
Profiles []string `json:"profiles"`
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"`
ImagePreview map[settings.ImagePreviewType]bool `json:"imagePreview"`
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"`
// mediaSettings is the runtime media state every authenticated user receives, for
// both the player and general UI (listings, previews, uploads). It is loaded once
// at app start and returned with the same shape to admins and non-admins.
type mediaSettings struct {
Enabled bool `json:"enabled"`
Profiles []string `json:"profiles"`
MaxConcurrentPerUser int `json:"maxConcurrentPerUser"`
HardwareAcceleration bool `json:"hardwareAcceleration"`
ImagePreview map[settings.ImagePreviewType]bool `json:"imagePreview"`
}
// 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[settings.ImagePreviewType]bool {
out := make(map[settings.ImagePreviewType]bool, len(settings.AllImagePreviewTypes))
for _, t := range settings.AllImagePreviewTypes {
out[t] = settings.CanConvertImage(string(t))
}
return out
}
func mediaSettingsLockMeta() (paths []string, message string) {
@@ -493,8 +509,9 @@ func mediaSettingsLockMeta() (paths []string, message string) {
// (transcoding, hardware acceleration) report their own availability.
func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if !d.User.Permissions.Admin {
return RenderJSON(w, r, currentMediaPlayerSettings())
return RenderJSON(w, r, currentMediaSettings())
}
shared := currentMediaSettings()
lockedPaths, lockMessage := mediaSettingsLockMeta()
media := state.GetMediaSettings()
svc := ffmpeg.Get()
@@ -507,6 +524,10 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
}
return RenderJSON(w, r, mediaSettingsResponse{
MediaSettings: media,
Enabled: shared.Enabled,
Profiles: shared.Profiles,
MaxConcurrentPerUser: shared.MaxConcurrentPerUser,
ImagePreview: shared.ImagePreview,
LockedFromConfigPaths: lockedPaths,
LockMessage: lockMessage,
AccelDeviceOptions: accelDeviceOptions,
@@ -514,14 +535,15 @@ func settingsMediaGetHandler(w http.ResponseWriter, r *http.Request, d *Context)
})
}
// currentMediaPlayerSettings reads the live media settings for the player UI.
func currentMediaPlayerSettings() mediaPlayerSettings {
// currentMediaSettings reads the live shared media state returned to every user.
func currentMediaSettings() mediaSettings {
media := state.GetMediaSettings()
return mediaPlayerSettings{
return mediaSettings{
Enabled: settings.TranscodeEnabled(),
Profiles: settings.EnabledTranscodeProfiles(),
MaxConcurrentPerUser: settings.TranscodeMaxConcurrentPerUser(),
HardwareAcceleration: media.HardwareAcceleration,
ImagePreview: imagePreviewAvailability(),
}
}
+11 -9
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
@@ -250,7 +257,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"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,
@@ -279,7 +286,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"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,
@@ -299,16 +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(),
"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,
@@ -325,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()
+80 -50
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,10 +560,40 @@ func TextPreviewKind(ext string) string {
return textPreviewKinds[ext]
}
// 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,
}
// IsTextPreviewable reports whether an item uses the text preview endpoint. The decision
// is the item's mimetype: every text/* type is previewable, and everything else is not.
// is the item's mimetype: every text/* type and the textPreviewMimeTypes allowlist.
func IsTextPreviewable(mimetype string) bool {
return strings.HasPrefix(mimetype, "text/")
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
@@ -573,4 +603,4 @@ func TextPreviewRenderKind(name string) string {
return kind
}
return "plain"
}
}
@@ -1,6 +1,7 @@
package iteminfo
import (
"sort"
"testing"
"github.com/stretchr/testify/require"
@@ -24,7 +25,11 @@ func TestIsTextPreviewable(t *testing.T) {
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{"", "image/svg+xml", "application/pdf", "application/json", "application/x-sqlite3", "application/postscript", "application/backup", "application/wasm"} {
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)
}
}
@@ -34,3 +39,13 @@ func TestTextPreviewRenderKind(t *testing.T) {
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")
}
+19
View File
@@ -8975,6 +8975,10 @@ const docTemplate = `{
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"enabled": {
"description": "Shared fields, identical to the ones every user receives (see mediaSettings).",
"type": "boolean"
},
"encoderBackendOptions": {
"type": "array",
"items": {
@@ -8985,6 +8989,12 @@ const docTemplate = `{
"description": "enable hardware acceleration for ffmpeg if available",
"type": "boolean"
},
"imagePreview": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
},
"lockMessage": {
"type": "string"
},
@@ -8994,6 +9004,15 @@ const docTemplate = `{
"type": "string"
}
},
"maxConcurrentPerUser": {
"type": "integer"
},
"profiles": {
"type": "array",
"items": {
"type": "string"
}
},
"transcode": {
"description": "HLS transcoding for media playback",
"allOf": [
+19
View File
@@ -8964,6 +8964,10 @@
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"enabled": {
"description": "Shared fields, identical to the ones every user receives (see mediaSettings).",
"type": "boolean"
},
"encoderBackendOptions": {
"type": "array",
"items": {
@@ -8974,6 +8978,12 @@
"description": "enable hardware acceleration for ffmpeg if available",
"type": "boolean"
},
"imagePreview": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
},
"lockMessage": {
"type": "string"
},
@@ -8983,6 +8993,15 @@
"type": "string"
}
},
"maxConcurrentPerUser": {
"type": "integer"
},
"profiles": {
"type": "array",
"items": {
"type": "string"
}
},
"transcode": {
"description": "HLS transcoding for media playback",
"allOf": [
+14
View File
@@ -2439,6 +2439,10 @@ definitions:
description: output ffmpeg stdout for media integration -- careful can produces
lots of output!
type: boolean
enabled:
description: Shared fields, identical to the ones every user receives (see
mediaSettings).
type: boolean
encoderBackendOptions:
items:
$ref: '#/definitions/ffmpeg.EncoderBackendOption'
@@ -2446,12 +2450,22 @@ definitions:
hardwareAcceleration:
description: enable hardware acceleration for ffmpeg if available
type: boolean
imagePreview:
additionalProperties:
type: boolean
type: object
lockMessage:
type: string
lockedFromConfigPaths:
items:
type: string
type: array
maxConcurrentPerUser:
type: integer
profiles:
items:
type: string
type: array
transcode:
allOf:
- $ref: '#/definitions/settings.MediaTranscode'
+6 -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,13 @@ 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). Every authenticated user receives
* the same fields; admins get extra config fields on the same endpoint.
* @returns {Promise<{ enabled: boolean, profiles: string[], maxConcurrentPerUser: number, hardwareAcceleration: boolean, 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;
@@ -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 -2
View File
@@ -330,12 +330,14 @@ 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() },
},
}));
+2 -1
View File
@@ -88,13 +88,14 @@ 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) {
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),
imagePreview: { ...(data?.imagePreview || {}) },
};
}
+8 -5
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: {
@@ -119,16 +119,17 @@ describe('mediaSettings', () => {
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({
expect(normalizeSharedMediaSettings({
enabled: false,
profiles: ['quality'],
maxConcurrentPerUser: 3,
@@ -138,15 +139,17 @@ describe('mediaSettings', () => {
profiles: ['quality'],
maxConcurrentPerUser: 3,
hardwareAcceleration: false,
imagePreview: {},
});
});
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: {},
});
});
});
+43 -1
View File
@@ -1,4 +1,29 @@
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,
@@ -40,8 +65,25 @@ describe("isTextPreviewableType", () => {
}
});
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", "image/svg+xml", "image/png", "directory", "", null, undefined]) {
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 -4
View File
@@ -1,3 +1,4 @@
import { globalVars } from "@/utils/constants.js";
import { state } from "@/store/index.ts";
import type { TextPreviewPayload } from "@/utils/textPreviewRender.ts";
@@ -26,13 +27,29 @@ export function languageFromPath(path: string | null | undefined): string | null
return name.slice(dot + 1).toLowerCase();
}
// 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 allowlistSet;
}
/**
* True when an item's mimetype is text/*. 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 are not text/* and never reach the endpoint.
* 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 {
return (mimetype ?? "").startsWith("text/");
// 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"));
@@ -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 (_) {