diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 48b9b147b..a7b330971 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -100,6 +100,7 @@
- [StableCrimson](https://github.com/StableCrimson)
- [diegoeche](https://github.com/diegoeche)
- [Free O'Toole](https://github.com/freeotoole)
+- [TheBosZ](https://github.com/thebosz)
## Emby Contributors
diff --git a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx
new file mode 100644
index 000000000..98fe4be57
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx
@@ -0,0 +1,67 @@
+import ChevronRight from '@mui/icons-material/ChevronRight';
+import Button from '@mui/material/Button';
+import List from '@mui/material/List';
+import Typography from '@mui/material/Typography';
+import React from 'react';
+
+import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage';
+import StorageListItem from 'apps/dashboard/features/storage/components/StorageListItem';
+import globalize from 'lib/globalize';
+
+const ServerPathWidget = () => {
+ const { data: systemStorage } = useSystemStorage();
+
+ return (
+ <>
+ }
+ sx={{
+ marginTop: 1,
+ marginBottom: 1
+ }}
+ // NOTE: We should use a react-router Link component, but components rendered in legacy views lack the
+ // routing context
+ href='#/dashboard/settings'
+ >
+
+ {globalize.translate('HeaderPaths')}
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ServerPathWidget;
diff --git a/src/apps/dashboard/controllers/dashboard.html b/src/apps/dashboard/controllers/dashboard.html
index 5c449c5e9..55906b688 100644
--- a/src/apps/dashboard/controllers/dashboard.html
+++ b/src/apps/dashboard/controllers/dashboard.html
@@ -75,44 +75,7 @@
-
+
diff --git a/src/apps/dashboard/controllers/dashboard.js b/src/apps/dashboard/controllers/dashboard.js
index 2388ac717..8e91a8f33 100644
--- a/src/apps/dashboard/controllers/dashboard.js
+++ b/src/apps/dashboard/controllers/dashboard.js
@@ -1,30 +1,31 @@
import escapeHtml from 'escape-html';
-import datetime from 'scripts/datetime';
-import Events from 'utils/events.ts';
+import ServerPathWidget from 'apps/dashboard/components/widgets/ServerPathWidget';
+import ActivityLog from 'components/activitylog';
+import alert from 'components/alert';
+import cardBuilder from 'components/cardbuilder/cardBuilder';
+import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
+import confirm from 'components/confirm/confirm';
+import imageLoader from 'components/images/imageLoader';
+import indicators from 'components/indicators/indicators';
import itemHelper from 'components/itemHelper';
-import serverNotifications from 'scripts/serverNotifications';
-import dom from 'scripts/dom';
-import globalize from 'lib/globalize';
-import { formatDistanceToNow } from 'date-fns';
-import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
import loading from 'components/loading/loading';
import playMethodHelper from 'components/playback/playmethodhelper';
-import cardBuilder from 'components/cardbuilder/cardBuilder';
-import imageLoader from 'components/images/imageLoader';
-import ActivityLog from 'components/activitylog';
-import imageHelper from 'utils/image';
-import indicators from 'components/indicators/indicators';
+import { formatDistanceToNow } from 'date-fns';
+import { getSystemInfoQuery } from 'hooks/useSystemInfo';
+import globalize from 'lib/globalize';
+import { ServerConnections } from 'lib/jellyfin-apiclient';
+import datetime from 'scripts/datetime';
+import dom from 'scripts/dom';
+import serverNotifications from 'scripts/serverNotifications';
import taskButton from 'scripts/taskbutton';
import Dashboard from 'utils/dashboard';
-import alert from 'components/alert';
-import confirm from 'components/confirm/confirm';
-import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
-
-import { getSystemInfoQuery } from 'hooks/useSystemInfo';
-import { ServerConnections } from 'lib/jellyfin-apiclient';
+import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
+import Events from 'utils/events.ts';
+import imageHelper from 'utils/image';
import { toApi } from 'utils/jellyfin-apiclient/compat';
import { queryClient } from 'utils/query/queryClient';
+import { renderComponent } from 'utils/reactUtils';
import 'elements/emby-button/emby-button';
import 'elements/emby-itemscontainer/emby-itemscontainer';
@@ -220,12 +221,6 @@ function reloadSystemInfo(view, apiClient) {
.then(systemInfo => {
view.querySelector('#serverName').innerText = systemInfo.ServerName;
view.querySelector('#versionNumber').innerText = systemInfo.Version;
-
- view.querySelector('#cachePath').innerText = systemInfo.CachePath;
- view.querySelector('#logPath').innerText = systemInfo.LogPath;
- view.querySelector('#transcodePath').innerText = systemInfo.TranscodingTempPath;
- view.querySelector('#metadataPath').innerText = systemInfo.InternalMetadataPath;
- view.querySelector('#webPath').innerText = systemInfo.WebPath;
});
}
@@ -752,6 +747,9 @@ const DashboardPage = {
};
export default function (view) {
+ const serverId = ApiClient.serverId();
+ let unmountPathsWidget;
+
function onRestartRequired(evt, apiClient) {
console.debug('onRestartRequired not implemented', evt, apiClient);
}
@@ -783,7 +781,6 @@ export default function (view) {
}
}
- const serverId = ApiClient.serverId();
view.querySelector('.activeDevices').addEventListener('click', onActiveDevicesClick);
view.addEventListener('viewshow', function () {
const page = this;
@@ -827,6 +824,8 @@ export default function (view) {
button: page.querySelector('.btnRefresh')
});
+ unmountPathsWidget = renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths'));
+
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
});
@@ -853,6 +852,8 @@ export default function (view) {
button: page.querySelector('.btnRefresh')
});
+ if (unmountPathsWidget) unmountPathsWidget();
+
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
});
diff --git a/src/apps/dashboard/features/storage/api/useSystemStorage.ts b/src/apps/dashboard/features/storage/api/useSystemStorage.ts
new file mode 100644
index 000000000..12db4a171
--- /dev/null
+++ b/src/apps/dashboard/features/storage/api/useSystemStorage.ts
@@ -0,0 +1,28 @@
+import type { Api } from '@jellyfin/sdk';
+import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
+import { queryOptions, useQuery } from '@tanstack/react-query';
+import type { AxiosRequestConfig } from 'axios';
+
+import { useApi } from 'hooks/useApi';
+
+const fetchSystemStorage = async (
+ api: Api,
+ options?: AxiosRequestConfig
+) => {
+ const response = await getSystemApi(api)
+ .getSystemStorage(options);
+ return response.data;
+};
+
+const getSystemStorageQuery = (
+ api?: Api
+) => queryOptions({
+ queryKey: [ 'SystemStorage' ],
+ queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }),
+ enabled: !!api
+});
+
+export const useSystemStorage = () => {
+ const { api } = useApi();
+ return useQuery(getSystemStorageQuery(api));
+};
diff --git a/src/apps/dashboard/features/storage/components/StorageListItem.tsx b/src/apps/dashboard/features/storage/components/StorageListItem.tsx
new file mode 100644
index 000000000..cf8226235
--- /dev/null
+++ b/src/apps/dashboard/features/storage/components/StorageListItem.tsx
@@ -0,0 +1,100 @@
+import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client';
+import LinearProgress from '@mui/material/LinearProgress';
+import ListItem from '@mui/material/ListItem';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
+import Skeleton from '@mui/material/Skeleton';
+import Typography from '@mui/material/Typography';
+import React, { type FC } from 'react';
+
+import globalize from 'lib/globalize';
+import { getReadableSize } from 'utils/file';
+
+import { StorageType } from '../constants/StorageType';
+import { calculateTotal, calculateUsedPercentage } from '../utils/space';
+
+import StorageTypeIcon from './StorageTypeIcon';
+
+interface StorageListItemProps {
+ label: string
+ folder?: FolderStorageDto
+}
+
+const getStatusColor = (percent: number) => {
+ if (percent >= 90) return 'error';
+ if (percent >= 80) return 'warning';
+ return 'success';
+};
+
+const getStorageTypeText = (type?: string | null) => {
+ if (!type) return undefined;
+
+ if (Object.keys(StorageType).includes(type)) {
+ return globalize.translate(`StorageType.${type}`);
+ }
+
+ return type;
+};
+
+const StorageListItem: FC = ({
+ label,
+ folder
+}) => {
+ const readableUsedSpace = (typeof folder?.UsedSpace === 'undefined' || folder.UsedSpace < 0) ?
+ '?' : getReadableSize(folder.UsedSpace);
+ const totalSpace = calculateTotal(folder);
+ const readableTotalSpace = (totalSpace < 0) ? '?' : getReadableSize(totalSpace);
+ const usedPercentage = calculateUsedPercentage(folder);
+ const statusColor = folder ? getStatusColor(usedPercentage) : 'primary';
+
+ return (
+
+
+
+
+
+ {label}
+
+ }
+ secondary={
+ <>
+
+ {folder ? folder.Path : (
+
+ )}
+
+
+
+ {`${readableUsedSpace} / ${readableTotalSpace}`}
+
+ >
+ }
+ slots={{
+ secondary: 'div'
+ }}
+ />
+
+ );
+};
+
+export default StorageListItem;
diff --git a/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx b/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx
new file mode 100644
index 000000000..4b62d455e
--- /dev/null
+++ b/src/apps/dashboard/features/storage/components/StorageTypeIcon.tsx
@@ -0,0 +1,31 @@
+import Album from '@mui/icons-material/Album';
+import Lan from '@mui/icons-material/Lan';
+import Memory from '@mui/icons-material/Memory';
+import Storage from '@mui/icons-material/Storage';
+import Usb from '@mui/icons-material/Usb';
+import React, { type FC } from 'react';
+
+import { StorageType } from '../constants/StorageType';
+
+interface StorageTypeIconProps {
+ type?: string | null
+}
+
+const StorageTypeIcon: FC = ({
+ type
+}) => {
+ switch (type) {
+ case StorageType.CDRom:
+ return ;
+ case StorageType.Network:
+ return ;
+ case StorageType.Ram:
+ return ;
+ case StorageType.Removable:
+ return ;
+ default:
+ return ;
+ }
+};
+
+export default StorageTypeIcon;
diff --git a/src/apps/dashboard/features/storage/constants/StorageType.ts b/src/apps/dashboard/features/storage/constants/StorageType.ts
new file mode 100644
index 000000000..2a0a4d83d
--- /dev/null
+++ b/src/apps/dashboard/features/storage/constants/StorageType.ts
@@ -0,0 +1,13 @@
+/**
+ * Common storage type values returned by the API.
+ * NOTE: This list is not comprehensive as .NET might return any string.
+ */
+export enum StorageType {
+ CDRom = 'CDRom',
+ Fixed = 'Fixed',
+ Network = 'Network',
+ NoRootDirectory = 'NoRootDirectory',
+ Ram = 'Ram',
+ Removable = 'Removable',
+ Unknown = 'Unknown'
+};
diff --git a/src/apps/dashboard/features/storage/utils/space.test.ts b/src/apps/dashboard/features/storage/utils/space.test.ts
new file mode 100644
index 000000000..f234a9fd0
--- /dev/null
+++ b/src/apps/dashboard/features/storage/utils/space.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+
+import { calculateUsedPercentage, calculateTotal } from './space';
+
+describe('calculateTotal()', () => {
+ it('should return the total', () => {
+ expect(calculateTotal({ FreeSpace: 1, UsedSpace: 2 })).toBe(3);
+ });
+
+ it('should return -1 for invalid used space values', () => {
+ expect(calculateTotal({ FreeSpace: 1 })).toBe(-1);
+ expect(calculateTotal({ FreeSpace: 1, UsedSpace: -1 })).toBe(-1);
+ });
+
+ it('should treat invalid free space values as 0', () => {
+ expect(calculateTotal({ UsedSpace: 1 })).toBe(1);
+ expect(calculateTotal({ FreeSpace: -1, UsedSpace: 1 })).toBe(1);
+ });
+});
+
+describe('calculateUsedPercentage', () => {
+ it('should return the percentage used', () => {
+ expect(calculateUsedPercentage({ FreeSpace: 1, UsedSpace: 3 })).toBe(75);
+ });
+
+ it('should return 0 for invalid used space values', () => {
+ expect(calculateUsedPercentage({ FreeSpace: 1 })).toBe(0);
+ expect(calculateUsedPercentage({ FreeSpace: 1, UsedSpace: -1 })).toBe(0);
+ });
+
+ it('should return 100 for invalid free space values', () => {
+ expect(calculateUsedPercentage({ UsedSpace: 1 })).toBe(100);
+ expect(calculateUsedPercentage({ FreeSpace: -1, UsedSpace: 1 })).toBe(100);
+ });
+});
diff --git a/src/apps/dashboard/features/storage/utils/space.ts b/src/apps/dashboard/features/storage/utils/space.ts
new file mode 100644
index 000000000..d18e447ae
--- /dev/null
+++ b/src/apps/dashboard/features/storage/utils/space.ts
@@ -0,0 +1,20 @@
+import type { FolderStorageDto } from '@jellyfin/sdk/lib/generated-client/models/folder-storage-dto';
+
+export const calculateTotal = (folder?: FolderStorageDto) => {
+ if (typeof folder?.UsedSpace === 'undefined' || folder.UsedSpace < 0) {
+ return -1;
+ }
+
+ const freeSpace = Math.max(0, folder.FreeSpace || 0);
+ const usedSpace = Math.max(0, folder.UsedSpace);
+ return freeSpace + usedSpace;
+};
+
+export const calculateUsedPercentage = (folder?: FolderStorageDto) => {
+ const totalSpace = calculateTotal(folder);
+ if (totalSpace <= 0) return 0;
+
+ const usedSpace = folder?.UsedSpace || 0;
+
+ return Math.min(100, (usedSpace / totalSpace) * 100);
+};
diff --git a/src/components/itemMediaInfo/itemMediaInfo.js b/src/components/itemMediaInfo/itemMediaInfo.js
index 70c35e848..7ebe7464a 100644
--- a/src/components/itemMediaInfo/itemMediaInfo.js
+++ b/src/components/itemMediaInfo/itemMediaInfo.js
@@ -5,22 +5,27 @@
*/
import escapeHtml from 'escape-html';
-import dialogHelper from '../dialogHelper/dialogHelper';
-import layoutManager from '../layoutManager';
-import toast from '../toast/toast';
-import { copy } from '../../scripts/clipboard';
-import dom from '../../scripts/dom';
-import globalize from '../../lib/globalize';
+
+import dialogHelper from 'components/dialogHelper/dialogHelper';
+import itemHelper from 'components/itemHelper';
+import layoutManager from 'components/layoutManager';
+import loading from 'components/loading/loading';
+import toast from 'components/toast/toast';
+import globalize from 'lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
-import itemHelper from '../../components/itemHelper';
-import loading from '../loading/loading';
-import '../../elements/emby-select/emby-select';
-import '../listview/listview.scss';
-import '../../elements/emby-button/emby-button';
-import '../../elements/emby-button/paper-icon-button-light';
-import '../formdialog.scss';
+import { copy } from 'scripts/clipboard';
+import dom from 'scripts/dom';
+import { getReadableSize } from 'utils/file';
+
+import 'components/formdialog.scss';
+import 'components/listview/listview.scss';
+import 'elements/emby-button/emby-button';
+import 'elements/emby-button/paper-icon-button-light';
+import 'elements/emby-select/emby-select';
import 'material-design-icons-iconfont';
-import '../../styles/flexstyles.scss';
+
+import 'styles/flexstyles.scss';
+
import template from './itemMediaInfo.template.html';
// Do not add extra spaces between tags - they will be copied into the result
@@ -68,7 +73,7 @@ function getMediaSourceHtml(user, item, version) {
html += `${createAttribute(globalize.translate('MediaInfoPath'), version.Path, true)}
`;
}
if (version.Size) {
- const size = `${(version.Size / (1024 * 1024)).toFixed(0)} MB`;
+ const size = getReadableSize(version.Size);
html += `${createAttribute(globalize.translate('MediaInfoSize'), size)}
`;
}
version.MediaStreams.sort(itemHelper.sortTracks);
diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js
index 97d4a7afd..804c97e9c 100644
--- a/src/components/playerstats/playerstats.js
+++ b/src/components/playerstats/playerstats.js
@@ -1,12 +1,15 @@
-import '../../elements/emby-button/paper-icon-button-light';
-import globalize from '../../lib/globalize';
+import globalize from 'lib/globalize';
import { ServerConnections } from 'lib/jellyfin-apiclient';
-import Events from '../../utils/events.ts';
+import { PluginType } from 'types/plugin';
+import Events from 'utils/events';
+import { getReadableSize } from 'utils/file';
+
import layoutManager from '../layoutManager';
import { playbackManager } from '../playback/playbackmanager';
import playMethodHelper from '../playback/playmethodhelper';
import { pluginManager } from '../pluginManager';
-import { PluginType } from '../../types/plugin.ts';
+
+import 'elements/emby-button/paper-icon-button-light';
import './playerstats.scss';
@@ -211,16 +214,6 @@ function getDisplayTranscodeFps(session, player) {
return `${transcodeFramerate} fps (${(transcodeFramerate / originalFramerate).toFixed(2)}x)`;
}
-function getReadableSize(size) {
- if (size >= 1073741824) {
- return parseFloat((size / 1073741824).toFixed(1)) + ' GiB';
- } else if (size >= 1048576) {
- return parseFloat((size / 1048576).toFixed(1)) + ' MiB';
- } else {
- return Math.floor(size / 1024) + ' KiB';
- }
-}
-
function getMediaSourceStats(session, player) {
const sessionStats = [];
diff --git a/src/strings/en-us.json b/src/strings/en-us.json
index 76e4d8c4b..0add9274f 100644
--- a/src/strings/en-us.json
+++ b/src/strings/en-us.json
@@ -749,6 +749,7 @@
"LabelHomeScreenSectionValue": "Home screen section {0}",
"LabelHttpsPort": "Local HTTPS port number",
"LabelHttpsPortHelp": "The TCP port number for the HTTPS server.",
+ "LabelImageCache": "Image Cache",
"LabelImageFetchersHelp": "Enable and rank your preferred image fetchers in order of priority.",
"LabelImageType": "Image type",
"LabelImportOnlyFavoriteChannels": "Restrict to channels marked as favorite",
@@ -862,6 +863,7 @@
"LabelPreferredDisplayLanguage": "Preferred display language",
"LabelPreferredSubtitleLanguage": "Preferred subtitle language",
"LabelProfileContainer": "Container",
+ "LabelProgramData": "Program Data",
"LabelProtocol": "Protocol",
"LabelPublicHttpPort": "Public HTTP port number",
"LabelPublicHttpPortHelp": "The public port number that should be mapped to the local HTTP port.",
@@ -1558,6 +1560,13 @@
"StoryArc": "Story Arc",
"StopPlayback": "Stop playback",
"StopRecording": "Stop recording",
+ "StorageType.CDRom": "CD-ROM",
+ "StorageType.Fixed": "Fixed",
+ "StorageType.Network": "Network",
+ "StorageType.NoRootDirectory": "No Root Directory",
+ "StorageType.Ram": "RAM",
+ "StorageType.Removable": "Removable",
+ "StorageType.Unknown": "Unknown",
"StreamCountExceedsLimit": "The number of streams exceeds the limit",
"Studio": "Studio",
"Studios": "Studios",
diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts
new file mode 100644
index 000000000..1f23d4b96
--- /dev/null
+++ b/src/utils/file.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { getReadableSize } from './file';
+
+describe('getReadableSize()', () => {
+ it('should return the correct units', () => {
+ expect(getReadableSize(5)).toBe('5.0 Bytes');
+ expect(getReadableSize(1024)).toBe('1.0 KiB');
+ expect(getReadableSize(1024 * 1024)).toBe('1.0 MiB');
+ expect(getReadableSize(1024 * 1024 * 1024)).toBe('1.0 GiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 PiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 EiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 ZiB');
+ expect(getReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('1.0 YiB');
+ });
+
+ it('should return the correct precision', () => {
+ expect(getReadableSize(12345, 0)).toBe('12 KiB');
+ expect(getReadableSize(12345, 2)).toBe('12.06 KiB');
+ expect(getReadableSize(12345, 3)).toBe('12.056 KiB');
+ });
+});
diff --git a/src/utils/file.ts b/src/utils/file.ts
index 0019a5068..b82df8738 100644
--- a/src/utils/file.ts
+++ b/src/utils/file.ts
@@ -28,3 +28,11 @@ export function readFileAsText(file: File): Promise {
reader.readAsText(file);
});
}
+
+/** Gets a human readable string representing a file size in bytes */
+export function getReadableSize(value: number, precision = 1) {
+ let d = Math.log(value) / Math.log(1024) | 0;
+
+ return (value / Math.pow(1024, d)).toFixed(precision)
+ + ' ' + (d ? 'KMGTPEZY'[--d] + 'iB' : 'Bytes');
+}
diff --git a/src/utils/reactUtils.tsx b/src/utils/reactUtils.tsx
new file mode 100644
index 000000000..80abdd512
--- /dev/null
+++ b/src/utils/reactUtils.tsx
@@ -0,0 +1,43 @@
+import { ThemeProvider } from '@mui/material/styles';
+import { QueryClientProvider } from '@tanstack/react-query';
+import * as React from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { ApiProvider } from 'hooks/useApi';
+import { UserSettingsProvider } from 'hooks/useUserSettings';
+import { WebConfigProvider } from 'hooks/useWebConfig';
+import appTheme from 'themes/themes';
+import { queryClient } from 'utils/query/queryClient';
+
+export const renderComponent = (
+ Component: React.FC
,
+ props: P,
+ element: HTMLElement
+) => {
+ const root = createRoot(element);
+ root.render(
+
+
+
+ );
+
+ // NOTE: We need to wrap the unmount in a setTimeout to workaround this issue with nested roots:
+ // https://github.com/facebook/react/issues/25675
+ return () => setTimeout(() => root.unmount());
+};
+
+const RootContext: React.FC = ({ children }) => {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+};