Merge pull request #7017 from thornbill/plugin-unity

Add unified plugin page
This commit is contained in:
Bill Thornton
2025-07-30 16:27:25 -04:00
committed by GitHub
23 changed files with 602 additions and 435 deletions
@@ -0,0 +1,119 @@
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
import { useMemo } from 'react';
import { useApi } from 'hooks/useApi';
import { PluginCategory } from '../constants/pluginCategory';
import type { PluginDetails } from '../types/PluginDetails';
import { findBestConfigurationPage } from './configurationPage';
import { findBestPluginInfo } from './pluginInfo';
import { useConfigurationPages } from './useConfigurationPages';
import { usePackages } from './usePackages';
import { usePlugins } from './usePlugins';
export const usePluginDetails = () => {
const { api } = useApi();
const {
data: configurationPages,
isError: isConfigurationPagesError,
isPending: isConfigurationPagesPending
} = useConfigurationPages();
const {
data: packages,
isError: isPackagesError,
isPending: isPackagesPending
} = usePackages();
const {
data: plugins,
isError: isPluginsError,
isPending: isPluginsPending
} = usePlugins();
const pluginDetails = useMemo<PluginDetails[]>(() => {
if (!isPackagesPending && !isPluginsPending) {
const pluginIds = new Set<string>();
packages?.forEach(({ guid }) => {
if (guid) pluginIds.add(guid);
});
plugins?.forEach(({ Id }) => {
if (Id) pluginIds.add(Id);
});
return Array.from(pluginIds)
.map(id => {
const packageInfo = packages?.find(pkg => pkg.guid === id);
const pluginInfo = findBestPluginInfo(id, 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?.getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`);
}
let category = packageInfo?.category;
if (!packageInfo) {
switch (id) {
case 'a629c0dafac54c7e931a7174223f14c8': // AudioDB
case '8c95c4d2e50c4fb0a4f36c06ff0f9a1a': // MusicBrainz
category = PluginCategory.Music;
break;
case 'a628c0dafac54c7e9d1a7134223f14c8': // OMDb
case 'b8715ed16c4745289ad3f72deb539cd4': // TMDb
category = PluginCategory.MoviesAndShows;
break;
case '872a78491171458da6fb3de3d442ad30': // Studio Images
category = PluginCategory.General;
}
}
return {
canUninstall: !!pluginInfo?.CanUninstall,
category,
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
id,
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
isEnabled: pluginInfo?.Status !== PluginStatus.Disabled,
name: pluginInfo?.Name || packageInfo?.name,
owner: packageInfo?.owner,
status: pluginInfo?.Status,
configurationPage: findBestConfigurationPage(configurationPages || [], id),
version,
versions: packageInfo?.versions || []
};
})
.sort(({ name: nameA }, { name: nameB }) => (
(nameA || '').localeCompare(nameB || '')
));
}
return [];
}, [
api,
configurationPages,
isPluginsPending,
packages,
plugins
]);
return {
data: pluginDetails,
isError: isConfigurationPagesError || isPackagesError || isPluginsError,
isPending: isConfigurationPagesPending || isPackagesPending || isPluginsPending
};
};
@@ -0,0 +1,51 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import React, { type FC } from 'react';
import globalize from 'lib/globalize';
interface NoPluginResultsProps {
isFiltered: boolean
onViewAll: () => void
query: string
}
const NoPluginResults: FC<NoPluginResultsProps> = ({
isFiltered,
onViewAll,
query
}) => {
return (
<Box
sx={{
textAlign: 'center'
}}
>
<Typography
component='div'
sx={{
marginTop: 2,
marginBottom: 1
}}
>
{
query ?
globalize.translate('SearchResultsEmpty', query) :
globalize.translate('NoSubtitleSearchResultsFound')
}
</Typography>
{isFiltered && (
<Button
variant='text'
onClick={onViewAll}
>
{globalize.translate('ViewAllPlugins')}
</Button>
)}
</Box>
);
};
export default NoPluginResults;
@@ -1,28 +0,0 @@
import React from 'react';
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
import ExtensionIcon from '@mui/icons-material/Extension';
import BaseCard from 'apps/dashboard/components/BaseCard';
import { useLocation } from 'react-router-dom';
type IProps = {
pkg: PackageInfo;
};
const PackageCard = ({ pkg }: IProps) => {
const location = useLocation();
return (
<BaseCard
title={pkg.name}
image={pkg.imageUrl}
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
to={{
pathname: `/dashboard/plugins/${pkg.guid}`,
search: `?name=${encodeURIComponent(pkg.name || '')}`,
hash: location.hash
}}
/>
);
};
export default PackageCard;
@@ -1,171 +1,34 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useApi } from 'hooks/useApi';
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
import globalize from 'lib/globalize';
import BaseCard from 'apps/dashboard/components/BaseCard';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import Settings from '@mui/icons-material/Settings';
import Delete from '@mui/icons-material/Delete';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import BlockIcon from '@mui/icons-material/Block';
import ExtensionIcon from '@mui/icons-material/Extension';
import ListItemText from '@mui/material/ListItemText';
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
import { useEnablePlugin } from '../api/useEnablePlugin';
import { useDisablePlugin } from '../api/useDisablePlugin';
import { useUninstallPlugin } from '../api/useUninstallPlugin';
import ConfirmDialog from 'components/ConfirmDialog';
import React, { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
interface IProps {
plugin: PluginInfo;
configurationPage?: ConfigurationPageInfo;
import BaseCard from 'apps/dashboard/components/BaseCard';
import { PluginDetails } from '../types/PluginDetails';
interface PluginCardProps {
plugin: PluginDetails;
};
const PluginCard = ({ plugin, configurationPage }: IProps) => {
const PluginCard = ({ plugin }: PluginCardProps) => {
const location = useLocation();
const navigate = useNavigate();
const actionRef = useRef<HTMLButtonElement | null>(null);
const enablePlugin = useEnablePlugin();
const disablePlugin = useDisablePlugin();
const uninstallPlugin = useUninstallPlugin();
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
const { api } = useApi();
const pluginPage = useMemo(() => (
{
pathname: '/configurationpage',
search: `?name=${encodeURIComponent(configurationPage?.Name || '')}`,
pathname: `/dashboard/plugins/${plugin.id}`,
search: `?name=${encodeURIComponent(plugin.name || '')}`,
hash: location.hash
}
), [ location, configurationPage ]);
const navigateToPluginSettings = useCallback(() => {
navigate(pluginPage);
}, [ navigate, pluginPage ]);
const onEnablePlugin = useCallback(() => {
if (plugin.Id && plugin.Version) {
enablePlugin.mutate({
pluginId: plugin.Id,
version: plugin.Version
});
setAnchorEl(null);
setIsMenuOpen(false);
}
}, [ plugin, enablePlugin ]);
const onDisablePlugin = useCallback(() => {
if (plugin.Id && plugin.Version) {
disablePlugin.mutate({
pluginId: plugin.Id,
version: plugin.Version
});
setAnchorEl(null);
setIsMenuOpen(false);
}
}, [ plugin, disablePlugin ]);
const onCloseUninstallConfirmDialog = useCallback(() => {
setIsUninstallConfirmOpen(false);
}, []);
const showUninstallConfirmDialog = useCallback(() => {
setIsUninstallConfirmOpen(true);
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onUninstall = useCallback(() => {
if (plugin.Id && plugin.Version) {
uninstallPlugin.mutate({
pluginId: plugin.Id,
version: plugin.Version
});
setAnchorEl(null);
setIsMenuOpen(false);
}
}, [ plugin, uninstallPlugin ]);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onActionClick = useCallback(() => {
setAnchorEl(actionRef.current);
setIsMenuOpen(true);
}, []);
), [ location, plugin ]);
return (
<>
<BaseCard
title={plugin.Name}
secondaryTitle={plugin.Version}
to={pluginPage}
text={`${globalize.translate('LabelStatus')} ${plugin.Status}`}
image={plugin.HasImage ? api?.getUri(`/Plugins/${plugin.Id}/${plugin.Version}/Image`) : null}
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
action={true}
actionRef={actionRef}
onActionClick={onActionClick}
/>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
{configurationPage && (
<MenuItem onClick={navigateToPluginSettings}>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText>{globalize.translate('Settings')}</ListItemText>
</MenuItem>
)}
{(plugin.CanUninstall && plugin.Status === PluginStatus.Active) && (
<MenuItem onClick={onDisablePlugin}>
<ListItemIcon>
<BlockIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('DisablePlugin')}</ListItemText>
</MenuItem>
)}
{(plugin.CanUninstall && plugin.Status === PluginStatus.Disabled) && (
<MenuItem onClick={onEnablePlugin}>
<ListItemIcon>
<CheckCircleOutlineIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('EnablePlugin')}</ListItemText>
</MenuItem>
)}
{plugin.CanUninstall && (
<MenuItem onClick={showUninstallConfirmDialog}>
<ListItemIcon>
<Delete />
</ListItemIcon>
<ListItemText>{globalize.translate('ButtonUninstall')}</ListItemText>
</MenuItem>
)}
</Menu>
<ConfirmDialog
open={isUninstallConfirmOpen}
title={globalize.translate('HeaderUninstallPlugin')}
text={globalize.translate('UninstallPluginConfirmation', plugin.Name || '')}
onCancel={onCloseUninstallConfirmDialog}
onConfirm={onUninstall}
confirmButtonColor='error'
confirmButtonText={globalize.translate('ButtonUninstall')}
/>
</>
<BaseCard
title={plugin.name}
to={pluginPage}
text={[plugin.version?.VersionNumber, plugin.status].filter(t => t).join(' ')}
image={plugin.imageUrl}
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
/>
);
};
@@ -72,6 +72,9 @@ const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
<TableCell>
{
(isRepositoryLoading && <Skeleton />)
|| (pluginDetails?.status && pluginDetails?.canUninstall === false
&& globalize.translate('LabelBundled')
)
|| (pluginDetails?.version?.repositoryUrl && (
<Link
component={RouterLink}
@@ -1,15 +1,14 @@
import { PluginCategory } from './pluginCategory';
/** A mapping of category names used by the plugin repository to translation keys. */
export const CATEGORY_LABELS: Record<string, string> = {
Administration: 'HeaderAdmin',
Anime: 'Anime',
Authentication: 'LabelAuthProvider', // Legacy
Books: 'Books',
Channel: 'Channels', // Unused?
General: 'General',
LiveTV: 'LiveTV',
Metadata: 'LabelMetadata', // Legacy
MoviesAndShows: 'MoviesAndShows',
Music: 'TabMusic',
Subtitles: 'Subtitles',
Other: 'Other'
export const CATEGORY_LABELS: Record<PluginCategory, string> = {
[PluginCategory.Administration]: 'HeaderAdmin',
[PluginCategory.General]: 'General',
[PluginCategory.Anime]: 'Anime',
[PluginCategory.Books]: 'Books',
[PluginCategory.LiveTV]: 'LiveTV',
[PluginCategory.MoviesAndShows]: 'MoviesAndShows',
[PluginCategory.Music]: 'TabMusic',
[PluginCategory.Subtitles]: 'Subtitles',
[PluginCategory.Other]: 'Other'
};
@@ -0,0 +1,12 @@
/** Supported plugin category values. */
export enum PluginCategory {
Administration = 'Administration',
General = 'General',
Anime = 'Anime',
Books = 'Books',
LiveTV = 'LiveTV',
MoviesAndShows = 'MoviesAndShows',
Music = 'Music',
Subtitles = 'Subtitles',
Other = 'Other'
}
@@ -0,0 +1,6 @@
/** Options for filtering plugins based on the installation status. */
export enum PluginStatusOption {
All = 'All',
Available = 'Available',
Installed = 'Installed'
}
@@ -2,6 +2,7 @@ import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin
export interface PluginDetails {
canUninstall: boolean
category?: string
description?: string
id: string
imageUrl?: string
@@ -1,17 +0,0 @@
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
const getPackageCategories = (packages?: PackageInfo[]) => {
if (!packages) return [];
const categories: string[] = [];
for (const pkg of packages) {
if (pkg.category && !categories.includes(pkg.category)) {
categories.push(pkg.category);
}
}
return categories.sort((a, b) => a.localeCompare(b));
};
export default getPackageCategories;
@@ -1,17 +0,0 @@
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
const getPackagesByCategory = (packages: PackageInfo[] | undefined, category: string) => {
if (!packages) return [];
return packages
.filter(pkg => pkg.category === category)
.sort((a, b) => {
if (a.name && b.name) {
return a.name.localeCompare(b.name);
} else {
return 0;
}
});
};
export default getPackagesByCategory;