From f900d623dc46b18a4d33c98fecf24a980ac45b39 Mon Sep 17 00:00:00 2001
From: Mark McDowall <mark@mcdowall.ca>
Date: Sun, 14 Jul 2024 16:42:35 -0700
Subject: [PATCH] New: Allow major version updates to be installed

(cherry picked from commit 0e95ba2021b23cc65bce0a0620dd48e355250dab)
---
 frontend/src/App/AppRoutes.js                 |   4 +-
 frontend/src/App/State/SystemAppState.ts      |   3 +
 frontend/src/System/Updates/UpdateChanges.js  |  52 ---
 frontend/src/System/Updates/UpdateChanges.tsx |  43 +++
 frontend/src/System/Updates/Updates.js        | 249 --------------
 frontend/src/System/Updates/Updates.tsx       | 303 ++++++++++++++++++
 .../src/System/Updates/UpdatesConnector.js    |  98 ------
 frontend/src/typings/Update.ts                |  20 ++
 src/NzbDrone.Core/Localization/Core/ar.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/bg.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/ca.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/cs.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/da.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/de.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/el.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/en.json   |   6 +-
 src/NzbDrone.Core/Localization/Core/es.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/fi.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/fr.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/he.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/hi.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/hu.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/is.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/it.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/ja.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/ko.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/nl.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/pl.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/pt.json   |   2 +-
 .../Localization/Core/pt_BR.json              |   2 +-
 src/NzbDrone.Core/Localization/Core/ro.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/ru.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/sv.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/th.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/tr.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/uk.json   |   2 +-
 src/NzbDrone.Core/Localization/Core/vi.json   |   2 +-
 .../Localization/Core/zh_CN.json              |   2 +-
 .../Commands/ApplicationCheckUpdateCommand.cs |   2 +
 .../Commands/ApplicationUpdateCommand.cs      |   1 +
 .../Update/InstallUpdateService.cs            |  14 +-
 .../Update/UpdatePackageProvider.cs           |   1 +
 42 files changed, 419 insertions(+), 435 deletions(-)
 delete mode 100644 frontend/src/System/Updates/UpdateChanges.js
 create mode 100644 frontend/src/System/Updates/UpdateChanges.tsx
 delete mode 100644 frontend/src/System/Updates/Updates.js
 create mode 100644 frontend/src/System/Updates/Updates.tsx
 delete mode 100644 frontend/src/System/Updates/UpdatesConnector.js
 create mode 100644 frontend/src/typings/Update.ts

diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js
index c5207e454..fa293dc22 100644
--- a/frontend/src/App/AppRoutes.js
+++ b/frontend/src/App/AppRoutes.js
@@ -31,7 +31,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
 import Logs from 'System/Logs/Logs';
 import Status from 'System/Status/Status';
 import Tasks from 'System/Tasks/Tasks';
-import UpdatesConnector from 'System/Updates/UpdatesConnector';
+import Updates from 'System/Updates/Updates';
 import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
 import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
 import MissingConnector from 'Wanted/Missing/MissingConnector';
