mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-06 17:42:42 -05:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a515f1ed2 | ||
|
|
4bebf74f51 | ||
|
|
210bf9c162 | ||
|
|
f547d0de32 | ||
|
|
324a9c3802 | ||
|
|
972ba638df |
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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
Reference in new issue
Block a user