Merge pull request #6917 from viown/backup-feature

Add backup dashboard page
This commit is contained in:
Bill Thornton
2025-06-05 13:57:11 -04:00
committed by GitHub
13 changed files with 721 additions and 0 deletions
@@ -1,4 +1,5 @@
import Article from '@mui/icons-material/Article';
import Backup from '@mui/icons-material/Backup';
import Lan from '@mui/icons-material/Lan';
import Schedule from '@mui/icons-material/Schedule';
import VpnKey from '@mui/icons-material/VpnKey';
@@ -38,6 +39,14 @@ const AdvancedDrawerSection = () => {
<ListItemText primary={globalize.translate('HeaderApiKeys')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/backups'>
<ListItemIcon>
<Backup />
</ListItemIcon>
<ListItemText primary={globalize.translate('HeaderBackups')} />
</ListItemLink>
</ListItem>
<ListItem disablePadding>
<ListItemLink to='/dashboard/logs'>
<ListItemIcon>
@@ -0,0 +1,37 @@
import { Api } from '@jellyfin/sdk';
import { BackupApi } from '@jellyfin/sdk/lib/generated-client/api/backup-api';
import { useQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
export const QUERY_KEY = 'Backups';
const fetchBackups = async (api: Api, options?: AxiosRequestConfig) => {
// FIXME: Replace with getBackupApi when available in SDK
const backupApi = new BackupApi(api.configuration, undefined, api.axiosInstance);
const response = await backupApi.listBackups(options);
const backups = response.data;
backups.sort((a, b) => {
if (a.DateCreated && b.DateCreated) {
return new Date(b.DateCreated).getTime() - new Date(a.DateCreated).getTime();
} else {
return 0;
}
});
return backups;
};
export const useBackups = () => {
const { api } = useApi();
return useQuery({
queryKey: [ QUERY_KEY ],
queryFn: ({ signal }) =>
fetchBackups(api!, { signal }),
enabled: !!api
});
};
@@ -0,0 +1,25 @@
import type { BackupOptionsDto } from '@jellyfin/sdk/lib/generated-client/models/backup-options-dto';
import { BackupApi } from '@jellyfin/sdk/lib/generated-client/api/backup-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { QUERY_KEY } from './useBackups';
import { queryClient } from 'utils/query/queryClient';
export const useCreateBackup = () => {
const { api } = useApi();
// FIXME: Replace with getBackupApi when available in SDK
const backupApi = new BackupApi(api?.configuration, undefined, api?.axiosInstance);
return useMutation({
mutationFn: (backupOptions: BackupOptionsDto) => (
backupApi.createBackup({
backupOptionsDto: backupOptions
})
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QUERY_KEY ]
});
}
});
};
@@ -0,0 +1,19 @@
import { BackupApi } from '@jellyfin/sdk/lib/generated-client/api/backup-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
export const useRestoreBackup = () => {
const { api } = useApi();
// FIXME: Replace with getBackupApi when available in SDK
const backupApi = new BackupApi(api?.configuration, undefined, api?.axiosInstance);
return useMutation({
mutationFn: (fileName: string) => (
backupApi.startRestoreBackup({
backupRestoreRequestDto: {
ArchiveFileName: fileName
}
})
)
});
};
@@ -0,0 +1,68 @@
import React, { FunctionComponent, useCallback, useState } from 'react';
import type { BackupManifestDto } from '@jellyfin/sdk/lib/generated-client/models/backup-manifest-dto';
import IconButton from '@mui/material/IconButton';
import ListItem from '@mui/material/ListItem';
import Restore from '@mui/icons-material/Restore';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Tooltip from '@mui/material/Tooltip';
import globalize from 'lib/globalize';
import BackupInfoDialog from './BackupInfoDialog';
type BackupProps = {
backup: BackupManifestDto;
onRestore: (backup: BackupManifestDto) => void;
};
const Backup: FunctionComponent<BackupProps> = ({ backup, onRestore }) => {
const [ isInfoDialogOpen, setIsInfoDialogOpen ] = useState(false);
const onDialogClose = useCallback(() => {
setIsInfoDialogOpen(false);
}, []);
const openDialog = useCallback(() => {
setIsInfoDialogOpen(true);
}, []);
const restore = useCallback(() => {
onRestore(backup);
}, [ backup, onRestore ]);
return (
<>
<BackupInfoDialog
backup={backup}
onClose={onDialogClose}
open={isInfoDialogOpen}
/>
<ListItem
disablePadding
secondaryAction={
<Tooltip disableInteractive title={globalize.translate('LabelRestore')}>
<IconButton onClick={restore}>
<Restore />
</IconButton>
</Tooltip>
}
>
<ListItemButton onClick={openDialog}>
<ListItemText
primary={backup.DateCreated}
secondary={backup.Path}
slotProps={{
primary: {
variant: 'h3'
},
secondary: {
variant: 'body1'
}
}}
/>
</ListItemButton>
</ListItem>
</>
);
};
export default Backup;
@@ -0,0 +1,135 @@
import type { BackupManifestDto } from '@jellyfin/sdk/lib/generated-client/models/backup-manifest-dto';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Box from '@mui/material/Box';
import globalize from 'lib/globalize';
import React, { FunctionComponent, useCallback } from 'react';
import Stack from '@mui/material/Stack';
import FormGroup from '@mui/material/FormGroup';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import ContentCopy from '@mui/icons-material/ContentCopy';
import IconButton from '@mui/material/IconButton';
import { copy } from 'scripts/clipboard';
import toast from 'components/toast/toast';
type IProps = {
backup: BackupManifestDto;
open: boolean;
onClose: () => void;
};
const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }: IProps) => {
const copyPath = useCallback(async () => {
if (backup.Path) {
await copy(backup.Path);
toast({ text: globalize.translate('Copied') });
}
}, [ backup.Path ]);
return (
<Dialog
onClose={onClose}
open={open}
maxWidth={'sm'}
fullWidth
>
<DialogTitle>
{backup.DateCreated}
</DialogTitle>
<DialogContent>
<Stack gap={2}>
<Box>
<Stack
direction='row'
gap={2}
>
<Typography fontWeight='bold'>{globalize.translate('LabelPath')}</Typography>
<Stack direction='row'>
<Typography color='text.secondary'>{backup.Path}</Typography>
<IconButton size='small' onClick={copyPath}>
<ContentCopy fontSize='small' />
</IconButton>
</Stack>
</Stack>
<Stack
direction='row'
gap={2}
>
<Typography fontWeight='bold'>{globalize.translate('LabelVersion')}</Typography>
<Typography color='text.secondary'>{backup.ServerVersion}</Typography>
</Stack>
</Box>
<FormGroup>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Database'
defaultChecked={true}
disabled
/>
}
label={globalize.translate('LabelDatabase')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Metadata'
defaultChecked={backup.Options?.Metadata}
disabled
/>
}
label={globalize.translate('LabelMetadata')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Subtitles'
defaultChecked={backup.Options?.Subtitles}
disabled
/>
}
label={globalize.translate('Subtitles')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Trickplay'
defaultChecked={backup.Options?.Trickplay}
disabled
/>
}
label={globalize.translate('Trickplay')}
/>
</FormControl>
</FormGroup>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
{globalize.translate('ButtonOk')}
</Button>
</DialogActions>
</Dialog>
);
};
export default BackupInfoDialog;
@@ -0,0 +1,27 @@
import React, { FunctionComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import LinearProgress from '@mui/material/LinearProgress';
import globalize from 'lib/globalize';
type IProps = {
open: boolean
};
const BackupProgressDialog: FunctionComponent<IProps> = ({ open }) => {
return (
<Dialog
open={open}
maxWidth={'xs'}
fullWidth
>
<DialogTitle>{globalize.translate('MessageBackupInProgress')}</DialogTitle>
<DialogContent>
<LinearProgress />
</DialogContent>
</Dialog>
);
};
export default BackupProgressDialog;
@@ -0,0 +1,123 @@
import React, { FunctionComponent, useCallback } from 'react';
import globalize from 'lib/globalize';
import type { BackupOptionsDto } from '@jellyfin/sdk/lib/generated-client/models/backup-options-dto';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import Stack from '@mui/material/Stack';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import FormGroup from '@mui/material/FormGroup';
import DialogContentText from '@mui/material/DialogContentText';
type IProps = {
open: boolean,
onClose?: () => void,
onCreate: (backupOptions: BackupOptionsDto) => void
};
const CreateBackupForm: FunctionComponent<IProps> = ({ open, onClose, onCreate }) => {
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData.entries());
const backupOptions: BackupOptionsDto = {
'Metadata': data.Metadata?.toString() === 'on',
'Trickplay': data.Trickplay?.toString() === 'on',
'Subtitles': data.Subtitles?.toString() === 'on'
};
onCreate(backupOptions);
}, [ onCreate ]);
return (
<Dialog
open={open}
maxWidth={'xs'}
fullWidth
onClose={onClose}
slotProps={{
paper: {
component: 'form',
onSubmit
}
}}
>
<DialogTitle>{globalize.translate('ButtonCreateBackup')}</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<DialogContentText>
{globalize.translate('MessageBackupDisclaimer')}
</DialogContentText>
<FormGroup>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Database'
defaultChecked={true}
disabled
/>
}
label={globalize.translate('LabelDatabase')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Metadata'
defaultChecked={false}
/>
}
label={globalize.translate('LabelMetadata')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Subtitles'
defaultChecked={false}
/>
}
label={globalize.translate('Subtitles')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='Trickplay'
defaultChecked={false}
/>
}
label={globalize.translate('Trickplay')}
/>
</FormControl>
</FormGroup>
</Stack>
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
variant='text'
>{globalize.translate('ButtonCancel')}</Button>
<Button type='submit'>{globalize.translate('Create')}</Button>
</DialogActions>
</Dialog>
);
};
export default CreateBackupForm;
@@ -0,0 +1,46 @@
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import globalize from 'lib/globalize';
import React, { FunctionComponent } from 'react';
type IProps = {
open: boolean;
onClose: () => void;
onConfirm: () => void;
};
const RestoreConfirmationDialog: FunctionComponent<IProps> = ({ open, onClose, onConfirm }: IProps) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth={'xs'}
fullWidth
>
<DialogTitle>
{globalize.translate('LabelRestore')}
</DialogTitle>
<DialogContent>
<DialogContentText>
{globalize.translate('MessageRestoreDisclaimer')}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color='error'>
{globalize.translate('ButtonCancel')}
</Button>
<Button onClick={onConfirm}>
{globalize.translate('LabelRestore')}
</Button>
</DialogActions>
</Dialog>
);
};
export default RestoreConfirmationDialog;
@@ -0,0 +1,32 @@
import React, { FunctionComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import LinearProgress from '@mui/material/LinearProgress';
import DialogContentText from '@mui/material/DialogContentText';
import Stack from '@mui/material/Stack';
import globalize from 'lib/globalize';
type IProps = {
open: boolean
};
const RestoreProgressDialog: FunctionComponent<IProps> = ({ open }) => {
return (
<Dialog
open={open}
maxWidth={'xs'}
fullWidth
>
<DialogTitle>{globalize.translate('MessageRestoreInProgress')}</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<DialogContentText>{globalize.translate('MessageWaitingForServer')}</DialogContentText>
<LinearProgress />
</Stack>
</DialogContent>
</Dialog>
);
};
export default RestoreProgressDialog;
@@ -3,6 +3,7 @@ import { AppType } from 'constants/appType';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity', type: AppType.Dashboard },
{ path: 'backups', type: AppType.Dashboard },
{ path: 'branding', type: AppType.Dashboard },
{ path: 'devices', type: AppType.Dashboard },
{ path: 'settings', type: AppType.Dashboard },
+184
View File
@@ -0,0 +1,184 @@
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import { useBackups } from 'apps/dashboard/features/backups/api/useBackups';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import React, { useCallback, useEffect, useState } from 'react';
import Loading from 'components/loading/LoadingComponent';
import Alert from '@mui/material/Alert';
import List from '@mui/material/List';
import CreateBackupForm from 'apps/dashboard/features/backups/components/CreateBackupForm';
import type { BackupOptionsDto } from '@jellyfin/sdk/lib/generated-client/models/backup-options-dto';
import type { BackupManifestDto } from '@jellyfin/sdk/lib/generated-client/models/backup-manifest-dto';
import { useCreateBackup } from 'apps/dashboard/features/backups/api/useCreateBackup';
import BackupProgressDialog from 'apps/dashboard/features/backups/components/BackupProgressDialog';
import Backup from 'apps/dashboard/features/backups/components/Backup';
import SimpleAlert from 'components/SimpleAlert';
import RestoreConfirmationDialog from 'apps/dashboard/features/backups/components/RestoreConfirmationDialog';
import { useRestoreBackup } from 'apps/dashboard/features/backups/api/useRestoreBackup';
import RestoreProgressDialog from 'apps/dashboard/features/backups/components/RestoreProgressDialog';
import { useApi } from 'hooks/useApi';
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
export const Component = () => {
const { api } = useApi();
const { data: backups, isPending, isError } = useBackups();
const [ isCreateFormOpen, setIsCreateFormOpen ] = useState(false);
const [ backupInProgress, setBackupInProgress ] = useState(false);
const [ restoreInProgress, setRestoreInProgress ] = useState(false);
const [ isRestoreSuccess, setIsRestoreSuccess ] = useState(false);
const [ isErrorOccurred, setIsErrorOccurred ] = useState(false);
const [ isRestoreDialogOpen, setIsRestoreDialogOpen ] = useState(false);
const [ backupToRestore, setBackupToRestore ] = useState<BackupManifestDto | null>(null);
const createBackup = useCreateBackup();
const restoreBackup = useRestoreBackup();
const onCreateClick = useCallback(() => {
setIsCreateFormOpen(true);
}, []);
const onCreateFormClose = useCallback(() => {
setIsCreateFormOpen(false);
}, []);
const onRestoreDialogClose = useCallback(() => {
setIsRestoreDialogOpen(false);
}, []);
const onErrorAlertClose = useCallback(() => {
setIsErrorOccurred(false);
}, []);
const onRestoreSuccessAlertClose = useCallback(() => {
setIsRestoreSuccess(false);
}, []);
const onBackupCreate = useCallback((backupOptions: BackupOptionsDto) => {
setBackupInProgress(true);
setIsCreateFormOpen(false);
createBackup.mutate(backupOptions, {
onError: () => {
setIsErrorOccurred(true);
},
onSettled: () => {
setBackupInProgress(false);
}
});
}, [ createBackup ]);
const promptRestore = useCallback((backup: BackupManifestDto) => {
setIsRestoreDialogOpen(true);
setBackupToRestore(backup);
}, []);
const onRestoreConfirm = useCallback(() => {
if (backupToRestore?.Path) {
restoreBackup.mutate(backupToRestore?.Path, {
onSuccess: () => {
setRestoreInProgress(true);
},
onError: () => {
setIsErrorOccurred(true);
},
onSettled: () => {
setIsRestoreDialogOpen(false);
}
});
}
}, [backupToRestore, restoreBackup]);
useEffect(() => {
if (restoreInProgress) {
const serverCheckInterval = setInterval(() => {
void getSystemApi(api!)
.getPublicSystemInfo()
.then(() => {
setRestoreInProgress(false);
setIsRestoreSuccess(true);
clearInterval(serverCheckInterval);
}).catch(() => {
// Server is still down
});
}, 5000);
return () => {
clearInterval(serverCheckInterval);
};
}
}, [api, restoreInProgress]);
if (isPending) {
return <Loading />;
}
return (
<Page
id='backupsPage'
title={globalize.translate('HeaderBackups')}
className='mainAnimatedPage type-interior'
>
<BackupProgressDialog open={backupInProgress} />
<RestoreProgressDialog open={restoreInProgress} />
<CreateBackupForm
open={isCreateFormOpen}
onClose={onCreateFormClose}
onCreate={onBackupCreate}
/>
<SimpleAlert
open={isErrorOccurred}
text={globalize.translate('UnknownError')}
onClose={onErrorAlertClose}
/>
<SimpleAlert
open={isRestoreSuccess}
title={globalize.translate('Success')}
text={globalize.translate('MessageRestoreSuccess')}
onClose={onRestoreSuccessAlertClose}
/>
<RestoreConfirmationDialog
open={isRestoreDialogOpen}
onClose={onRestoreDialogClose}
onConfirm={onRestoreConfirm}
/>
<Box className='content-primary'>
{isError ? (
<Alert severity='error'>{globalize.translate('BackupsPageLoadError')}</Alert>
) : (
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('HeaderBackups')}
</Typography>
<Typography>
{globalize.translate('HeaderBackupsHelp')}
</Typography>
<Button
sx={{ alignSelf: 'flex-start' }}
startIcon={<AddIcon />}
onClick={onCreateClick}
>
{globalize.translate('ButtonCreateBackup')}
</Button>
<Box className='readOnlyContent'>
{backups.length > 0 && (
<List sx={{ bgcolor: 'background.paper' }}>
{backups.map(backup => {
return <Backup
key={backup.Path}
backup={backup}
onRestore={promptRestore}
/>;
})}
</List>
)}
</Box>
</Stack>
)}
</Box>
</Page>
);
};
+15
View File
@@ -76,6 +76,7 @@
"Backdrop": "Backdrop",
"Backdrops": "Backdrops",
"BackdropScreensaver": "Backdrop Screensaver",
"BackupsPageLoadError": "Failed to load backups page",
"Banner": "Banner",
"BirthDateValue": "Born: {0}",
"BirthLocation": "Birth location",
@@ -104,6 +105,7 @@
"ButtonCast": "Cast to Device",
"ButtonChangeServer": "Change Server",
"ButtonClose": "Close",
"ButtonCreateBackup": "Create Backup",
"ButtonEditOtherUserPreferences": "Edit this user's profile, image and personal preferences.",
"ButtonEditUser": "Edit user",
"ButtonForgotPassword": "Forgot Password",
@@ -190,6 +192,7 @@
"CopyStreamURLSuccess": "URL copied successfully.",
"CopyLogSuccess": "Log contents copied successfully.",
"CoverArtist": "Cover artist",
"Create": "Create",
"Creator": "Creator",
"CriticRating": "Critics rating",
"Cursive": "Cursive",
@@ -388,6 +391,7 @@
"HeaderAudioSettings": "Audio Settings",
"HeaderAutoDiscovery": "Network Discovery",
"HeaderBlockItemsWithNoRating": "Block items with no or unrecognized rating information",
"HeaderBackupsHelp": "Create and restore snapshots of your server which can include the database, metadata, subtitles and trickplay.",
"HeaderBranding": "Branding",
"HeaderCancelRecording": "Cancel Recording",
"HeaderCancelSeries": "Cancel Series",
@@ -617,6 +621,7 @@
"LabelAutomaticallyRefreshInternetMetadataEvery": "Automatically refresh metadata from the internet",
"LabelAutomaticDiscovery": "Enable Auto Discovery",
"LabelAutomaticDiscoveryHelp": "Allow applications to automatically detect Jellyfin by using UDP port 7359.",
"LabelBackupsUnavailable": "No backups available",
"LabelBaseUrl": "Base URL",
"LabelBaseUrlHelp": "Add a custom subdirectory to the server URL. For example: <code>http://example.com/<b>&lt;baseurl&gt;</b></code>",
"LabelBackdropScreensaverInterval": "Backdrop Screensaver Interval",
@@ -661,6 +666,7 @@
"LabelCustomTagDelimiters": "Custom Tag Delimiter",
"LabelCustomTagDelimitersHelp": "Characters to be treated as delimiters to separate tags.",
"LabelDashboardTheme": "Server Dashboard theme",
"LabelDatabase": "Database",
"LabelDate": "Date",
"LabelDateAdded": "Date added",
"LabelDateAddedBehavior": "Date added behavior for new content",
@@ -881,6 +887,7 @@
"LabelRepositoryUrlHelp": "The location of the repository manifest you want to include.",
"LabelRequireHttps": "Require HTTPS",
"LabelRequireHttpsHelp": "If checked, the server will automatically redirect all requests over HTTP to HTTPS. This has no effect if the server is not listening on HTTPS.",
"LabelRestore": "Restore",
"LabelRuntimeMinutes": "Runtime",
"LabelSaveLocalMetadata": "Save artwork into media folders",
"LabelSaveLocalMetadataHelp": "Saving artwork into media folders will put them in a place where they can be easily edited.",
@@ -1109,6 +1116,8 @@
"MessageAddRepository": "If you wish to add a repository, click the button next to the header and fill out the requested information.",
"MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?",
"MessageAreYouSureYouWishToRemoveMediaFolder": "Are you sure you wish to remove this media folder?",
"MessageBackupDisclaimer": "Depending on the size of your library, the backup process may take a while.",
"MessageBackupInProgress": "Backup in progress",
"MessageBrowsePluginCatalog": "Browse our plugin catalog to view available plugins.",
"MessageCancelSeriesTimerError": "An error occurred while canceling the series timer",
"MessageCancelTimerError": "An error occurred while canceling the timer",
@@ -1163,6 +1172,9 @@
"MessageReenableUser": "See below to reenable",
"MessageRenameMediaFolder": "Renaming a media library will cause all metadata to be lost, proceed with caution.",
"MessageRepositoryInstallDisclaimer": "WARNING: Installing a third party plugin repository carries risks. It may contain unstable or malicious code, and may change at any time. Only install repositories from authors that you trust.",
"MessageRestoreDisclaimer": "WARNING: You are about to restore from a previous backup. Your server will be rendered unusable for the duration of the process and any new data created since the backup will be lost.",
"MessageRestoreInProgress": "Restore in progress",
"MessageRestoreSuccess": "Restore successfully completed",
"MessageSent": "Message sent.",
"MessageSplitVersionsError": "An error occurred while splitting versions",
"MessageSyncPlayCreateGroupDenied": "Permission required to create a group.",
@@ -1185,6 +1197,7 @@
"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.",
"MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.",
"MessageWaitingForServer": "Waiting for server...",
"MetadataImagesLoadError": "Failed to load metadata settings",
"MetadataManager": "Metadata Manager",
"MetadataNfoLoadError": "Failed to load metadata NFO settings",
@@ -1565,12 +1578,14 @@
"SubtitleVerticalPositionHelp": "Line number where text appears. Positive numbers indicate top down. Negative numbers indicate bottom up.",
"SubtitleWhite": "White",
"SubtitleYellow": "Yellow",
"Success": "Success",
"Suggestions": "Suggestions",
"Sunday": "Sunday",
"SyncPlayAccessHelp": "The SyncPlay feature enables to sync playback with other devices. Select the level of access this user has to the SyncPlay.",
"SyncPlayGroupDefaultTitle": "{0}'s group",
"TabAccess": "Access",
"TabAdvanced": "Advanced",
"HeaderBackups": "Backups",
"TabCatalog": "Catalog",
"TabDashboard": "Dashboard",
"TabLatest": "Recently Added",