@@ -228,7 +228,7 @@ function AppRoutes(props) {
 
       <Route
         path="/system/updates"
-        component={UpdatesConnector}
+        component={Updates}
       />
 
       <Route
diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts
index 7bfc6d5fb..1161f0e1e 100644
--- a/frontend/src/App/State/SystemAppState.ts
+++ b/frontend/src/App/State/SystemAppState.ts
@@ -2,18 +2,21 @@ import DiskSpace from 'typings/DiskSpace';
 import Health from 'typings/Health';
 import SystemStatus from 'typings/SystemStatus';
 import Task from 'typings/Task';
+import Update from 'typings/Update';
 import AppSectionState, { AppSectionItemState } from './AppSectionState';
 
 export type DiskSpaceAppState = AppSectionState<DiskSpace>;
 export type HealthAppState = AppSectionState<Health>;
 export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
 export type TaskAppState = AppSectionState<Task>;
+export type UpdateAppState = AppSectionState<Update>;
 
 interface SystemAppState {
   diskSpace: DiskSpaceAppState;
   health: HealthAppState;
   status: SystemStatusAppState;
   tasks: TaskAppState;
+  updates: UpdateAppState;
 }
 
 export default SystemAppState;
diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js
deleted file mode 100644
index a42e420fa..000000000
--- a/frontend/src/System/Updates/UpdateChanges.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
-import styles from './UpdateChanges.css';
-
-class UpdateChanges extends Component {
-
-  //
-  // Render
-
-  render() {
-    const {
-      title,
-      changes
-    } = this.props;
-
-    if (changes.length === 0) {
-      return null;
-    }
-
-    const uniqueChanges = [...new Set(changes)];
-
-    return (
-      <div>
-        <div className={styles.title}>{title}</div>
-        <ul>
-          {
-            uniqueChanges.map((change, index) => {
-              const checkChange = change.replace(/#\d{4,5}\b/g, (match, contents) => {
-                return `[${match}](https://github.com/Radarr/Radarr/issues/${match.substring(1)})`;
-              });
-
-              return (
-                <li key={index}>
-                  <InlineMarkdown data={checkChange} />
-                </li>
-              );
-            })
-          }
-        </ul>
-      </div>
-    );
-  }
-
-}
-
-UpdateChanges.propTypes = {
-  title: PropTypes.string.isRequired,
-  changes: PropTypes.arrayOf(PropTypes.string)
-};
-
-export default UpdateChanges;
diff --git a/frontend/src/System/Updates/UpdateChanges.tsx b/frontend/src/System/Updates/UpdateChanges.tsx
new file mode 100644
index 000000000..20338d011
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import styles from './UpdateChanges.css';
+
+interface UpdateChangesProps {
+  title: string;
+  changes: string[];
+}
+
+function UpdateChanges(props: UpdateChangesProps) {
+  const { title, changes } = props;
+
+  if (changes.length === 0) {
+    return null;
+  }
+
+  const uniqueChanges = [...new Set(changes)];
+
+  return (
+    <div>
+      <div className={styles.title}>{title}</div>
+      <ul>
+        {uniqueChanges.map((change, index) => {
+          const checkChange = change.replace(
+            /#\d{4,5}\b/g,
+            (match) =>
+              `[${match}](https://github.com/Radarr/Radarr/issues/${match.substring(
+                1
+              )})`
+          );
+
+          return (
+            <li key={index}>
+              <InlineMarkdown data={checkChange} />
+            </li>
+          );
+        })}
+      </ul>
+    </div>
+  );
+}
+
+export default UpdateChanges;
diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js
deleted file mode 100644
index aae373765..000000000
--- a/frontend/src/System/Updates/Updates.js
+++ /dev/null
@@ -1,249 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component, Fragment } from 'react';
-import Alert from 'Components/Alert';
-import Icon from 'Components/Icon';
-import Label from 'Components/Label';
-import SpinnerButton from 'Components/Link/SpinnerButton';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
-import PageContent from 'Components/Page/PageContent';
-import PageContentBody from 'Components/Page/PageContentBody';
-import { icons, kinds } from 'Helpers/Props';
-import formatDate from 'Utilities/Date/formatDate';
-import formatDateTime from 'Utilities/Date/formatDateTime';
-import translate from 'Utilities/String/translate';
-import UpdateChanges from './UpdateChanges';
-import styles from './Updates.css';
-
-class Updates extends Component {
-
-  //
-  // Render
-
-  render() {
-    const {
-      currentVersion,
-      isFetching,
-      isPopulated,
-      updatesError,
-      generalSettingsError,
-      items,
-      isInstallingUpdate,
-      updateMechanism,
-      updateMechanismMessage,
-      shortDateFormat,
-      longDateFormat,
-      timeFormat,
-      onInstallLatestPress
-    } = this.props;
-
-    const hasError = !!(updatesError || generalSettingsError);
-    const hasUpdates = isPopulated && !hasError && items.length > 0;
-    const noUpdates = isPopulated && !hasError && !items.length;
-    const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true });
-    const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
-
-    const externalUpdaterPrefix = translate('UpdateRadarrDirectlyLoadError');
-    const externalUpdaterMessages = {
-      external: translate('ExternalUpdater'),
-      apt: translate('AptUpdater'),
-      docker: translate('DockerUpdater')
-    };
-
-    return (
-      <PageContent title={translate('Updates')}>
-        <PageContentBody>
-          {
-            !isPopulated && !hasError &&
-              <LoadingIndicator />
-          }
-
-          {
-            noUpdates &&
-              <Alert kind={kinds.INFO}>
-                {translate('NoUpdatesAreAvailable')}
-              </Alert>
-          }
-
-          {
-            hasUpdateToInstall &&
-              <div className={styles.messageContainer}>
-                {
-                  updateMechanism === 'builtIn' || updateMechanism === 'script' ?
-                    <SpinnerButton
-                      className={styles.updateAvailable}
-                      kind={kinds.PRIMARY}
-                      isSpinning={isInstallingUpdate}
-                      onPress={onInstallLatestPress}
-                    >
-                      {translate('InstallLatest')}
-                    </SpinnerButton> :
-
-                    <Fragment>
-                      <Icon
-                        name={icons.WARNING}
-                        kind={kinds.WARNING}
-                        size={30}
-                      />
-
-                      <div className={styles.message}>
-                        {externalUpdaterPrefix} <InlineMarkdown data={updateMechanismMessage || externalUpdaterMessages[updateMechanism] || externalUpdaterMessages.external} />
-                      </div>
-                    </Fragment>
-                }
-
-                {
-                  isFetching &&
-                    <LoadingIndicator
-                      className={styles.loading}
-                      size={20}
-                    />
-                }
-              </div>
-          }
-
-          {
-            noUpdateToInstall &&
-              <div className={styles.messageContainer}>
-                <Icon
-                  className={styles.upToDateIcon}
-                  name={icons.CHECK_CIRCLE}
-                  size={30}
-                />
-                <div className={styles.message}>
-                  {translate('OnLatestVersion')}
-                </div>
-
-                {
-                  isFetching &&
-                    <LoadingIndicator
-                      className={styles.loading}
-                      size={20}
-                    />
-                }
-              </div>
-          }
-
-          {
-            hasUpdates &&
-              <div>
-                {
-                  items.map((update) => {
-                    const hasChanges = !!update.changes;
-
-                    return (
-                      <div
-                        key={update.version}
-                        className={styles.update}
-                      >
-                        <div className={styles.info}>
-                          <div className={styles.version}>{update.version}</div>
-                          <div className={styles.space}>&mdash;</div>
-                          <div
-                            className={styles.date}
-                            title={formatDateTime(update.releaseDate, longDateFormat, timeFormat)}
-                          >
-                            {formatDate(update.releaseDate, shortDateFormat)}
-                          </div>
-
-                          {
-                            update.branch === 'master' ?
-                              null :
-                              <Label
-                                className={styles.label}
-                              >
-                                {update.branch}
-                              </Label>
-                          }
-
-                          {
-                            update.version === currentVersion ?
-                              <Label
-                                className={styles.label}
-                                kind={kinds.SUCCESS}
-                                title={formatDateTime(update.installedOn, longDateFormat, timeFormat)}
-                              >
-                                {translate('CurrentlyInstalled')}
-                              </Label> :
-                              null
-                          }
-
-                          {
-                            update.version !== currentVersion && update.installedOn ?
-                              <Label
-                                className={styles.label}
-                                kind={kinds.INVERSE}
-                                title={formatDateTime(update.installedOn, longDateFormat, timeFormat)}
-                              >
-                                {translate('PreviouslyInstalled')}
-                              </Label> :
-                              null
-                          }
-                        </div>
-
-                        {
-                          !hasChanges &&
-                            <div>
-                              {translate('MaintenanceRelease')}
-                            </div>
-                        }
-
-                        {
-                          hasChanges &&
-                            <div className={styles.changes}>
-                              <UpdateChanges
-                                title={translate('New')}
-                                changes={update.changes.new}
-                              />
-
-                              <UpdateChanges
-                                title={translate('Fixed')}
-                                changes={update.changes.fixed}
-                              />
-                            </div>
-                        }
-                      </div>
-                    );
-                  })
-                }
-              </div>
-          }
-
-          {
-            !!updatesError &&
-              <div>
-                {translate('FailedToFetchUpdates')}
-              </div>
-          }
-
-          {
-            !!generalSettingsError &&
-              <div>
-                {translate('FailedToUpdateSettings')}
-              </div>
-          }
-        </PageContentBody>
-      </PageContent>
-    );
-  }
-
-}
-
-Updates.propTypes = {
-  currentVersion: PropTypes.string.isRequired,
-  isFetching: PropTypes.bool.isRequired,
-  isPopulated: PropTypes.bool.isRequired,
-  updatesError: PropTypes.object,
-  generalSettingsError: PropTypes.object,
-  items: PropTypes.array.isRequired,
-  isInstallingUpdate: PropTypes.bool.isRequired,
-  updateMechanism: PropTypes.string,
-  updateMechanismMessage: PropTypes.string,
-  shortDateFormat: PropTypes.string.isRequired,
-  longDateFormat: PropTypes.string.isRequired,
-  timeFormat: PropTypes.string.isRequired,
-  onInstallLatestPress: PropTypes.func.isRequired
-};
-
-export default Updates;
diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx
new file mode 100644
index 000000000..df635e5d7
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.tsx
@@ -0,0 +1,303 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import * as commandNames from 'Commands/commandNames';
+import Alert from 'Components/Alert';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import { icons, kinds } from 'Helpers/Props';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { UpdateMechanism } from 'typings/Settings/General';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import translate from 'Utilities/String/translate';
+import UpdateChanges from './UpdateChanges';
+import styles from './Updates.css';
+
+const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i;
+
+function createUpdatesSelector() {
+  return createSelector(
+    (state: AppState) => state.system.updates,
+    (state: AppState) => state.settings.general,
+    (updates, generalSettings) => {
+      const { error: updatesError, items } = updates;
+
+      const isFetching = updates.isFetching || generalSettings.isFetching;
+      const isPopulated = updates.isPopulated && generalSettings.isPopulated;
+
+      return {
+        isFetching,
+        isPopulated,
+        updatesError,
+        generalSettingsError: generalSettings.error,
+        items,
+        updateMechanism: generalSettings.item.updateMechanism,
+      };
+    }
+  );
+}
+
+function Updates() {
+  const currentVersion = useSelector((state: AppState) => state.app.version);
+  const { packageUpdateMechanismMessage } = useSelector(
+    createSystemStatusSelector()
+  );
+  const { shortDateFormat, longDateFormat, timeFormat } = useSelector(
+    createUISettingsSelector()
+  );
+  const isInstallingUpdate = useSelector(
+    createCommandExecutingSelector(commandNames.APPLICATION_UPDATE)
+  );
+
+  const {
+    isFetching,
+    isPopulated,
+    updatesError,
+    generalSettingsError,
+    items,
+    updateMechanism,
+  } = useSelector(createUpdatesSelector());
+
+  const dispatch = useDispatch();
+  const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false);
+  const hasError = !!(updatesError || generalSettingsError);
+  const hasUpdates = isPopulated && !hasError && items.length > 0;
+  const noUpdates = isPopulated && !hasError && !items.length;
+
+  const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError');
+  const externalUpdaterMessages: Partial<Record<UpdateMechanism, string>> = {
+    external: translate('ExternalUpdater'),
+    apt: translate('AptUpdater'),
+    docker: translate('DockerUpdater'),
+  };
+
+  const { isMajorUpdate, hasUpdateToInstall } = useMemo(() => {
+    const majorVersion = parseInt(
+      currentVersion.match(VERSION_REGEX)?.[0] ?? '0'
+    );
+
+    const latestVersion = items[0]?.version;
+    const latestMajorVersion = parseInt(
+      latestVersion?.match(VERSION_REGEX)?.[0] ?? '0'
+    );
+
+    return {
+      isMajorUpdate: latestMajorVersion > majorVersion,
+      hasUpdateToInstall: items.some(
+        (update) => update.installable && update.latest
+      ),
+    };
+  }, [currentVersion, items]);
+
+  const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
+
+  const handleInstallLatestPress = useCallback(() => {
+    if (isMajorUpdate) {
+      setIsMajorUpdateModalOpen(true);
+    } else {
+      dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE }));
+    }
+  }, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]);
+
+  const handleInstallLatestMajorVersionPress = useCallback(() => {
+    setIsMajorUpdateModalOpen(false);
+
+    dispatch(
+      executeCommand({
+        name: commandNames.APPLICATION_UPDATE,
+        installMajorUpdate: true,
+      })
+    );
+  }, [setIsMajorUpdateModalOpen, dispatch]);
+
+  const handleCancelMajorVersionPress = useCallback(() => {
+    setIsMajorUpdateModalOpen(false);
+  }, [setIsMajorUpdateModalOpen]);
+
+  useEffect(() => {
+    dispatch(fetchUpdates());
+    dispatch(fetchGeneralSettings());
+  }, [dispatch]);
+
+  return (
+    <PageContent title={translate('Updates')}>
+      <PageContentBody>
+        {isPopulated || hasError ? null : <LoadingIndicator />}
+
+        {noUpdates ? (
+          <Alert kind={kinds.INFO}>{translate('NoUpdatesAreAvailable')}</Alert>
+        ) : null}
+
+        {hasUpdateToInstall ? (
+          <div className={styles.messageContainer}>
+            {updateMechanism === 'builtIn' || updateMechanism === 'script' ? (
+              <SpinnerButton
+                kind={kinds.PRIMARY}
+                isSpinning={isInstallingUpdate}
+                onPress={handleInstallLatestPress}
+              >
+                {translate('InstallLatest')}
+              </SpinnerButton>
+            ) : (
+              <>
+                <Icon name={icons.WARNING} kind={kinds.WARNING} size={30} />
+
+                <div className={styles.message}>
+                  {externalUpdaterPrefix}{' '}
+                  <InlineMarkdown
+                    data={
+                      packageUpdateMechanismMessage ||
+                      externalUpdaterMessages[updateMechanism] ||
+                      externalUpdaterMessages.external
+                    }
+                  />
+                </div>
+              </>
+            )}
+
+            {isFetching ? (
+              <LoadingIndicator className={styles.loading} size={20} />
+            ) : null}
+          </div>
+        ) : null}
+
+        {noUpdateToInstall && (
+          <div className={styles.messageContainer}>
+            <Icon
+              className={styles.upToDateIcon}
+              name={icons.CHECK_CIRCLE}
+              size={30}
+            />
+            <div className={styles.message}>{translate('OnLatestVersion')}</div>
+
+            {isFetching && (
+              <LoadingIndicator className={styles.loading} size={20} />
+            )}
+          </div>
+        )}
+
+        {hasUpdates && (
+          <div>
+            {items.map((update) => {
+              return (
+                <div key={update.version} className={styles.update}>
+                  <div className={styles.info}>
+                    <div className={styles.version}>{update.version}</div>
+                    <div className={styles.space}>&mdash;</div>
+                    <div
+                      className={styles.date}
+                      title={formatDateTime(
+                        update.releaseDate,
+                        longDateFormat,
+                        timeFormat
+                      )}
+                    >
+                      {formatDate(update.releaseDate, shortDateFormat)}
+                    </div>
+
+                    {update.branch === 'main' ? null : (
+                      <Label className={styles.label}>{update.branch}</Label>
+                    )}
+
+                    {update.version === currentVersion ? (
+                      <Label
+                        className={styles.label}
+                        kind={kinds.SUCCESS}
+                        title={formatDateTime(
+                          update.installedOn,
+                          longDateFormat,
+                          timeFormat
+                        )}
+                      >
+                        {translate('CurrentlyInstalled')}
+                      </Label>
+                    ) : null}
+
+                    {update.version !== currentVersion && update.installedOn ? (
+                      <Label
+                        className={styles.label}
+                        kind={kinds.INVERSE}
+                        title={formatDateTime(
+                          update.installedOn,
+                          longDateFormat,
+                          timeFormat
+                        )}
+                      >
+                        {translate('PreviouslyInstalled')}
+                      </Label>
+                    ) : null}
+                  </div>
+
+                  {update.changes ? (
+                    <div>
+                      <UpdateChanges
+                        title={translate('New')}
+                        changes={update.changes.new}
+                      />
+
+                      <UpdateChanges
+                        title={translate('Fixed')}
+                        changes={update.changes.fixed}
+                      />
+                    </div>
+                  ) : (
+                    <div>{translate('MaintenanceRelease')}</div>
+                  )}
+                </div>
+              );
+            })}
+          </div>
+        )}
+
+        {updatesError ? (
+          <Alert kind={kinds.WARNING}>
+            {translate('FailedToFetchUpdates')}
+          </Alert>
+        ) : null}
+
+        {generalSettingsError ? (
+          <Alert kind={kinds.DANGER}>
+            {translate('FailedToUpdateSettings')}
+          </Alert>
+        ) : null}
+
+        <ConfirmModal
+          isOpen={isMajorUpdateModalOpen}
+          kind={kinds.WARNING}
+          title={translate('InstallMajorVersionUpdate')}
+          message={
+            <div>
+              <div>{translate('InstallMajorVersionUpdateMessage')}</div>
+              <div>
+                <InlineMarkdown
+                  data={translate('InstallMajorVersionUpdateMessageLink', {
+                    domain: 'radarr.video',
+                    url: 'https://radarr.video/#downloads',
+                  })}
+                />
+              </div>
+            </div>
+          }
+          confirmLabel={translate('Install')}
+          onConfirm={handleInstallLatestMajorVersionPress}
+          onCancel={handleCancelMajorVersionPress}
+        />
+      </PageContentBody>
+    </PageContent>
+  );
+}
+
+export default Updates;
diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js
deleted file mode 100644
index 77d75dbda..000000000
--- a/frontend/src/System/Updates/UpdatesConnector.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import * as commandNames from 'Commands/commandNames';
-import { executeCommand } from 'Store/Actions/commandActions';
-import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
-import { fetchUpdates } from 'Store/Actions/systemActions';
-import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
-import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import Updates from './Updates';
-
-function createMapStateToProps() {
-  return createSelector(
-    (state) => state.app.version,
-    createSystemStatusSelector(),
-    (state) => state.system.updates,
-    (state) => state.settings.general,
-    createUISettingsSelector(),
-    createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
-    (
-      currentVersion,
-      status,
-      updates,
-      generalSettings,
-      uiSettings,
-      isInstallingUpdate
-    ) => {
-      const {
-        error: updatesError,
-        items
-      } = updates;
-
-      const isFetching = updates.isFetching || generalSettings.isFetching;
-      const isPopulated = updates.isPopulated && generalSettings.isPopulated;
-
-      return {
-        currentVersion,
-        isFetching,
-        isPopulated,
-        updatesError,
-        generalSettingsError: generalSettings.error,
-        items,
-        isInstallingUpdate,
-        updateMechanism: generalSettings.item.updateMechanism,
-        updateMechanismMessage: status.packageUpdateMechanismMessage,
-        shortDateFormat: uiSettings.shortDateFormat,
-        longDateFormat: uiSettings.longDateFormat,
-        timeFormat: uiSettings.timeFormat
-      };
-    }
-  );
-}
-
-const mapDispatchToProps = {
-  dispatchFetchUpdates: fetchUpdates,
-  dispatchFetchGeneralSettings: fetchGeneralSettings,
-  dispatchExecuteCommand: executeCommand
-};
-
-class UpdatesConnector extends Component {
-
-  //
-  // Lifecycle
-
-  componentDidMount() {
-    this.props.dispatchFetchUpdates();
-    this.props.dispatchFetchGeneralSettings();
-  }
-
-  //
-  // Listeners
-
-  onInstallLatestPress = () => {
-    this.props.dispatchExecuteCommand({ name: commandNames.APPLICATION_UPDATE });
-  };
-
-  //
-  // Render
-
-  render() {
-    return (
-      <Updates
-        onInstallLatestPress={this.onInstallLatestPress}
-        {...this.props}
-      />
-    );
-  }
-}
-
-UpdatesConnector.propTypes = {
-  dispatchFetchUpdates: PropTypes.func.isRequired,
-  dispatchFetchGeneralSettings: PropTypes.func.isRequired,
-  dispatchExecuteCommand: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector);
diff --git a/frontend/src/typings/Update.ts b/frontend/src/typings/Update.ts
new file mode 100644
index 000000000..448b1728d
--- /dev/null
+++ b/frontend/src/typings/Update.ts
@@ -0,0 +1,20 @@
+export interface Changes {
+  new: string[];
+  fixed: string[];
+}
+
+interface Update {
+  version: string;
+  branch: string;
+  releaseDate: string;
+  fileName: string;
+  url: string;
+  installed: boolean;
+  installedOn: string;
+  installable: boolean;
+  latest: boolean;
+  changes: Changes | null;
+  hash: string;
+}
+
+export default Update;
diff --git a/src/NzbDrone.Core/Localization/Core/ar.json b/src/NzbDrone.Core/Localization/Core/ar.json
index b437f7316..018c87cf7 100644
--- a/src/NzbDrone.Core/Localization/Core/ar.json
+++ b/src/NzbDrone.Core/Localization/Core/ar.json
@@ -56,7 +56,7 @@
     "Unlimited": "غير محدود",
     "Ungroup": "فك التجميع",
     "Unavailable": "غير متوفره",
