1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Redesign add plugins page

This commit is contained in:
Bill Thornton 2024-03-17 04:08:53 -04:00
parent 5936ed10ca
commit e928a2ff95
26 changed files with 1022 additions and 241 deletions

31
package-lock.json generated
View file

@ -71,6 +71,7 @@
"@babel/plugin-transform-modules-umd": "7.24.7", "@babel/plugin-transform-modules-umd": "7.24.7",
"@babel/preset-env": "7.24.7", "@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7", "@babel/preset-react": "7.24.7",
"@types/dompurify": "3.0.5",
"@types/escape-html": "1.0.4", "@types/escape-html": "1.0.4",
"@types/loadable__component": "5.13.9", "@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
@ -4872,6 +4873,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/escape-html": { "node_modules/@types/escape-html": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz",
@ -5212,6 +5222,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz",
@ -27245,6 +27261,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"requires": {
"@types/trusted-types": "*"
}
},
"@types/escape-html": { "@types/escape-html": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz",
@ -27569,6 +27594,12 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"dev": true "dev": true
}, },
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
},
"@types/unist": { "@types/unist": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz",

View file

@ -11,6 +11,7 @@
"@babel/plugin-transform-modules-umd": "7.24.7", "@babel/plugin-transform-modules-umd": "7.24.7",
"@babel/preset-env": "7.24.7", "@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7", "@babel/preset-react": "7.24.7",
"@types/dompurify": "3.0.5",
"@types/escape-html": "1.0.4", "@types/escape-html": "1.0.4",
"@types/loadable__component": "5.13.9", "@types/loadable__component": "5.13.9",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",

View file

@ -17,18 +17,14 @@ import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink'; import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize'; import globalize from 'scripts/globalize';
const PLUGIN_PATHS = [ const isPluginPath = (path: string) => (
'/dashboard/plugins', path.startsWith('/dashboard/plugins')
'/dashboard/plugins/catalog', || path === '/configurationpage'
'/dashboard/plugins/repositories', );
'/dashboard/plugins/add',
'/configurationpage'
];
const AdvancedDrawerSection = () => { const AdvancedDrawerSection = () => {
const location = useLocation(); const location = useLocation();
const isPluginSectionOpen = isPluginPath(location.pathname);
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
return ( return (
<List <List

View file

@ -0,0 +1,25 @@
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
/**
* HACK: The Plugins API is returning garbage data in some cases,
* so we need to try to find the "best" match if multiple exist.
*/
export const findBestPluginInfo = (
pluginId: string,
plugins?: PluginInfo[]
) => {
if (!plugins) return;
// Find all plugin entries with a matching ID
const matches = plugins.filter(p => p.Id === pluginId);
// Get the first match (or undefined if none)
const firstMatch = matches?.[0];
if (matches.length > 1) {
return matches.find(p => p.Status === PluginStatus.Disabled) // Disabled entries take priority
|| matches.find(p => p.Status === PluginStatus.Restart) // Then entries specifying restart is needed
|| firstMatch; // Fallback to the first match
}
return firstMatch;
};

View file

@ -0,0 +1,5 @@
export enum QueryKey {
ConfigurationPages = 'ConfigurationPages',
PackageInfo = 'PackageInfo',
Plugins = 'Plugins'
}

View file

@ -0,0 +1,40 @@
import type { Api } from '@jellyfin/sdk';
import type { DashboardApiGetConfigurationPagesRequest } from '@jellyfin/sdk/lib/generated-client/api/dashboard-api';
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
const fetchConfigurationPages = async (
api?: Api,
params?: DashboardApiGetConfigurationPagesRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchConfigurationPages] No API instance available');
return [];
}
const response = await getDashboardApi(api)
.getConfigurationPages(params, options);
return response.data;
};
const getConfigurationPagesQuery = (
api?: Api,
params?: DashboardApiGetConfigurationPagesRequest
) => queryOptions({
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
enabled: !!api
});
export const useConfigurationPages = (
params?: DashboardApiGetConfigurationPagesRequest
) => {
const { api } = useApi();
return useQuery(getConfigurationPagesQuery(api, params));
};

View file

@ -0,0 +1,24 @@
import type { PluginsApiDisablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useDisablePlugin = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PluginsApiDisablePluginRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPluginsApi(api!)
.disablePlugin(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
}
});
};

View file

@ -0,0 +1,24 @@
import type { PluginsApiEnablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useEnablePlugin = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PluginsApiEnablePluginRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPluginsApi(api!)
.enablePlugin(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
}
});
};

View file

@ -0,0 +1,27 @@
import type { PackageApiInstallPackageRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useInstallPackage = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PackageApiInstallPackageRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPackageApi(api!)
.installPackage(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.ConfigurationPages ]
});
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
}
});
};

View file

@ -0,0 +1,47 @@
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { Api } from '@jellyfin/sdk';
import type { PackageApiGetPackageInfoRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
const fetchPackageInfo = async (
api?: Api,
params?: PackageApiGetPackageInfoRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchPackageInfo] No API instance available');
return;
}
if (!params) {
console.warn('[fetchPackageInfo] Missing request params');
return;
}
const response = await getPackageApi(api)
.getPackageInfo(params, options);
return response.data;
};
const getPackageInfoQuery = (
api?: Api,
params?: PackageApiGetPackageInfoRequest
) => queryOptions({
// Don't retry since requests for plugins not available in repos fail
retry: false,
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
enabled: !!api && !!params?.name
});
export const usePackageInfo = (
params?: PackageApiGetPackageInfoRequest
) => {
const { api } = useApi();
return useQuery(getPackageInfoQuery(api, params));
};

View file

