diff --git a/src/apps/dashboard/components/drawer/sections/AdvancedDrawerSection.tsx b/src/apps/dashboard/components/drawer/sections/AdvancedDrawerSection.tsx index 97d8f2671..940520528 100644 --- a/src/apps/dashboard/components/drawer/sections/AdvancedDrawerSection.tsx +++ b/src/apps/dashboard/components/drawer/sections/AdvancedDrawerSection.tsx @@ -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 = () => { + + + + + + + + diff --git a/src/apps/dashboard/features/backups/api/useBackups.ts b/src/apps/dashboard/features/backups/api/useBackups.ts new file mode 100644 index 000000000..842879b4e --- /dev/null +++ b/src/apps/dashboard/features/backups/api/useBackups.ts @@ -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 + }); +}; diff --git a/src/apps/dashboard/features/backups/api/useCreateBackup.ts b/src/apps/dashboard/features/backups/api/useCreateBackup.ts new file mode 100644 index 000000000..19861d096 --- /dev/null +++ b/src/apps/dashboard/features/backups/api/useCreateBackup.ts @@ -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 ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/backups/api/useRestoreBackup.ts b/src/apps/dashboard/features/backups/api/useRestoreBackup.ts new file mode 100644 index 000000000..0ecafbfb5 --- /dev/null +++ b/src/apps/dashboard/features/backups/api/useRestoreBackup.ts @@ -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 + } + }) + ) + }); +}; diff --git a/src/apps/dashboard/features/backups/components/Backup.tsx b/src/apps/dashboard/features/backups/components/Backup.tsx new file mode 100644 index 000000000..e7c622d3b --- /dev/null +++ b/src/apps/dashboard/features/backups/components/Backup.tsx @@ -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 = ({ 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 ( + <> + + + + + + + } + > + + + + + + ); +}; + +export default Backup; diff --git a/src/apps/dashboard/features/backups/components/BackupInfoDialog.tsx b/src/apps/dashboard/features/backups/components/BackupInfoDialog.tsx new file mode 100644 index 000000000..d83a768fb --- /dev/null +++ b/src/apps/dashboard/features/backups/components/BackupInfoDialog.tsx @@ -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 = ({ backup, open, onClose }: IProps) => { + const copyPath = useCallback(async () => { + if (backup.Path) { + await copy(backup.Path); + toast({ text: globalize.translate('Copied') }); + } + }, [ backup.Path ]); + + return ( + + + {backup.DateCreated} + + + + + + + {globalize.translate('LabelPath')} + + {backup.Path} + + + + + + + {globalize.translate('LabelVersion')} + {backup.ServerVersion} + + + + + + + } + label={globalize.translate('LabelDatabase')} + /> + + + + + } + label={globalize.translate('LabelMetadata')} + /> + + + + + } + label={globalize.translate('Subtitles')} + /> + + + + + } + label={globalize.translate('Trickplay')} + /> + + + + + + + + + + ); +}; + +export default BackupInfoDialog; diff --git a/src/apps/dashboard/features/backups/components/BackupProgressDialog.tsx b/src/apps/dashboard/features/backups/components/BackupProgressDialog.tsx new file mode 100644 index 000000000..bcf158d2a --- /dev/null +++ b/src/apps/dashboard/features/backups/components/BackupProgressDialog.tsx @@ -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 = ({ open }) => { + return ( + + {globalize.translate('MessageBackupInProgress')} + + + + + ); +}; + +export default BackupProgressDialog; diff --git a/src/apps/dashboard/features/backups/components/CreateBackupForm.tsx b/src/apps/dashboard/features/backups/components/CreateBackupForm.tsx new file mode 100644 index 000000000..000af98a1 --- /dev/null +++ b/src/apps/dashboard/features/backups/components/CreateBackupForm.tsx @@ -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 = ({ open, onClose, onCreate }) => { + const onSubmit = useCallback((e: React.FormEvent) => { + 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 ( + + {globalize.translate('ButtonCreateBackup')} + + + + + {globalize.translate('MessageBackupDisclaimer')} + + + + + } + label={globalize.translate('LabelDatabase')} + /> + + + + + } + label={globalize.translate('LabelMetadata')} + /> + + + + + } + label={globalize.translate('Subtitles')} + /> + + + + + } + label={globalize.translate('Trickplay')} + /> + + + + + + + + + + + ); +}; + +export default CreateBackupForm; diff --git a/src/apps/dashboard/features/backups/components/RestoreConfirmationDialog.tsx b/src/apps/dashboard/features/backups/components/RestoreConfirmationDialog.tsx new file mode 100644 index 000000000..3b51204b6 --- /dev/null +++ b/src/apps/dashboard/features/backups/components/RestoreConfirmationDialog.tsx @@ -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 = ({ open, onClose, onConfirm }: IProps) => { + return ( + + + {globalize.translate('LabelRestore')} + + + + + {globalize.translate('MessageRestoreDisclaimer')} + + + + + + + + + ); +}; + +export default RestoreConfirmationDialog; diff --git a/src/apps/dashboard/features/backups/components/RestoreProgressDialog.tsx b/src/apps/dashboard/features/backups/components/RestoreProgressDialog.tsx new file mode 100644 index 000000000..937aaafba --- /dev/null +++ b/src/apps/dashboard/features/backups/components/RestoreProgressDialog.tsx @@ -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 = ({ open }) => { + return ( + + {globalize.translate('MessageRestoreInProgress')} + + + {globalize.translate('MessageWaitingForServer')} + + + + + ); +}; + +export default RestoreProgressDialog; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 2ab21c5d4..10d4fb838 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -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 }, diff --git a/src/apps/dashboard/routes/backups/index.tsx b/src/apps/dashboard/routes/backups/index.tsx new file mode 100644 index 000000000..047e6fef8 --- /dev/null +++ b/src/apps/dashboard/routes/backups/index.tsx @@ -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(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 ; + } + + return ( + + + + + + + + + {isError ? ( + {globalize.translate('BackupsPageLoadError')} + ) : ( + + + {globalize.translate('HeaderBackups')} + + + {globalize.translate('HeaderBackupsHelp')} + + + + + + {backups.length > 0 && ( + + {backups.map(backup => { + return ; + })} + + )} + + + )} + + + ); +}; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 5402157b2..76e4d8c4b 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -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: http://example.com/<baseurl>", "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",