-    "UpdateRadarrDirectlyLoadError": "تعذر تحديث {appName} مباشرة ،",
+    "UpdateAppDirectlyLoadError": "تعذر تحديث {appName} مباشرة ،",
     "UiSettingsLoadError": "تعذر تحميل إعدادات واجهة المستخدم",
     "CalendarLoadError": "تعذر تحميل التقويم",
     "TagsLoadError": "تعذر تحميل العلامات",
diff --git a/src/NzbDrone.Core/Localization/Core/bg.json b/src/NzbDrone.Core/Localization/Core/bg.json
index b80b1e401..a8529a072 100644
--- a/src/NzbDrone.Core/Localization/Core/bg.json
+++ b/src/NzbDrone.Core/Localization/Core/bg.json
@@ -621,7 +621,7 @@
     "UnableToLoadRootFolders": "Не може да се заредят коренови папки",
     "TagsLoadError": "Не може да се заредят маркери",
     "CalendarLoadError": "Календарът не може да се зареди",
-    "UpdateRadarrDirectlyLoadError": "Не може да се актуализира {appName} директно,",
+    "UpdateAppDirectlyLoadError": "Не може да се актуализира {appName} директно,",
     "Ungroup": "Разгрупиране",
     "Unlimited": "Неограничен",
     "UnmappedFilesOnly": "Само немапирани файлове",
