Merge pull request #6918 from thornbill/storage-metrics
Add storage metric display to dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Button
|
||||
variant='text'
|
||||
color='inherit'
|
||||
endIcon={<ChevronRight />}
|
||||
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'
|
||||
>
|
||||
<Typography variant='h3' component='span'>
|
||||
{globalize.translate('HeaderPaths')}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelCache')}
|
||||
folder={systemStorage?.CacheFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelImageCache')}
|
||||
folder={systemStorage?.ImageCacheFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelProgramData')}
|
||||
folder={systemStorage?.ProgramDataFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelLogs')}
|
||||
folder={systemStorage?.LogFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelMetadata')}
|
||||
folder={systemStorage?.InternalMetadataFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelTranscodes')}
|
||||
folder={systemStorage?.TranscodingTempFolder}
|
||||
/>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelWeb')}
|
||||
folder={systemStorage?.WebFolder}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPathWidget;
|
||||
@@ -75,44 +75,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="#/dashboard/settings" class="button-flat sectionTitleTextButton">
|
||||
<h3>${HeaderPaths}</h3>
|
||||
<span class="material-icons chevron_right" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div class="paperList">
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelCache}</div>
|
||||
<div class="listItemBodyText" id="cachePath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelLogs}</div>
|
||||
<div class="listItemBodyText" id="logPath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelMetadata}</div>
|
||||
<div class="listItemBodyText" id="metadataPath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelTranscodes}</div>
|
||||
<div class="listItemBodyText" id="transcodePath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="listItem listItem-border">
|
||||
<div class="listItemBody two-line">
|
||||
<div class="listItemBodyText secondary" style="margin:0;">${LabelWeb}</div>
|
||||
<div class="listItemBodyText" id="webPath" dir="ltr" style="text-align: left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="serverPaths" class="dashboardSection"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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<StorageListItemProps> = ({
|
||||
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 (
|
||||
<ListItem>
|
||||
<ListItemIcon title={getStorageTypeText(folder?.StorageType)}>
|
||||
<StorageTypeIcon type={folder?.StorageType} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography
|
||||
component='span'
|
||||
variant='body2'
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography
|
||||
color='textPrimary'
|
||||
sx={{
|
||||
paddingBottom: 0.5
|
||||
}}
|
||||
>
|
||||
{folder ? folder.Path : (
|
||||
<Skeleton />
|
||||
)}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant={folder ? 'determinate' : 'indeterminate'}
|
||||
color={statusColor}
|
||||
value={usedPercentage}
|
||||
/>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='textSecondary'
|
||||
sx={{
|
||||
textAlign: 'end'
|
||||
}}
|
||||
>
|
||||
{`${readableUsedSpace} / ${readableTotalSpace}`}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
slots={{
|
||||
secondary: 'div'
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageListItem;
|
||||
@@ -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<StorageTypeIconProps> = ({
|
||||
type
|
||||
}) => {
|
||||
switch (type) {
|
||||
case StorageType.CDRom:
|
||||
return <Album />;
|
||||
case StorageType.Network:
|
||||
return <Lan />;
|
||||
case StorageType.Ram:
|
||||
return <Memory />;
|
||||
case StorageType.Removable:
|
||||
return <Usb />;
|
||||
default:
|
||||
return <Storage />;
|
||||
}
|
||||
};
|
||||
|
||||
export default StorageTypeIcon;
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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)}<br/>`;
|
||||
}
|
||||
if (version.Size) {
|
||||
const size = `${(version.Size / (1024 * 1024)).toFixed(0)} MB`;
|
||||
const size = getReadableSize(version.Size);
|
||||
html += `${createAttribute(globalize.translate('MediaInfoSize'), size)}<br/>`;
|
||||
}
|
||||
version.MediaStreams.sort(itemHelper.sortTracks);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -28,3 +28,11 @@ export function readFileAsText(file: File): Promise<string> {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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 = <P extends object> (
|
||||
Component: React.FC<P>,
|
||||
props: P,
|
||||
element: HTMLElement
|
||||
) => {
|
||||
const root = createRoot(element);
|
||||
root.render(
|
||||
<RootContext>
|
||||
<Component {...props} />
|
||||
</RootContext>
|
||||
);
|
||||
|
||||
// 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<React.PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiProvider>
|
||||
<UserSettingsProvider>
|
||||
<WebConfigProvider>
|
||||
<ThemeProvider theme={appTheme} defaultMode='dark'>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</WebConfigProvider>
|
||||
</UserSettingsProvider>
|
||||
</ApiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user