@ -0,0 +1,36 @@
import type { Api } from '@jellyfin/sdk';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
const fetchPlugins = async (
api?: Api,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchPlugins] No API instance available');
return [];
}
const response = await getPluginsApi(api)
.getPlugins(options);
return response.data;
};
const getPluginsQuery = (
api?: Api
) => queryOptions({
queryKey: [ QueryKey.Plugins ],
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
enabled: !!api
});
export const usePlugins = () => {
const { api } = useApi();
return useQuery(getPluginsQuery(api));
};

View file

@ -0,0 +1,27 @@
import type { PluginsApiUninstallPluginByVersionRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useUninstallPlugin = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPluginsApi(api!)
.uninstallPluginByVersion(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
void queryClient.invalidateQueries({
queryKey: [ QueryKey.ConfigurationPages ]
});
}
});
};

View file

@ -0,0 +1,94 @@
import Link from '@mui/material/Link/Link';
import Paper, { type PaperProps } from '@mui/material/Paper/Paper';
import Skeleton from '@mui/material/Skeleton/Skeleton';
import Table from '@mui/material/Table/Table';
import TableBody from '@mui/material/TableBody/TableBody';
import TableCell from '@mui/material/TableCell/TableCell';
import TableContainer from '@mui/material/TableContainer/TableContainer';
import TableRow from '@mui/material/TableRow/TableRow';
import React, { FC } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import globalize from 'scripts/globalize';
import type { PluginDetails } from '../types/PluginDetails';
interface PluginDetailsTableProps extends PaperProps {
isPluginLoading: boolean
isRepositoryLoading: boolean
pluginDetails?: PluginDetails
}
const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
isPluginLoading,
isRepositoryLoading,
pluginDetails,
...paperProps
}) => (
<TableContainer component={Paper} {...paperProps}>
<Table>
<TableBody>
<TableRow>
<TableCell variant='head'>
{globalize.translate('LabelStatus')}
</TableCell>
<TableCell>
{
(isPluginLoading && <Skeleton />)
|| pluginDetails?.status
|| globalize.translate('LabelNotInstalled')
}
</TableCell>
</TableRow>
<TableRow>
<TableCell variant='head'>
{globalize.translate('LabelVersion')}
</TableCell>
<TableCell>
{
(isPluginLoading || !pluginDetails?.version?.version && <Skeleton />)
|| pluginDetails?.version?.version
}
</TableCell>
</TableRow>
<TableRow>
<TableCell variant='head'>
{globalize.translate('LabelDeveloper')}
</TableCell>
<TableCell>
{
(isRepositoryLoading && <Skeleton />)
|| pluginDetails?.owner
|| globalize.translate('Unknown')
}
</TableCell>
</TableRow>
<TableRow
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell variant='head'>
{globalize.translate('LabelRepository')}
</TableCell>
<TableCell>
{
(isRepositoryLoading && <Skeleton />)
|| (pluginDetails?.version?.repositoryUrl && (
<Link
component={RouterLink}
to={pluginDetails.version.repositoryUrl}
target='_blank'
rel='noopener noreferrer'
>
{pluginDetails.version.repositoryName}
</Link>
))
|| globalize.translate('Unknown')
}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
);
export default PluginDetailsTable;

View file

@ -0,0 +1,34 @@
import Paper from '@mui/material/Paper/Paper';
import Skeleton from '@mui/material/Skeleton/Skeleton';
import React, { type FC } from 'react';
interface PluginImageProps {
isLoading: boolean
alt?: string
url?: string
}
const PluginImage: FC<PluginImageProps> = ({
isLoading,
alt,
url
}) => (
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
{isLoading && (
<Skeleton
variant='rectangular'
width='100%'
height='100%'
/>
)}
{url && (
<img
src={url}
alt={alt}
width='100%'
/>
)}
</Paper>
);
export default PluginImage;

View file

@ -0,0 +1,67 @@
import Download from '@mui/icons-material/Download';
import DownloadDone from '@mui/icons-material/DownloadDone';
import ExpandMore from '@mui/icons-material/ExpandMore';
import Accordion from '@mui/material/Accordion/Accordion';
import AccordionDetails from '@mui/material/AccordionDetails/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary/AccordionSummary';
import Button from '@mui/material/Button/Button';
import Stack from '@mui/material/Stack/Stack';
import React, { type FC } from 'react';
import MarkdownBox from 'components/MarkdownBox';
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
import globalize from 'scripts/globalize';
import type { PluginDetails } from '../types/PluginDetails';
import { VersionInfo } from '@jellyfin/sdk/lib/generated-client';
interface PluginRevisionsProps {
pluginDetails?: PluginDetails,
onInstall: (version?: VersionInfo) => () => void
}
const PluginRevisions: FC<PluginRevisionsProps> = ({
pluginDetails,
onInstall
}) => (
pluginDetails?.versions?.map(version => (
<Accordion key={version.checksum}>
<AccordionSummary
expandIcon={<ExpandMore />}
>
{version.version}
{version.timestamp && (<>
&nbsp;&mdash;&nbsp;
{toLocaleString(parseISO8601Date(version.timestamp))}
</>)}
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<MarkdownBox
fallback={globalize.translate('LabelNoChangelog')}
markdown={version.changelog}
/>
{pluginDetails.status && version.version === pluginDetails.version?.version ? (
<Button
disabled
startIcon={<DownloadDone />}
variant='outlined'
>
{globalize.translate('LabelInstalled')}
</Button>
) : (
<Button
startIcon={<Download />}
variant='outlined'
onClick={onInstall(version)}
>
{globalize.translate('HeaderInstall')}
</Button>
)}
</Stack>
</AccordionDetails>
</Accordion>
))
);
export default PluginRevisions;

View file