diff --git a/src/NzbDrone.Core/Localization/Core/ca.json b/src/NzbDrone.Core/Localization/Core/ca.json
index d84131e64..700a44456 100644
--- a/src/NzbDrone.Core/Localization/Core/ca.json
+++ b/src/NzbDrone.Core/Localization/Core/ca.json
@@ -909,7 +909,7 @@
     "TagsLoadError": "No es poden carregar les etiquetes",
     "CalendarLoadError": "No es pot carregar el calendari",
     "UiSettingsLoadError": "No es pot carregar la configuració de la IU",
-    "UpdateRadarrDirectlyLoadError": "No es pot actualitzar {appName} directament,",
+    "UpdateAppDirectlyLoadError": "No es pot actualitzar {appName} directament,",
     "Unreleased": "No disponible",
     "UnselectAll": "Desseleccioneu-ho tot",
     "UpdateCheckStartupNotWritableMessage": "L'actualització no es pot instal·lar perquè la carpeta d'inici '{startupFolder}' no té permisos d'escriptura per a l'usuari '{userName}'.",
diff --git a/src/NzbDrone.Core/Localization/Core/cs.json b/src/NzbDrone.Core/Localization/Core/cs.json
index 7382eefa6..d6edfbe11 100644
--- a/src/NzbDrone.Core/Localization/Core/cs.json
+++ b/src/NzbDrone.Core/Localization/Core/cs.json
@@ -901,7 +901,7 @@
     "QualityProfilesLoadError": "Nelze načíst profily kvality",
     "UnableToLoadRootFolders": "Nelze načíst kořenové složky",
     "CalendarLoadError": "Kalendář nelze načíst",
-    "UpdateRadarrDirectlyLoadError": "{appName} nelze aktualizovat přímo,",
+    "UpdateAppDirectlyLoadError": "{appName} nelze aktualizovat přímo,",
     "UnmappedFilesOnly": "Pouze nezmapované soubory",
     "UnmappedFolders": "Nezmapované složky",
     "Unmonitored": "Nemonitorováno",
diff --git a/src/NzbDrone.Core/Localization/Core/da.json b/src/NzbDrone.Core/Localization/Core/da.json
index e96e44af2..e3deb2e06 100644
--- a/src/NzbDrone.Core/Localization/Core/da.json
+++ b/src/NzbDrone.Core/Localization/Core/da.json
@@ -159,7 +159,7 @@
     "TagsSettingsSummary": "Se alle tags og hvordan de bruges. Ubrugte tags kan fjernes",
     "Time": "Tid",
     "MediaManagementSettingsLoadError": "Kan ikke indlæse indstillinger for mediestyring",
