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 ( + <> + + + + + + + + + + + + + ); +}; + +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 @@ -
- -

${HeaderPaths}

- -
-
-
-
-
${LabelCache}
-
-
-
-
-
-
${LabelLogs}
-
-
-
-
-
-
${LabelMetadata}
-
-
-
-
-
-
${LabelTranscodes}
-
-
-
-
-
-
${LabelWeb}
-
-
-
-
-
+
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} + + + + + + ); +};