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
25 changed files with 167 additions and 105 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(),
}
}
+10 -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,
+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: {},
});
});
});
+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 (_) {