-    "UpdateRadarrDirectlyLoadError": "Kan ikke opdatere {appName} direkte,",
+    "UpdateAppDirectlyLoadError": "Kan ikke opdatere {appName} direkte,",
     "BindAddressHelpText": "Gyldig IP4-adresse, 'localhost' eller '*' for alle grænseflader",
     "CreateEmptyMovieFoldersHelpText": "Opret manglende filmmapper under diskscanning",
     "CouldNotConnectSignalR": "Kunne ikke oprette forbindelse til SignalR, UI opdateres ikke",
diff --git a/src/NzbDrone.Core/Localization/Core/de.json b/src/NzbDrone.Core/Localization/Core/de.json
index bd21479ca..7e4998d94 100644
--- a/src/NzbDrone.Core/Localization/Core/de.json
+++ b/src/NzbDrone.Core/Localization/Core/de.json
@@ -774,7 +774,7 @@
     "UpgradesAllowed": "Upgrades erlaubt",
     "UnmappedFilesOnly": "Nur nicht zugeordnete Dateien",
     "Unlimited": "Unlimitiert",
-    "UpdateRadarrDirectlyLoadError": "{appName} konnte nicht direkt aktualisiert werden,",
+    "UpdateAppDirectlyLoadError": "{appName} konnte nicht direkt aktualisiert werden,",
     "UnableToLoadManualImportItems": "Einträge für manuelles importieren konnten nicht geladen werden",
     "AlternativeTitlesLoadError": "Alternative Titel konnten nicht geladen werden.",
     "Trigger": "Auslöser",
diff --git a/src/NzbDrone.Core/Localization/Core/el.json b/src/NzbDrone.Core/Localization/Core/el.json
index 90886d17d..a0b2e1309 100644
--- a/src/NzbDrone.Core/Localization/Core/el.json
+++ b/src/NzbDrone.Core/Localization/Core/el.json
@@ -893,7 +893,7 @@
     "UnableToLoadRootFolders": "Δεν είναι δυνατή η φόρτωση ριζικών φακέλων",
     "TagsLoadError": "Δεν είναι δυνατή η φόρτωση ετικετών",
     "CalendarLoadError": "Δεν είναι δυνατή η φόρτωση του ημερολογίου",
-    "UpdateRadarrDirectlyLoadError": "Δεν είναι δυνατή η απευθείας ενημέρωση του {appName},",
+    "UpdateAppDirectlyLoadError": "Δεν είναι δυνατή η απευθείας ενημέρωση του {appName},",
     "Ungroup": "Κατάργηση ομάδας",
     "Unlimited": "Απεριόριστος",
     "UnmappedFilesOnly": "Μόνο μη αντιστοιχισμένα αρχεία",
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index ae7a54cc5..a5dc41ce0 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -795,7 +795,11 @@
   "IndexersSettingsSummary": "Indexers and release restrictions",
   "Info": "Info",
   "InfoUrl": "Info URL",
+  "Install": "Install",
   "InstallLatest": "Install Latest",
+  "InstallMajorVersionUpdate": "Install Update",
+  "InstallMajorVersionUpdateMessage": "This update will install a new major version and may not be compatible with your system. Are you sure you want to install this update?",
+  "InstallMajorVersionUpdateMessageLink": "Please check [{domain}]({url}) for more information.",
   "InstanceName": "Instance Name",
   "InstanceNameHelpText": "Instance name in tab and for Syslog app name",
   "InteractiveImport": "Interactive Import",
@@ -1774,6 +1778,7 @@
   "UnsavedChanges": "Unsaved Changes",
   "UnselectAll": "Unselect All",
   "UpdateAll": "Update All",
+  "UpdateAppDirectlyLoadError": "Unable to update {appName} directly,",
   "UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates",
   "UpdateAvailableHealthCheckMessage": "New update is available: {version}",
   "UpdateCheckStartupNotWritableMessage": "Cannot install update because startup folder '{startupFolder}' is not writable by the user '{userName}'.",
@@ -1781,7 +1786,6 @@
   "UpdateCheckUINotWritableMessage": "Cannot install update because UI folder '{uiFolder}' is not writable by the user '{userName}'.",
   "UpdateFiltered": "Update Filtered",
   "UpdateMechanismHelpText": "Use {appName}'s built-in updater or a script",
-  "UpdateRadarrDirectlyLoadError": "Unable to update {appName} directly,",
   "UpdateScriptPathHelpText": "Path to a custom script that takes an extracted update package and handle the remainder of the update process",
   "UpdateSelected": "Update Selected",
   "UpdaterLogFiles": "Updater Log Files",
diff --git a/src/NzbDrone.Core/Localization/Core/es.json b/src/NzbDrone.Core/Localization/Core/es.json
index 28a509be2..810583bc7 100644
--- a/src/NzbDrone.Core/Localization/Core/es.json
+++ b/src/NzbDrone.Core/Localization/Core/es.json
@@ -926,7 +926,7 @@
     "Trigger": "Desencadenar",
     "Unlimited": "Ilimitado",
     "UnableToLoadManualImportItems": "No se pueden cargar elementos de importación manual",
-    "UpdateRadarrDirectlyLoadError": "No se puede actualizar {appName} directamente,",
+    "UpdateAppDirectlyLoadError": "No se puede actualizar {appName} directamente,",
     "UnmappedFilesOnly": "Solo archivos sin mapear",
     "UpgradeUntilCustomFormatScore": "Actualizar hasta la puntuación de formato personalizado",
     "UpgradeUntil": "Actualizar hasta",
diff --git a/src/NzbDrone.Core/Localization/Core/fi.json b/src/NzbDrone.Core/Localization/Core/fi.json
index 41d5ac72f..273289ba9 100644
--- a/src/NzbDrone.Core/Localization/Core/fi.json
+++ b/src/NzbDrone.Core/Localization/Core/fi.json
@@ -896,7 +896,7 @@
     "UnableToLoadRootFolders": "Juurikansioiden lataus epäonnistui.",
     "TagsLoadError": "Tunnisteiden lataus ei onnistu",
     "CalendarLoadError": "Kalenterin lataus epäonnistui.",
-    "UpdateRadarrDirectlyLoadError": "{appName}ia ei voida päivittää suoraan,",
+    "UpdateAppDirectlyLoadError": "{appName}ia ei voida päivittää suoraan,",
     "Ungroup": "Pura ryhmä",
     "UnmappedFilesOnly": "Vain kohdistamattomat tiedostot",
     "UnmappedFolders": "Kohdistamattomat kansiot",
diff --git a/src/NzbDrone.Core/Localization/Core/fr.json b/src/NzbDrone.Core/Localization/Core/fr.json
index 9da523bd0..00be66ddb 100644
--- a/src/NzbDrone.Core/Localization/Core/fr.json
+++ b/src/NzbDrone.Core/Localization/Core/fr.json
@@ -934,7 +934,7 @@
     "Trakt": "Trakt",
     "Trigger": "Déclencheur",
     "UnableToLoadManualImportItems": "Impossible de charger les éléments d'importation manuelle",
-    "UpdateRadarrDirectlyLoadError": "Impossible de mettre à jour {appName} directement,",
+    "UpdateAppDirectlyLoadError": "Impossible de mettre à jour {appName} directement,",
     "Unlimited": "Illimité",
     "UnmappedFilesOnly": "Fichiers non mappés uniquement",
     "UpgradeUntilCustomFormatScore": "Mise à niveau jusqu'au score de format personnalisé",