@ -0,0 +1,15 @@
import type { PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';
export interface PluginDetails {
canUninstall: boolean
description?: string
hasConfiguration: boolean
id: string
imageUrl?: string
isEnabled: boolean
name?: string
owner?: string
status?: PluginStatus
version?: VersionInfo
versions: VersionInfo[]
}

View file

@ -2,11 +2,12 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity', type: AsyncRouteType.Dashboard }, { path: 'activity', type: AsyncRouteType.Dashboard },
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
{ path: 'users', type: AsyncRouteType.Dashboard }, { path: 'users', type: AsyncRouteType.Dashboard },
{ path: 'users/access', type: AsyncRouteType.Dashboard }, { path: 'users/access', type: AsyncRouteType.Dashboard },
{ path: 'users/add', type: AsyncRouteType.Dashboard }, { path: 'users/add', type: AsyncRouteType.Dashboard },
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard }, { path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
{ path: 'users/password', type: AsyncRouteType.Dashboard }, { path: 'users/password', type: AsyncRouteType.Dashboard },
{ path: 'users/profile', type: AsyncRouteType.Dashboard }, { path: 'users/profile', type: AsyncRouteType.Dashboard }
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
]; ];

View file

@ -31,12 +31,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'dashboard/devices/device', controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html' view: 'dashboard/devices/device.html'
} }
}, {
path: 'plugins/add',
pageProps: {
controller: 'dashboard/plugins/add/index',
view: 'dashboard/plugins/add/index.html'
}
}, { }, {
path: 'libraries', path: 'libraries',
pageProps: { pageProps: {

View file

@ -1,7 +1,6 @@
import type { Redirect } from 'components/router/Redirect'; import type { Redirect } from 'components/router/Redirect';
export const REDIRECTS: Redirect[] = [ export const REDIRECTS: Redirect[] = [
{ from: 'addplugin.html', to: '/dashboard/plugins/add' },
{ from: 'apikeys.html', to: '/dashboard/keys' }, { from: 'apikeys.html', to: '/dashboard/keys' },
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' }, { from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
{ from: 'dashboard.html', to: '/dashboard' }, { from: 'dashboard.html', to: '/dashboard' },

View file

@ -0,0 +1,417 @@
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
import type { VersionInfo } from '@jellyfin/sdk/lib/generated-client/models/version-info';
import Alert from '@mui/material/Alert/Alert';
import Button from '@mui/material/Button/Button';
import Container from '@mui/material/Container/Container';
import FormControlLabel from '@mui/material/FormControlLabel/FormControlLabel';
import FormGroup from '@mui/material/FormGroup/FormGroup';
import Grid from '@mui/material/Grid/Grid';
import Skeleton from '@mui/material/Skeleton/Skeleton';
import Stack from '@mui/material/Stack/Stack';
import Switch from '@mui/material/Switch/Switch';
import Typography from '@mui/material/Typography/Typography';
import Delete from '@mui/icons-material/Delete';
import Download from '@mui/icons-material/Download';
import Settings from '@mui/icons-material/Settings';
import React, { type FC, useEffect, useState, useCallback, useMemo } from 'react';
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
import { findBestPluginInfo } from 'apps/dashboard/features/plugins/api/pluginInfo';
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
import { useDisablePlugin } from 'apps/dashboard/features/plugins/api/useDisablePlugin';
import { useEnablePlugin } from 'apps/dashboard/features/plugins/api/useEnablePlugin';
import { useInstallPackage } from 'apps/dashboard/features/plugins/api/useInstallPackage';
import { usePackageInfo } from 'apps/dashboard/features/plugins/api/usePackageInfo';
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
import { useUninstallPlugin } from 'apps/dashboard/features/plugins/api/useUninstallPlugin';
import PluginImage from 'apps/dashboard/features/plugins/components/PluginImage';
import PluginDetailsTable from 'apps/dashboard/features/plugins/components/PluginDetailsTable';
import PluginRevisions from 'apps/dashboard/features/plugins/components/PluginRevisions';
import type { PluginDetails } from 'apps/dashboard/features/plugins/types/PluginDetails';
import ConfirmDialog from 'components/ConfirmDialog';
import Page from 'components/Page';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import { getPluginUrl } from 'utils/dashboard';
interface AlertMessage {
severity?: 'success' | 'info' | 'warning' | 'error'
messageKey: string
}
// Plugins from this url will be trusted and not prompt for confirmation when installing
const TRUSTED_REPO_URL = 'https://repo.jellyfin.org/';
const PluginPage: FC = () => {
const { api } = useApi();
const { pluginId } = useParams();
const [ searchParams ] = useSearchParams();
const disablePlugin = useDisablePlugin();
const enablePlugin = useEnablePlugin();
const installPlugin = useInstallPackage();
const uninstallPlugin = useUninstallPlugin();
const [ isLoading, setIsLoading ] = useState(true);
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
const [ pluginDetails, setPluginDetails ] = useState<PluginDetails>();
const [ pluginName, setPluginName ] = useState<string>();
const {
data: configurationPages,
isError: isConfigurationPagesError,
isLoading: isConfigurationPagesLoading
} = useConfigurationPages();
const {
data: packageInfo,
isError: isPackageInfoError,
isLoading: isPackageInfoLoading
} = usePackageInfo(pluginName ? {
name: pluginName,
assemblyGuid: pluginId
} : undefined);
const {
data: plugins,
isLoading: isPluginsLoading,
isError: isPluginsError
} = usePlugins();
const alertMessages = useMemo(() => {
const alerts: AlertMessage[] = [];
if (disablePlugin.isError) {
alerts.push({ messageKey: 'PluginDisableError' });
}
if (enablePlugin.isError) {
alerts.push({ messageKey: 'PluginEnableError' });
}
if (installPlugin.isSuccess) {
alerts.push({
severity: 'success',
messageKey: 'MessagePluginInstalled'
});
}
if (installPlugin.isError) {
alerts.push({ messageKey: 'MessagePluginInstallError' });
}
if (uninstallPlugin.isError) {
alerts.push({ messageKey: 'PluginUninstallError' });
}
if (isConfigurationPagesError) {
alerts.push({ messageKey: 'PluginLoadConfigError' });
}
if (isPackageInfoError) {
alerts.push({
severity: 'warning',
messageKey: 'PluginLoadRepoError'
});
}
if (isPluginsError) {
alerts.push({ messageKey: 'MessageGetInstalledPluginsError' });
}
return alerts;
}, [
disablePlugin.isError,
enablePlugin.isError,
installPlugin.isError,
installPlugin.isSuccess,
isConfigurationPagesError,
isPackageInfoError,
isPluginsError,
uninstallPlugin.isError
]);
/** Enable/disable the plugin */
const toggleEnabled = useCallback(() => {
if (!pluginDetails?.version?.version) return;
console.debug('[PluginPage] %s plugin', pluginDetails.isEnabled ? 'disabling' : 'enabling', pluginDetails);
if (pluginDetails.isEnabled) {
disablePlugin.mutate({
pluginId: pluginDetails.id,
version: pluginDetails.version.version
}, {
onSuccess: () => {
setIsEnabledOverride(false);
}
});
} else {
enablePlugin.mutate({
pluginId: pluginDetails.id,
version: pluginDetails.version.version
}, {
onSuccess: () => {
setIsEnabledOverride(true);
}
});
}
}, [ disablePlugin, enablePlugin, pluginDetails ]);
/** Install the plugin or prompt for confirmation if untrusted */
const onInstall = useCallback((version?: VersionInfo, isConfirmed = false) => () => {
if (!pluginDetails?.name) return;
const installVersion = version || pluginDetails.version;
if (!installVersion) return;
if (!isConfirmed && !installVersion.repositoryUrl?.startsWith(TRUSTED_REPO_URL)) {
console.debug('[PluginPage] plugin install needs confirmed', installVersion);
setPendingInstallVersion(installVersion);
setIsInstallConfirmOpen(true);
return;
}
console.debug('[PluginPage] installing plugin', installVersion);
installPlugin.mutate({
name: pluginDetails.name,
assemblyGuid: pluginDetails.id,
version: installVersion.version,
repositoryUrl: installVersion.repositoryUrl
}, {
onSettled: () => {
setPendingInstallVersion(undefined);
}
});
}, [ installPlugin, pluginDetails ]);
/** Confirm and install the plugin */
const onConfirmInstall = useCallback(() => {
console.debug('[PluginPage] confirmed installing plugin', pendingInstallVersion);
setIsInstallConfirmOpen(false);
onInstall(pendingInstallVersion, true)();
}, [ onInstall, pendingInstallVersion ]);
/** Close the install confirmation dialog */
const onCloseInstallConfirmDialog = useCallback(() => {
setPendingInstallVersion(undefined);
setIsInstallConfirmOpen(false);
}, []);
/** Show the uninstall confirmation dialog */
const onConfirmUninstall = useCallback(() => {
setIsUninstallConfirmOpen(true);
}, []);
/** Uninstall the plugin */
const onUninstall = useCallback(() => {
if (!pluginDetails?.version?.version) return;
console.debug('[PluginPage] uninstalling plugin', pluginDetails);
setIsUninstallConfirmOpen(false);
uninstallPlugin.mutate({
pluginId: pluginDetails.id,
version: pluginDetails.version.version
});
}, [ pluginDetails, uninstallPlugin ]);
/** Close the uninstall confirmation dialog */
const onCloseUninstallConfirmDialog = useCallback(() => {
setIsUninstallConfirmOpen(false);
}, []);
useEffect(() => {
setIsLoading(isConfigurationPagesLoading || isPackageInfoLoading || isPluginsLoading);
}, [ isConfigurationPagesLoading, isPackageInfoLoading, isPluginsLoading ]);
useEffect(() => {
setPluginName(searchParams.get('name') ?? undefined);
}, [ searchParams ]);
useEffect(() => {
if (pluginId && !isPluginsLoading) {
const pluginInfo = findBestPluginInfo(pluginId, plugins);
let version;
if (pluginInfo) {
// Find the installed version
const repoVersion = packageInfo?.versions?.find(v => v.version === pluginInfo.Version);
version = repoVersion || {
version: pluginInfo.Version,
VersionNumber: pluginInfo.Version
};
} else {
// Use the latest version
version = packageInfo?.versions?.[0];
}
let imageUrl;
if (pluginInfo?.HasImage) {
imageUrl = api?.axiosInstance.getUri({
baseURL: api.basePath,
url: `/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`
});
}
setPluginDetails({
canUninstall: !!pluginInfo?.CanUninstall,
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
hasConfiguration: !!configurationPages?.some(page => page.PluginId === pluginId),
id: pluginId,
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
?? pluginInfo?.Status !== PluginStatus.Disabled,
name: pluginName || pluginInfo?.Name || packageInfo?.name,
owner: packageInfo?.owner,
status: pluginInfo?.Status,
version,
versions: packageInfo?.versions || []
});
}
}, [ api, configurationPages, isEnabledOverride, isPluginsLoading, pluginId, packageInfo, pluginName, plugins ]);
return (
<Page
id='addPluginPage'
className='mainAnimatedPage type-interior'
>
<Container className='content-primary'>
{alertMessages.map(({ severity = 'error', messageKey }) => (
<Alert key={messageKey} severity={severity}>
{globalize.translate(messageKey)}
</Alert>
))}
<Grid container spacing={2} sx={{ marginTop: 0 }}>
<Grid item xs={12} lg={8}>
<Stack spacing={2}>
<Typography variant='h1'>
{pluginDetails?.name || pluginName}
</Typography>
<Typography sx={{ maxWidth: '80ch' }}>
{isLoading && !pluginDetails?.description ? (
<Skeleton />
) : (
pluginDetails?.description
)}
</Typography>
</Stack>
</Grid>
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
<PluginImage
isLoading={isLoading}
alt={pluginDetails?.name}
url={pluginDetails?.imageUrl}
/>
</Grid>
<Grid item xs={12} lg={8} sx={{ order: { xs: 1, lg: 'initial' } }}>
{!!pluginDetails?.versions.length && (
<>
<Typography variant='h3' sx={{ marginBottom: 2 }}>
{globalize.translate('HeaderRevisionHistory')}
</Typography>
<PluginRevisions
pluginDetails={pluginDetails}
onInstall={onInstall}
/>
</>
)}
</Grid>
<Grid item xs={12} lg={4}>
<Stack spacing={2} direction={{ xs: 'column', sm: 'row-reverse', lg: 'column' }}>
<Stack spacing={1} sx={{ flexBasis: '50%' }}>
{!isLoading && !pluginDetails?.status && (
<>
<Alert severity='info'>
{globalize.translate('ServerRestartNeededAfterPluginInstall')}
</Alert>
<Button
startIcon={<Download />}
onClick={onInstall()}
>
{globalize.translate('HeaderInstall')}
</Button>
</>
)}
{!isLoading && pluginDetails?.canUninstall && (
<FormGroup>
<FormControlLabel
control={
<Switch
checked={pluginDetails.isEnabled}
onChange={toggleEnabled}
disabled={pluginDetails.status === PluginStatus.Restart}
/>
}
label={globalize.translate('LabelEnablePlugin')}
/>
</FormGroup>
)}
{!isLoading && pluginDetails?.hasConfiguration && (
<Button
component={RouterLink}
to={`/${getPluginUrl(pluginName)}`}
startIcon={<Settings />}
>
{globalize.translate('Settings')}
</Button>
)}
{!isLoading && pluginDetails?.canUninstall && (
<Button
color='error'
startIcon={<Delete />}
onClick={onConfirmUninstall}
>
{globalize.translate('ButtonUninstall')}
</Button>
)}
</Stack>
<PluginDetailsTable
isPluginLoading={isPluginsLoading}
isRepositoryLoading={isPackageInfoLoading}
pluginDetails={pluginDetails}
sx={{ flexBasis: '50%' }}
/>
</Stack>
</Grid>
</Grid>
</Container>
<ConfirmDialog
open={isInstallConfirmOpen}
title={globalize.translate('HeaderConfirmPluginInstallation')}
text={globalize.translate('MessagePluginInstallDisclaimer')}
onCancel={onCloseInstallConfirmDialog}
onConfirm={onConfirmInstall}
confirmButtonText={globalize.translate('HeaderInstall')}
/>
<ConfirmDialog
open={isUninstallConfirmOpen}
title={globalize.translate('HeaderUninstallPlugin')}
text={globalize.translate('UninstallPluginConfirmation', pluginName || '')}
onCancel={onCloseUninstallConfirmDialog}
onConfirm={onUninstall}
confirmButtonColor='error'
confirmButtonText={globalize.translate('ButtonUninstall')}
/>
</Page>
);
};
export default PluginPage;

View file

@ -0,0 +1,56 @@
import Button from '@mui/material/Button/Button';
import Dialog, { type DialogProps } from '@mui/material/Dialog/Dialog';
import DialogActions from '@mui/material/DialogActions/DialogActions';
import DialogContent from '@mui/material/DialogContent/DialogContent';
import DialogContentText from '@mui/material/DialogContentText/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle/DialogTitle';
import React, { type FC } from 'react';
import globalize from 'scripts/globalize';
interface ConfirmDialogProps extends DialogProps {
confirmButtonColor?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
confirmButtonText?: string
title: string
text: string
onCancel: () => void
onConfirm: () => void
}
/** Convenience wrapper for a simple MUI Dialog component for displaying a prompt that needs confirmation. */
const ConfirmDialog: FC<ConfirmDialogProps> = ({
confirmButtonColor = 'primary',
confirmButtonText,
title,
text,
onCancel,
onConfirm,
...dialogProps
}) => (
<Dialog {...dialogProps}>
<DialogTitle>
{title}
</DialogTitle>
<DialogContent>
<DialogContentText>
{text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
variant='text'
onClick={onCancel}
>
{globalize.translate('ButtonCancel')}
</Button>
<Button
color={confirmButtonColor}
onClick={onConfirm}
>
{confirmButtonText || globalize.translate('ButtonOk')}
</Button>
</DialogActions>
</Dialog>
);
export default ConfirmDialog;

View file

@ -0,0 +1,31 @@
import Box from '@mui/material/Box/Box';
import DOMPurify from 'dompurify';
import markdownIt from 'markdown-it';
import React, { type FC } from 'react';
interface MarkdownBoxProps {
markdown?: string | null
fallback?: string
}
/** A component to render Markdown content within a MUI Box component. */
const MarkdownBox: FC<MarkdownBoxProps> = ({
markdown,
fallback
}) => (
<Box
dangerouslySetInnerHTML={
markdown ?
{ __html: DOMPurify.sanitize(markdownIt({ html: true }).render(markdown)) } :
undefined
}
sx={{
'> :first-child': { marginTop: 0, paddingTop: 0 },
'> :last-child': { marginBottom: 0, paddingBottom: 0 }
}}
>
{markdown ? undefined : fallback}
</Box>
);
export default MarkdownBox;

View file

@ -1,51 +0,0 @@
<div id="addPluginPage" data-role="page" class="page type-interior pluginConfigurationPage" data-backbutton="true">
<div>
<div class="content-primary">
<div class="readOnlyContent">
<div class="verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h1 class="sectionTitle pluginName"></h1>
<a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton" target="_blank" href="https://jellyfin.org/docs/general/server/plugins/">${Help}</a>
</div>
<p id="overview" style="font-style: italic;"></p>
<p id="description"></p>
</div>
<div class="verticalSection">
<h2 class="sectionTitle">${HeaderInstall}</h2>
<form class="addPluginForm">
<p id="pCurrentVersion"></p>
<div id="pSelectVersion" class="hide selectContainer">
<select id="selectVersion" name="selectVersion" is="emby-select" label="${LabelSelectVersionToInstall}"></select>
</div>
<div id="btnInstallDiv" class="hide">
<button is="emby-button" type="submit" id="btnInstall" class="raised button-submit block">
<span>${HeaderInstall}</span>
</button>
<div class="fieldDescription">${ServerRestartNeededAfterPluginInstall}</div>
</div>
</form>
</div>
</div>
<br />
<div class="readOnlyContent">
<div is="emby-collapse" title="${HeaderDeveloperInfo}">
<div class="collapseContent">
<p>${LabelDeveloper}: <span id="developer"></span></p>
<p>${LabelRepositoryName}: <span id="repositoryName"></span></p>
<p>${LabelRepositoryUrl}: <span id="repositoryUrl"></span></p>
</div>
</div>
<div is="emby-collapse" title="${HeaderRevisionHistory}">
<div class="collapseContent">
<div id="revisionHistory"></div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,165 +0,0 @@
import 'jquery';
import markdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import loading from '../../../../components/loading/loading';
import globalize from '../../../../scripts/globalize';
import Dashboard from '../../../../utils/dashboard';
import alert from '../../../../components/alert';
import confirm from '../../../../components/confirm/confirm';
import 'elements/emby-button/emby-button';
import 'elements/emby-collapse/emby-collapse';
import 'elements/emby-select/emby-select';
function populateHistory(packageInfo, page) {
let html = '';
const length = Math.min(packageInfo.versions.length, 10);
for (let i = 0; i < length; i++) {
const version = packageInfo.versions[i];
html += '<h2 style="margin:.5em 0;">' + version.version + '</h2>';
html += '<div style="margin-bottom:1.5em;">' + DOMPurify.sanitize(markdownIt({ html: true }).render(version.changelog)) + '</div>';
}
$('#revisionHistory', page).html(html);
}
function populateVersions(packageInfo, page, installedPlugin) {
let html = '';
packageInfo.versions.sort((a, b) => {
return b.timestamp < a.timestamp ? -1 : 1;
});
for (const version of packageInfo.versions) {
html += '<option value="' + version.version + '">' + globalize.translate('PluginFromRepo', version.version, version.repositoryName) + '</option>';
}
const selectmenu = $('#selectVersion', page).html(html);
if (!installedPlugin) {
$('#pCurrentVersion', page).hide().html('');
}
const packageVersion = packageInfo.versions[0];
if (packageVersion) {
selectmenu.val(packageVersion.version);
}
}
function renderPackage(pkg, installedPlugins, page) {
const installedPlugin = installedPlugins.filter(function (ip) {
return ip.Name == pkg.name;
})[0];
populateVersions(pkg, page, installedPlugin);
populateHistory(pkg, page);
$('.pluginName', page).text(pkg.name);
$('#btnInstallDiv', page).removeClass('hide');
$('#pSelectVersion', page).removeClass('hide');
if (pkg.overview) {
$('#overview', page).show().text(pkg.overview);
} else {
$('#overview', page).hide();
}
$('#description', page).text(pkg.description);
$('#developer', page).text(pkg.owner);
// This is a hack; the repository name and URL should be part of the global values
// for the plugin, not each individual version. So we just use the top (latest)
// version to get this information. If it's missing (no versions), then say so.
if (pkg.versions.length) {
$('#repositoryName', page).text(pkg.versions[0].repositoryName);
$('#repositoryUrl', page).text(pkg.versions[0].repositoryUrl);
} else {
$('#repositoryName', page).text(globalize.translate('Unknown'));
$('#repositoryUrl', page).text(globalize.translate('Unknown'));
}
if (installedPlugin) {
const currentVersionText = globalize.translate('MessageYouHaveVersionInstalled', '<strong>' + installedPlugin.Version + '</strong>');
$('#pCurrentVersion', page).show().html(currentVersionText);
} else {
$('#pCurrentVersion', page).hide().text('');
}
loading.hide();
}
function alertText(options) {
alert(options);
}
function performInstallation(page, name, guid, version) {
const repositoryUrl = $('#repositoryUrl', page).html().toLowerCase();
const alertCallback = function () {
loading.show();
page.querySelector('#btnInstall').disabled = true;
ApiClient.installPlugin(name, guid, version).then(() => {
loading.hide();
alertText(globalize.translate('MessagePluginInstalled'));
}).catch(() => {
alertText(globalize.translate('MessagePluginInstallError'));
});
};
// Check the repository URL for the official Jellyfin repository domain, or
// present the warning for 3rd party plugins.
if (!repositoryUrl.startsWith('https://repo.jellyfin.org/')) {
loading.hide();
let msg = globalize.translate('MessagePluginInstallDisclaimer');
msg += '<br/>';
msg += '<br/>';
msg += globalize.translate('PleaseConfirmPluginInstallation');
confirm(msg, globalize.translate('HeaderConfirmPluginInstallation')).then(function () {
alertCallback();
}).catch(() => {
console.debug('plugin not installed');
});
} else {
alertCallback();
}
}
export default function(view, params) {
$('.addPluginForm', view).on('submit', function () {
loading.show();
const page = $(this).parents('#addPluginPage')[0];
const name = params.name;
const guid = params.guid;
ApiClient.getInstalledPlugins().then(function (plugins) {
const installedPlugin = plugins.filter(function (plugin) {
return plugin.Name == name;
})[0];
const version = $('#selectVersion', page).val();
if (installedPlugin && installedPlugin.Version === version) {
loading.hide();
Dashboard.alert({
message: globalize.translate('MessageAlreadyInstalled'),
title: globalize.translate('HeaderPluginInstallation')
});
} else {
performInstallation(page, name, guid, version);
}
}).catch(() => {
alertText(globalize.translate('MessageGetInstalledPluginsError'));
});
return false;
});
view.addEventListener('viewshow', function () {
const page = this;
loading.show();
const name = params.name;
const guid = params.guid;
const promise1 = ApiClient.getPackageInfo(name, guid);
const promise2 = ApiClient.getInstalledPlugins();
Promise.all([promise1, promise2]).then(function (responses) {
renderPackage(responses[0], responses[1], page);
});
});
}

View file

@ -119,7 +119,8 @@ function onSearchBarType(searchBar) {
function getPluginHtml(plugin, options, installedPlugins) { function getPluginHtml(plugin, options, installedPlugins) {
let html = ''; let html = '';
let href = plugin.externalUrl ? plugin.externalUrl : '#/dashboard/plugins/add?name=' + encodeURIComponent(plugin.name) + '&guid=' + plugin.guid; let href = plugin.externalUrl ? plugin.externalUrl :
`#/dashboard/plugins/${plugin.guid}?name=${encodeURIComponent(plugin.name)}`;
if (options.context) { if (options.context) {
href += '&context=' + options.context; href += '&context=' + options.context;

View file

@ -457,7 +457,6 @@
"HeaderPlaybackError": "Playback Error", "HeaderPlaybackError": "Playback Error",
"HeaderPlayOn": "Play On", "HeaderPlayOn": "Play On",
"HeaderPleaseSignIn": "Please sign in", "HeaderPleaseSignIn": "Please sign in",
"HeaderPluginInstallation": "Plugin Installation",
"HeaderPortRanges": "Firewall and Proxy Settings", "HeaderPortRanges": "Firewall and Proxy Settings",
"HeaderPreferredMetadataLanguage": "Preferred Metadata Language", "HeaderPreferredMetadataLanguage": "Preferred Metadata Language",
"HeaderRecentlyPlayed": "Recently Played", "HeaderRecentlyPlayed": "Recently Played",
@ -661,6 +660,7 @@
"LabelEnableIP6Help": "Enable IPv6 functionality.", "LabelEnableIP6Help": "Enable IPv6 functionality.",
"LabelEnableLUFSScan": "Enable LUFS scan", "LabelEnableLUFSScan": "Enable LUFS scan",
"LabelEnableLUFSScanHelp": "Clients can normalize audio playback to get equal loudness across tracks. This will make library scans longer and take more resources.", "LabelEnableLUFSScanHelp": "Clients can normalize audio playback to get equal loudness across tracks. This will make library scans longer and take more resources.",
"LabelEnablePlugin": "Enable plugin",
"LabelEnableRealtimeMonitor": "Enable real time monitoring", "LabelEnableRealtimeMonitor": "Enable real time monitoring",
"LabelEnableRealtimeMonitorHelp": "Changes to files will be processed immediately on supported file systems.", "LabelEnableRealtimeMonitorHelp": "Changes to files will be processed immediately on supported file systems.",
"LabelEncoderPreset": "Encoding preset", "LabelEncoderPreset": "Encoding preset",
@ -694,6 +694,7 @@
"LabelImageFetchersHelp": "Enable and rank your preferred image fetchers in order of priority.", "LabelImageFetchersHelp": "Enable and rank your preferred image fetchers in order of priority.",
"LabelImageType": "Image type", "LabelImageType": "Image type",
"LabelImportOnlyFavoriteChannels": "Restrict to channels marked as favorite", "LabelImportOnlyFavoriteChannels": "Restrict to channels marked as favorite",
"LabelInstalled": "Installed",
"LabelInternetQuality": "Internet quality", "LabelInternetQuality": "Internet quality",
"LabelIsForced": "Forced", "LabelIsForced": "Forced",
"LabelKeepUpTo": "Keep up to", "LabelKeepUpTo": "Keep up to",
@ -762,6 +763,8 @@
"LabelNewPassword": "New password", "LabelNewPassword": "New password",
"LabelNewPasswordConfirm": "New password confirm", "LabelNewPasswordConfirm": "New password confirm",
"LabelNewsCategories": "News categories", "LabelNewsCategories": "News categories",
"LabelNoChangelog": "No changelog provided for this release.",
"LabelNotInstalled": "Not installed",
"LabelNumber": "Number", "LabelNumber": "Number",
"LabelNumberOfGuideDays": "Number of days of guide data to download", "LabelNumberOfGuideDays": "Number of days of guide data to download",
"LabelNumberOfGuideDaysHelp": "Downloading more days worth of guide data provides the ability to schedule out further in advance and view more listings, but it will also take longer to download. Auto will pick based on the number of channels.", "LabelNumberOfGuideDaysHelp": "Downloading more days worth of guide data provides the ability to schedule out further in advance and view more listings, but it will also take longer to download. Auto will pick based on the number of channels.",
@ -813,6 +816,7 @@
"LabelReleaseDate": "Release date", "LabelReleaseDate": "Release date",
"LabelRemoteClientBitrateLimit": "Internet streaming bitrate limit (Mbps)", "LabelRemoteClientBitrateLimit": "Internet streaming bitrate limit (Mbps)",
"LabelRemoteClientBitrateLimitHelp": "An optional per-stream bitrate limit for all out of network devices. This is useful to prevent devices from requesting a higher bitrate than your internet connection can handle. This may result in increased CPU load on your server in order to transcode videos on the fly to a lower bitrate.", "LabelRemoteClientBitrateLimitHelp": "An optional per-stream bitrate limit for all out of network devices. This is useful to prevent devices from requesting a higher bitrate than your internet connection can handle. This may result in increased CPU load on your server in order to transcode videos on the fly to a lower bitrate.",
"LabelRepository": "Repository",
"LabelRepositoryName": "Repository Name", "LabelRepositoryName": "Repository Name",
"LabelRepositoryNameHelp": "A custom name to distinguish this repository from any others added to your server.", "LabelRepositoryNameHelp": "A custom name to distinguish this repository from any others added to your server.",
"LabelRepositoryUrl": "Repository URL", "LabelRepositoryUrl": "Repository URL",
@ -1025,7 +1029,6 @@
"MenuOpen": "Open Menu", "MenuOpen": "Open Menu",
"MenuClose": "Close Menu", "MenuClose": "Close Menu",
"MessageAddRepository": "If you wish to add a repository, click the button next to the header and fill out the requested information.", "MessageAddRepository": "If you wish to add a repository, click the button next to the header and fill out the requested information.",
"MessageAlreadyInstalled": "This version is already installed.",
"MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?", "MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?",
"MessageAreYouSureYouWishToRemoveMediaFolder": "Are you sure you wish to remove this media folder?", "MessageAreYouSureYouWishToRemoveMediaFolder": "Are you sure you wish to remove this media folder?",
"MessageBrowsePluginCatalog": "Browse our plugin catalog to view available plugins.", "MessageBrowsePluginCatalog": "Browse our plugin catalog to view available plugins.",
@ -1101,7 +1104,6 @@
"MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.", "MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.",
"MessageUnauthorizedUser": "You are not authorized to access the server at this time. Please contact your server administrator for more information.", "MessageUnauthorizedUser": "You are not authorized to access the server at this time. Please contact your server administrator for more information.",
"MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.", "MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.",
"MessageYouHaveVersionInstalled": "You currently have version {0} installed.",
"Metadata": "Metadata", "Metadata": "Metadata",
"MetadataManager": "Metadata Manager", "MetadataManager": "Metadata Manager",
"MetadataSettingChangeHelp": "Changing metadata settings will affect new content added going forward. To refresh existing content, open the detail screen and click the 'Refresh' button, or do bulk refreshes using the 'Metadata Manager'.", "MetadataSettingChangeHelp": "Changing metadata settings will affect new content added going forward. To refresh existing content, open the detail screen and click the 'Refresh' button, or do bulk refreshes using the 'Metadata Manager'.",
@ -1282,11 +1284,15 @@
"PlayNext": "Play next", "PlayNext": "Play next",
"PlayNextEpisodeAutomatically": "Play next episode automatically", "PlayNextEpisodeAutomatically": "Play next episode automatically",
"PleaseAddAtLeastOneFolder": "Please add at least one folder to this library by clicking the '+' button in 'Folders' section.", "PleaseAddAtLeastOneFolder": "Please add at least one folder to this library by clicking the '+' button in 'Folders' section.",
"PleaseConfirmPluginInstallation": "Please click OK to confirm you've read the above and wish to proceed with the plugin installation.",
"PleaseConfirmRepositoryInstallation": "Please click OK to confirm you've read the above and wish to proceed with the plugin repository installation.", "PleaseConfirmRepositoryInstallation": "Please click OK to confirm you've read the above and wish to proceed with the plugin repository installation.",
"PleaseEnterNameOrId": "Please enter a name or an external ID.", "PleaseEnterNameOrId": "Please enter a name or an external ID.",
"PleaseRestartServerName": "Please restart Jellyfin on {0}.", "PleaseRestartServerName": "Please restart Jellyfin on {0}.",
"PleaseSelectTwoItems": "Please select at least two items.", "PleaseSelectTwoItems": "Please select at least two items.",
"PluginDisableError": "An error occurred while disabling the plugin.",
"PluginEnableError": "An error occurred while enabling the plugin.",
"PluginLoadConfigError": "An error occurred while getting the plugin configuration pages.",
"PluginLoadRepoError": "An error occurred while getting the plugin details from the repository.",
"PluginUninstallError": "An error occurred while uninstalling the plugin.",
"Poster": "Poster", "Poster": "Poster",
"PosterCard": "Poster Card", "PosterCard": "Poster Card",
"PreferEmbeddedEpisodeInfosOverFileNames": "Prefer embedded episode information over filenames", "PreferEmbeddedEpisodeInfosOverFileNames": "Prefer embedded episode information over filenames",
@ -1314,7 +1320,6 @@
"ProductionLocations": "Production locations", "ProductionLocations": "Production locations",
"Profile": "Profile", "Profile": "Profile",
"Programs": "Programs", "Programs": "Programs",
"PluginFromRepo": "{0} from repository {1}",
"Quality": "Quality", "Quality": "Quality",
"QuickConnect": "Quick Connect", "QuickConnect": "Quick Connect",
"QuickConnectActivationSuccessful": "Successfully activated", "QuickConnectActivationSuccessful": "Successfully activated",
@ -1403,7 +1408,7 @@
"SeriesYearToPresent": "{0} - Present", "SeriesYearToPresent": "{0} - Present",
"ServerNameIsRestarting": "The server at {0} is restarting.", "ServerNameIsRestarting": "The server at {0} is restarting.",
"ServerNameIsShuttingDown": "The server at {0} is shutting down.", "ServerNameIsShuttingDown": "The server at {0} is shutting down.",
"ServerRestartNeededAfterPluginInstall": "Jellyfin will need to be restarted after installing a plugin.", "ServerRestartNeededAfterPluginInstall": "Jellyfin will need to be restarted after installing the plugin.",
"ServerUpdateNeeded": "This server needs to be updated. To download the latest version, please visit {0}", "ServerUpdateNeeded": "This server needs to be updated. To download the latest version, please visit {0}",
"Settings": "Settings", "Settings": "Settings",
"SettingsSaved": "Settings saved.", "SettingsSaved": "Settings saved.",