diff --git a/src/NzbDrone.Core/Localization/Core/he.json b/src/NzbDrone.Core/Localization/Core/he.json
index 774e1d9e1..7f48a470f 100644
--- a/src/NzbDrone.Core/Localization/Core/he.json
+++ b/src/NzbDrone.Core/Localization/Core/he.json
@@ -898,7 +898,7 @@
     "UnableToLoadRootFolders": "לא ניתן לטעון תיקיות שורש",
     "CalendarLoadError": "לא ניתן לטעון את היומן",
     "Unlimited": "ללא הגבלה",
-    "UpdateRadarrDirectlyLoadError": "לא ניתן לעדכן את {appName} ישירות,",
+    "UpdateAppDirectlyLoadError": "לא ניתן לעדכן את {appName} ישירות,",
     "Ungroup": "בטל קבוצה",
     "UnmappedFilesOnly": "קבצים שלא ממופים בלבד",
     "UnmappedFolders": "תיקיות לא ממופות",
diff --git a/src/NzbDrone.Core/Localization/Core/hi.json b/src/NzbDrone.Core/Localization/Core/hi.json
index 82e035898..28b3170d4 100644
--- a/src/NzbDrone.Core/Localization/Core/hi.json
+++ b/src/NzbDrone.Core/Localization/Core/hi.json
@@ -241,7 +241,7 @@
     "QualityProfilesLoadError": "गुणवत्ता प्रोफ़ाइल लोड करने में असमर्थ",
     "RemotePathMappingsLoadError": "दूरस्थ पथ मैपिंग लोड करने में असमर्थ",
     "CalendarLoadError": "कैलेंडर लोड करने में असमर्थ",
-    "UpdateRadarrDirectlyLoadError": "सीधे {appName} अद्यतन करने में असमर्थ,",
+    "UpdateAppDirectlyLoadError": "सीधे {appName} अद्यतन करने में असमर्थ,",
     "UnmappedFolders": "बिना मोड़े हुए फोल्डर",
     "ICalIncludeUnmonitoredMoviesHelpText": "ICal फीड में अनऑमिटर की गई फिल्में शामिल करें",
     "UnselectAll": "सभी का चयन रद्द",
diff --git a/src/NzbDrone.Core/Localization/Core/hu.json b/src/NzbDrone.Core/Localization/Core/hu.json
index 360416e1c..d8b4da173 100644
--- a/src/NzbDrone.Core/Localization/Core/hu.json
+++ b/src/NzbDrone.Core/Localization/Core/hu.json
@@ -846,7 +846,7 @@
     "UpgradesAllowed": "Frissítések Engedélyezve",
     "UnmappedFilesOnly": "Kizárólag fel nem térképezett fájlokat",
     "Unlimited": "korlátlan",
-    "UpdateRadarrDirectlyLoadError": "Nem lehetséges közvetlenül frissíteni a {appName}-t",
+    "UpdateAppDirectlyLoadError": "Nem lehetséges közvetlenül frissíteni a {appName}-t",
     "UnableToLoadManualImportItems": "Nem lehetséges betölteni a manuálisan importált elemeket",
     "AlternativeTitlesLoadError": "Nem lehetséges betölteni az alternatív címeket.",
     "Trigger": "Trigger",
diff --git a/src/NzbDrone.Core/Localization/Core/is.json b/src/NzbDrone.Core/Localization/Core/is.json
index dac804092..a5dafe8bc 100644
--- a/src/NzbDrone.Core/Localization/Core/is.json
+++ b/src/NzbDrone.Core/Localization/Core/is.json
@@ -899,7 +899,7 @@
     "UnableToLoadRootFolders": "Ekki er hægt að hlaða rótarmöppum",
     "TagsLoadError": "Ekki er hægt að hlaða merkin",
     "CalendarLoadError": "Ekki er hægt að hlaða dagatalið",
-    "UpdateRadarrDirectlyLoadError": "Ekki er hægt að uppfæra {appName} beint,",
+    "UpdateAppDirectlyLoadError": "Ekki er hægt að uppfæra {appName} beint,",
     "Ungroup": "Aftengja hópinn",
     "Unlimited": "Ótakmarkað",
     "UnmappedFilesOnly": "Aðeins ókortlagðar skrár",
diff --git a/src/NzbDrone.Core/Localization/Core/it.json b/src/NzbDrone.Core/Localization/Core/it.json
index c766a1979..77c4e8210 100644
--- a/src/NzbDrone.Core/Localization/Core/it.json
+++ b/src/NzbDrone.Core/Localization/Core/it.json
@@ -932,7 +932,7 @@
     "Trigger": "Trigger",
     "UnableToLoadManualImportItems": "Impossibile caricare gli elementi di importazione manuale",
     "Unlimited": "Illimitato",
-    "UpdateRadarrDirectlyLoadError": "Impossibile aggiornare {appName} direttamente,",
+    "UpdateAppDirectlyLoadError": "Impossibile aggiornare {appName} direttamente,",
     "UnmappedFilesOnly": "Solo file non mappati",
     "UpgradeUntilCustomFormatScore": "Aggiorna fino al punteggio formato personalizzato",
     "UpgradeUntil": "Upgrade fino alla qualità",
diff --git a/src/NzbDrone.Core/Localization/Core/ja.json b/src/NzbDrone.Core/Localization/Core/ja.json
index c02ec68e4..157de467b 100644
--- a/src/NzbDrone.Core/Localization/Core/ja.json
+++ b/src/NzbDrone.Core/Localization/Core/ja.json
@@ -896,7 +896,7 @@
     "UnableToLoadRootFolders": "ルートフォルダを読み込めません",
     "TagsLoadError": "タグを読み込めません",
     "CalendarLoadError": "カレンダーを読み込めません",
-    "UpdateRadarrDirectlyLoadError": "{appName}を直接更新できません。",
+    "UpdateAppDirectlyLoadError": "{appName}を直接更新できません。",
     "Ungroup": "グループ化を解除",
     "UnmappedFilesOnly": "マップされていないファイルのみ",
     "UnmappedFolders": "マップされていないフォルダ",
diff --git a/src/NzbDrone.Core/Localization/Core/ko.json b/src/NzbDrone.Core/Localization/Core/ko.json
index 2d3f8e185..23456bb35 100644
--- a/src/NzbDrone.Core/Localization/Core/ko.json
+++ b/src/NzbDrone.Core/Localization/Core/ko.json
@@ -899,7 +899,7 @@
     "UnableToLoadRestrictions": "제한을 불러올 수 없습니다.",
     "UnableToLoadRootFolders": "루트 폴더를 불러올 수 없습니다.",
     "CalendarLoadError": "달력을 불러올 수 없습니다.",
-    "UpdateRadarrDirectlyLoadError": "{appName}를 직접 업데이트 할 수 없습니다.",
+    "UpdateAppDirectlyLoadError": "{appName}를 직접 업데이트 할 수 없습니다.",
     "Ungroup": "그룹 해제",
     "UnmappedFilesOnly": "매핑되지 않은 파일 만",
     "UnmappedFolders": "매핑되지 않은 폴더",
diff --git a/src/NzbDrone.Core/Localization/Core/nl.json b/src/NzbDrone.Core/Localization/Core/nl.json
index 2f9909b22..8764a475b 100644
--- a/src/NzbDrone.Core/Localization/Core/nl.json
+++ b/src/NzbDrone.Core/Localization/Core/nl.json
@@ -934,7 +934,7 @@
     "Trakt": "Trakt",
     "Trigger": "In gang zetten",
     "UnableToLoadManualImportItems": "Kan items voor handmatig importeren niet laden",
-    "UpdateRadarrDirectlyLoadError": "Kan {appName} niet rechtstreeks updaten,",
+    "UpdateAppDirectlyLoadError": "Kan {appName} niet rechtstreeks updaten,",
     "Unlimited": "Onbeperkt",
     "UnmappedFilesOnly": "Alleen niet-toegewezen bestanden",
     "UpgradeUntilCustomFormatScore": "Upgraden tot Score aangepast formaat",
diff --git a/src/NzbDrone.Core/Localization/Core/pl.json b/src/NzbDrone.Core/Localization/Core/pl.json
index df6f9aa2c..5cc125898 100644
--- a/src/NzbDrone.Core/Localization/Core/pl.json
+++ b/src/NzbDrone.Core/Localization/Core/pl.json
@@ -901,7 +901,7 @@
     "UnableToLoadRootFolders": "Nie można załadować folderów głównych",
     "TagsLoadError": "Nie można załadować tagów",
     "CalendarLoadError": "Nie można załadować kalendarza",
-    "UpdateRadarrDirectlyLoadError": "Nie można bezpośrednio zaktualizować {appName},",
+    "UpdateAppDirectlyLoadError": "Nie można bezpośrednio zaktualizować {appName},",
     "Ungroup": "Rozgrupuj",
     "Unlimited": "Nieograniczony",
     "Unmonitored": "Niemonitorowane",
diff --git a/src/NzbDrone.Core/Localization/Core/pt.json b/src/NzbDrone.Core/Localization/Core/pt.json
index 0d0a9f258..7fb42ff7d 100644
--- a/src/NzbDrone.Core/Localization/Core/pt.json
+++ b/src/NzbDrone.Core/Localization/Core/pt.json
@@ -934,7 +934,7 @@
     "Trakt": "Trakt",
     "Trigger": "Acionador",
     "UnableToLoadManualImportItems": "Não foi possível carregar os itens de importação manual",
-    "UpdateRadarrDirectlyLoadError": "Não foi possível atualizar o {appName} diretamente,",
+    "UpdateAppDirectlyLoadError": "Não foi possível atualizar o {appName} diretamente,",
     "Unlimited": "Ilimitado",
     "UnmappedFilesOnly": "Somente ficheiros não mapeados",
     "UpgradeUntilCustomFormatScore": "Atualizar até a pontuação do formato personalizado",
diff --git a/src/NzbDrone.Core/Localization/Core/pt_BR.json b/src/NzbDrone.Core/Localization/Core/pt_BR.json
index 22ebe4532..e18661033 100644
--- a/src/NzbDrone.Core/Localization/Core/pt_BR.json
+++ b/src/NzbDrone.Core/Localization/Core/pt_BR.json
@@ -906,7 +906,7 @@
     "Unlimited": "Ilimitado",
     "Ungroup": "Desagrupar",
     "Unavailable": "Indisponível",
-    "UpdateRadarrDirectlyLoadError": "Não foi possível carregar o {appName} diretamente,",
+    "UpdateAppDirectlyLoadError": "Não foi possível carregar o {appName} diretamente,",
     "UiSettingsLoadError": "Não foi possível carregar as configurações da interface",
     "CalendarLoadError": "Não foi possível carregar o calendário",
     "TagsLoadError": "Não foi possível carregar as tags",
diff --git a/src/NzbDrone.Core/Localization/Core/ro.json b/src/NzbDrone.Core/Localization/Core/ro.json
index 2e6f09358..36ea94c6b 100644
--- a/src/NzbDrone.Core/Localization/Core/ro.json
+++ b/src/NzbDrone.Core/Localization/Core/ro.json
@@ -912,7 +912,7 @@
     "UnableToLoadRootFolders": "Imposibil de încărcat folderele rădăcină",
     "TagsLoadError": "Nu se pot încărca etichete",
     "CalendarLoadError": "Calendarul nu poate fi încărcat",
-    "UpdateRadarrDirectlyLoadError": "Imposibil de actualizat direct {appName},",
+    "UpdateAppDirectlyLoadError": "Imposibil de actualizat direct {appName},",
     "Ungroup": "Dezgrupează",
     "Unlimited": "Nelimitat",
     "UnmappedFilesOnly": "Numai fișiere nemapate",
diff --git a/src/NzbDrone.Core/Localization/Core/ru.json b/src/NzbDrone.Core/Localization/Core/ru.json
index 4b5627d5f..8d134e2ba 100644
--- a/src/NzbDrone.Core/Localization/Core/ru.json
+++ b/src/NzbDrone.Core/Localization/Core/ru.json
@@ -720,7 +720,7 @@
     "Unlimited": "Неограниченно",
     "Ungroup": "Разгруппировать",
     "Unavailable": "Недоступно",
-    "UpdateRadarrDirectlyLoadError": "Невозможно обновить {appName} напрямую,",
+    "UpdateAppDirectlyLoadError": "Невозможно обновить {appName} напрямую,",
     "UiSettingsLoadError": "Не удалось загрузить настройки пользовательского интерфейса",
     "CalendarLoadError": "Не удалось загрузить календарь",
     "TagsLoadError": "Невозможно загрузить теги",
diff --git a/src/NzbDrone.Core/Localization/Core/sv.json b/src/NzbDrone.Core/Localization/Core/sv.json
index ca7f7d820..79c6e926b 100644
--- a/src/NzbDrone.Core/Localization/Core/sv.json
+++ b/src/NzbDrone.Core/Localization/Core/sv.json
@@ -935,7 +935,7 @@
     "UnableToLoadRestrictions": "Det gick inte att ladda begränsningar",
     "UnableToLoadRootFolders": "Det gick inte att ladda rotmappar",
     "CalendarLoadError": "Det gick inte att ladda kalendern",
-    "UpdateRadarrDirectlyLoadError": "Det går inte att uppdatera {appName} direkt,",
+    "UpdateAppDirectlyLoadError": "Det går inte att uppdatera {appName} direkt,",
     "UpgradeUntilCustomFormatScore": "Uppgradera tills anpassat formatpoäng",
     "UpgradeUntil": "Uppgradera tills kvalitet",
     "UpgradeUntilThisQualityIsMetOrExceeded": "Uppgradera tills den här kvaliteten uppfylls eller överskrids",
diff --git a/src/NzbDrone.Core/Localization/Core/th.json b/src/NzbDrone.Core/Localization/Core/th.json
index 5f350362a..d70183180 100644
--- a/src/NzbDrone.Core/Localization/Core/th.json
+++ b/src/NzbDrone.Core/Localization/Core/th.json
@@ -907,7 +907,7 @@
     "UnableToLoadRestrictions": "ไม่สามารถโหลดข้อ จำกัด",
     "UnableToLoadRootFolders": "ไม่สามารถโหลดโฟลเดอร์รูท",
     "CalendarLoadError": "ไม่สามารถโหลดปฏิทิน",
-    "UpdateRadarrDirectlyLoadError": "ไม่สามารถอัปเดต {appName} ได้โดยตรง",
+    "UpdateAppDirectlyLoadError": "ไม่สามารถอัปเดต {appName} ได้โดยตรง",
     "Ungroup": "ยกเลิกการจัดกลุ่ม",
     "Unlimited": "ไม่ จำกัด",
     "UnmappedFilesOnly": "ไฟล์ที่ไม่ได้แมปเท่านั้น",
diff --git a/src/NzbDrone.Core/Localization/Core/tr.json b/src/NzbDrone.Core/Localization/Core/tr.json
index a3b4c5de6..97ee048e4 100644
--- a/src/NzbDrone.Core/Localization/Core/tr.json
+++ b/src/NzbDrone.Core/Localization/Core/tr.json
@@ -338,7 +338,7 @@
     "UnableToLoadRestrictions": "Kısıtlamalar yüklenemiyor",
     "UnableToLoadRootFolders": "Kök klasörler yüklenemiyor",
     "TagsLoadError": "Etiketler yüklenemiyor",
-    "UpdateRadarrDirectlyLoadError": "{appName} doğrudan güncellenemiyor,",
+    "UpdateAppDirectlyLoadError": "{appName} doğrudan güncellenemiyor,",
     "Ungroup": "Grubu çöz",
     "Unlimited": "Sınırsız",
     "UnmappedFilesOnly": "Yalnızca Eşlenmemiş Dosyalar",
diff --git a/src/NzbDrone.Core/Localization/Core/uk.json b/src/NzbDrone.Core/Localization/Core/uk.json
index 90b7947b0..fc596afd7 100644
--- a/src/NzbDrone.Core/Localization/Core/uk.json
+++ b/src/NzbDrone.Core/Localization/Core/uk.json
@@ -892,7 +892,7 @@
     "QualityDefinitionsLoadError": "Не вдалося завантажити визначення якості",
     "RemotePathMappingsLoadError": "Неможливо завантажити віддалені відображення шляхів",
     "UnableToLoadRootFolders": "Не вдалося завантажити кореневі папки",
-    "UpdateRadarrDirectlyLoadError": "Неможливо оновити {appName} безпосередньо,",
+    "UpdateAppDirectlyLoadError": "Неможливо оновити {appName} безпосередньо,",
     "Unavailable": "Недоступний",
     "Unlimited": "Необмежений",
     "UnmappedFilesOnly": "Лише незіставлені файли",
diff --git a/src/NzbDrone.Core/Localization/Core/vi.json b/src/NzbDrone.Core/Localization/Core/vi.json
index 74becaf4e..6a58934f0 100644
--- a/src/NzbDrone.Core/Localization/Core/vi.json
+++ b/src/NzbDrone.Core/Localization/Core/vi.json
@@ -912,7 +912,7 @@
     "QualityProfilesLoadError": "Không thể tải Hồ sơ chất lượng",
     "RemotePathMappingsLoadError": "Không thể tải Ánh xạ đường dẫn từ xa",
     "UnableToLoadRootFolders": "Không thể tải các thư mục gốc",
-    "UpdateRadarrDirectlyLoadError": "Không thể cập nhật {appName} trực tiếp,",
+    "UpdateAppDirectlyLoadError": "Không thể cập nhật {appName} trực tiếp,",
     "Ungroup": "Bỏ nhóm",
     "Unlimited": "Vô hạn",
     "UnmappedFilesOnly": "Chỉ các tệp chưa được ánh xạ",
diff --git a/src/NzbDrone.Core/Localization/Core/zh_CN.json b/src/NzbDrone.Core/Localization/Core/zh_CN.json
index 4a0e1bfc0..059441bb8 100644
--- a/src/NzbDrone.Core/Localization/Core/zh_CN.json
+++ b/src/NzbDrone.Core/Localization/Core/zh_CN.json
@@ -127,7 +127,7 @@
     "Unmonitored": "未追踪项",
     "Unlimited": "无限制",
     "Unavailable": "不可用",
-    "UpdateRadarrDirectlyLoadError": "无法直接更新{appName}",
+    "UpdateAppDirectlyLoadError": "无法直接更新{appName}",
     "UiSettingsLoadError": "无法加载UI设置",
     "CalendarLoadError": "无法加载日历",
     "TagsLoadError": "无法加载标签",
diff --git a/src/NzbDrone.Core/Update/Commands/ApplicationCheckUpdateCommand.cs b/src/NzbDrone.Core/Update/Commands/ApplicationCheckUpdateCommand.cs
index 6987af3fa..86461405f 100644
--- a/src/NzbDrone.Core/Update/Commands/ApplicationCheckUpdateCommand.cs
+++ b/src/NzbDrone.Core/Update/Commands/ApplicationCheckUpdateCommand.cs
@@ -7,5 +7,7 @@ namespace NzbDrone.Core.Update.Commands
         public override bool SendUpdatesToClient => true;
 
         public override string CompletionMessage => null;
+
+        public bool InstallMajorUpdate { get; set; }
     }
 }
diff --git a/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs b/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs
index 59a827a0b..6980af708 100644
--- a/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs
+++ b/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs
@@ -4,6 +4,7 @@ namespace NzbDrone.Core.Update.Commands
 {
     public class ApplicationUpdateCommand : Command
     {
+        public bool InstallMajorUpdate { get; set; }
         public override bool SendUpdatesToClient => true;
         public override bool IsExclusive => true;
     }
diff --git a/src/NzbDrone.Core/Update/InstallUpdateService.cs b/src/NzbDrone.Core/Update/InstallUpdateService.cs
index 91acb7952..0fe738bb5 100644
--- a/src/NzbDrone.Core/Update/InstallUpdateService.cs
+++ b/src/NzbDrone.Core/Update/InstallUpdateService.cs
@@ -231,7 +231,7 @@ namespace NzbDrone.Core.Update
             }
         }
 
-        private UpdatePackage GetUpdatePackage(CommandTrigger updateTrigger)
+        private UpdatePackage GetUpdatePackage(CommandTrigger updateTrigger, bool installMajorUpdate)
         {
             _logger.ProgressDebug("Checking for updates");
 
@@ -243,7 +243,13 @@ namespace NzbDrone.Core.Update
                 return null;
             }
 
-            if (OsInfo.IsNotWindows && !_configFileProvider.UpdateAutomatically && updateTrigger != CommandTrigger.Manual)
+            if (latestAvailable.Version.Major > BuildInfo.Version.Major && !installMajorUpdate)
+            {
+                _logger.ProgressInfo("Unable to install major update, please update update manually from System: Updates");
+                return null;
+            }
+
+            if (!_configFileProvider.UpdateAutomatically && updateTrigger != CommandTrigger.Manual)
             {
                 _logger.ProgressDebug("Auto-update not enabled, not installing available update.");
                 return null;
@@ -272,7 +278,7 @@ namespace NzbDrone.Core.Update
 
         public void Execute(ApplicationCheckUpdateCommand message)
         {
-            if (GetUpdatePackage(message.Trigger) != null)
+            if (GetUpdatePackage(message.Trigger, true) != null)
             {
                 _commandQueueManager.Push(new ApplicationUpdateCommand(), trigger: message.Trigger);
             }
@@ -280,7 +286,7 @@ namespace NzbDrone.Core.Update
 
         public void Execute(ApplicationUpdateCommand message)
         {
-            var latestAvailable = GetUpdatePackage(message.Trigger);
+            var latestAvailable = GetUpdatePackage(message.Trigger, message.InstallMajorUpdate);
 
             if (latestAvailable != null)
             {
diff --git a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs
index 8d82fefcb..b0543366f 100644
--- a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs
+++ b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs
@@ -42,6 +42,7 @@ namespace NzbDrone.Core.Update
                                          .AddQueryParam("runtime", "netcore")
                                          .AddQueryParam("runtimeVer", _platformInfo.Version)
                                          .AddQueryParam("dbType", _mainDatabase.DatabaseType)
+                                         .AddQueryParam("includeMajorVersion", true)
                                          .SetSegment("branch", branch);
 
             if (_analyticsService.IsEnabled)