diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
index 603b20a48..cc26a2633 100644
--- a/frontend/.eslintrc.js
+++ b/frontend/.eslintrc.js
@@ -28,7 +28,8 @@ module.exports = {
globals: {
expect: false,
chai: false,
- sinon: false
+ sinon: false,
+ JSX: true
},
parserOptions: {
diff --git a/frontend/src/Components/Table/VirtualTableRowButton.css b/frontend/src/Components/Table/VirtualTableRowButton.css
new file mode 100644
index 000000000..886765f2a
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRowButton.css
@@ -0,0 +1,4 @@
+.row {
+ composes: link from '~Components/Link/Link.css';
+ composes: row from '~./VirtualTableRow.css';
+}
diff --git a/frontend/src/Components/Table/VirtualTableRowButton.css.d.ts b/frontend/src/Components/Table/VirtualTableRowButton.css.d.ts
new file mode 100644
index 000000000..d4b245cd1
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRowButton.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'row': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Table/VirtualTableRowButton.js b/frontend/src/Components/Table/VirtualTableRowButton.js
new file mode 100644
index 000000000..ba63c1648
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRowButton.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import VirtualTableRow from './VirtualTableRow';
+import styles from './VirtualTableRowButton.css';
+
+function VirtualTableRowButton(props) {
+ return (
+
+ );
+}
+
+export default VirtualTableRowButton;
diff --git a/frontend/src/DownloadClient/DownloadProtocol.ts b/frontend/src/DownloadClient/DownloadProtocol.ts
new file mode 100644
index 000000000..090a1a087
--- /dev/null
+++ b/frontend/src/DownloadClient/DownloadProtocol.ts
@@ -0,0 +1,7 @@
+enum DownloadProtocol {
+ Unknown = 'unknown',
+ Usenet = 'usenet',
+ Torrent = 'torrent',
+}
+
+export default DownloadProtocol;
diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js
index 77803e56e..2929afdd8 100644
--- a/frontend/src/Helpers/Props/icons.js
+++ b/frontend/src/Helpers/Props/icons.js
@@ -40,6 +40,7 @@ import {
faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle,
+ faCircleDown as fasCircleDown,
faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog,
@@ -134,6 +135,7 @@ export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle;
+export const CIRCLE_DOWN = fasCircleDown;
export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt;
export const CLIPBOARD = fasCopy;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
index ffea82600..4c308f4fb 100644
--- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css
@@ -4,9 +4,10 @@
width: 80px;
}
-.title {
- composes: cell from '~Components/Table/Cells/TableRowCell.css';
-
+.titleContent {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
word-break: break-all;
}
@@ -34,8 +35,7 @@
cursor: default;
}
-.rejected,
-.download {
+.rejected {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 50px;
@@ -53,3 +53,39 @@
width: 75px;
}
+
+.download {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 80px;
+}
+
+.manualDownloadContent {
+ position: relative;
+ display: inline-block;
+ margin: 0 2px;
+ width: 22px;
+ height: 20.39px;
+ vertical-align: middle;
+ line-height: 20.39px;
+
+ &:hover {
+ color: var(--iconButtonHoverColor);
+ }
+}
+
+.interactiveIcon {
+ position: absolute;
+ top: 4px;
+ left: 0;
+ /* width: 100%; */
+ text-align: center;
+}
+
+.downloadIcon {
+ position: absolute;
+ top: 7px;
+ left: 8px;
+ /* width: 100%; */
+ text-align: center;
+}
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts
index ca01c5ee6..f056fdf44 100644
--- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts
@@ -4,13 +4,16 @@ interface CssExports {
'age': string;
'customFormatScore': string;
'download': string;
+ 'downloadIcon': string;
'indexer': string;
+ 'interactiveIcon': string;
+ 'manualDownloadContent': string;
'peers': string;
'protocol': string;
'quality': string;
'rejected': string;
'size': string;
- 'title': string;
+ 'titleContent': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js
deleted file mode 100644
index 3029d2d2f..000000000
--- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js
+++ /dev/null
@@ -1,283 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
-import AlbumFormats from 'Album/AlbumFormats';
-import TrackQuality from 'Album/TrackQuality';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
-import ConfirmModal from 'Components/Modal/ConfirmModal';
-import TableRowCell from 'Components/Table/Cells/TableRowCell';
-import TableRow from 'Components/Table/TableRow';
-import Popover from 'Components/Tooltip/Popover';
-import Tooltip from 'Components/Tooltip/Tooltip';
-import { icons, kinds, tooltipPositions } from 'Helpers/Props';
-import formatDateTime from 'Utilities/Date/formatDateTime';
-import formatAge from 'Utilities/Number/formatAge';
-import formatBytes from 'Utilities/Number/formatBytes';
-import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
-import translate from 'Utilities/String/translate';
-import Peers from './Peers';
-import styles from './InteractiveSearchRow.css';
-
-function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
- if (isGrabbing) {
- return icons.SPINNER;
- } else if (isGrabbed) {
- return icons.DOWNLOADING;
- } else if (grabError) {
- return icons.DOWNLOADING;
- }
-
- return icons.DOWNLOAD;
-}
-
-function getDownloadKind(isGrabbed, grabError, downloadAllowed) {
- if (isGrabbed) {
- return kinds.SUCCESS;
- }
-
- if (grabError || !downloadAllowed) {
- return kinds.DANGER;
- }
-
- return kinds.DEFAULT;
-}
-
-function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
- if (isGrabbing) {
- return '';
- } else if (isGrabbed) {
- return 'Added to downloaded queue';
- } else if (grabError) {
- return grabError;
- }
-
- return 'Add to downloaded queue';
-}
-
-class InteractiveSearchRow extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isConfirmGrabModalOpen: false
- };
- }
-
- //
- // Listeners
-
- onGrabPress = () => {
- const {
- guid,
- indexerId,
- onGrabPress
- } = this.props;
-
- onGrabPress({
- guid,
- indexerId
- });
- };
-
- onConfirmGrabPress = () => {
- this.setState({ isConfirmGrabModalOpen: true });
- };
-
- onGrabConfirm = () => {
- this.setState({ isConfirmGrabModalOpen: false });
-
- const {
- guid,
- indexerId,
- searchPayload,
- onGrabPress
- } = this.props;
-
- onGrabPress({
- guid,
- indexerId,
- ...searchPayload
- });
- };
-
- onGrabCancel = () => {
- this.setState({ isConfirmGrabModalOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- protocol,
- age,
- ageHours,
- ageMinutes,
- publishDate,
- title,
- infoUrl,
- indexer,
- size,
- seeders,
- leechers,
- quality,
- customFormatScore,
- customFormats,
- rejections,
- downloadAllowed,
- isGrabbing,
- isGrabbed,
- longDateFormat,
- timeFormat,
- grabError
- } = this.props;
-
- return (
-
-
-
-
-
-
- {formatAge(age, ageHours, ageMinutes)}
-
-
-
-
- {title}
-
-
-
-
- {indexer}
-
-
-
- {formatBytes(size)}
-
-
-
- {
- protocol === 'torrent' &&
-
- }
-
-
-
-
-
-
-
- }
- position={tooltipPositions.BOTTOM}
- />
-
-
-
- {
- !!rejections.length &&
-
- }
- title={translate('ReleaseRejected')}
- body={
-
- {
- rejections.map((rejection, index) => {
- return (
- -
- {rejection}
-
- );
- })
- }
-
- }
- position={tooltipPositions.LEFT}
- />
- }
-
-
-
- {
-
- }
-
-
-
-
- );
- }
-}
-
-InteractiveSearchRow.propTypes = {
- guid: PropTypes.string.isRequired,
- protocol: PropTypes.string.isRequired,
- age: PropTypes.number.isRequired,
- ageHours: PropTypes.number.isRequired,
- ageMinutes: PropTypes.number.isRequired,
- publishDate: PropTypes.string.isRequired,
- title: PropTypes.string.isRequired,
- infoUrl: PropTypes.string.isRequired,
- indexer: PropTypes.string.isRequired,
- indexerId: PropTypes.number.isRequired,
- size: PropTypes.number.isRequired,
- seeders: PropTypes.number,
- leechers: PropTypes.number,
- quality: PropTypes.object.isRequired,
- customFormats: PropTypes.arrayOf(PropTypes.object),
- customFormatScore: PropTypes.number.isRequired,
- rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
- downloadAllowed: PropTypes.bool.isRequired,
- isGrabbing: PropTypes.bool.isRequired,
- isGrabbed: PropTypes.bool.isRequired,
- grabError: PropTypes.string,
- longDateFormat: PropTypes.string.isRequired,
- timeFormat: PropTypes.string.isRequired,
- searchPayload: PropTypes.object.isRequired,
- onGrabPress: PropTypes.func.isRequired
-};
-
-InteractiveSearchRow.defaultProps = {
- rejections: [],
- isGrabbing: false,
- isGrabbed: false
-};
-
-export default InteractiveSearchRow;
diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx
new file mode 100644
index 000000000..e86b80ade
--- /dev/null
+++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx
@@ -0,0 +1,298 @@
+import React, { useCallback, useState } from 'react';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import AlbumFormats from 'Album/AlbumFormats';
+import TrackQuality from 'Album/TrackQuality';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRow from 'Components/Table/TableRow';
+import Popover from 'Components/Tooltip/Popover';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import { QualityModel } from 'Quality/Quality';
+import CustomFormat from 'typings/CustomFormat';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import formatBytes from 'Utilities/Number/formatBytes';
+import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
+import translate from 'Utilities/String/translate';
+import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
+import Peers from './Peers';
+import ReleaseAlbum from './ReleaseAlbum';
+import styles from './InteractiveSearchRow.css';
+
+function getDownloadIcon(
+ isGrabbing: boolean,
+ isGrabbed: boolean,
+ grabError?: string
+) {
+ if (isGrabbing) {
+ return icons.SPINNER;
+ } else if (isGrabbed) {
+ return icons.DOWNLOADING;
+ } else if (grabError) {
+ return icons.DOWNLOADING;
+ }
+
+ return icons.DOWNLOAD;
+}
+
+function getDownloadKind(isGrabbed: boolean, grabError?: string) {
+ if (isGrabbed) {
+ return kinds.SUCCESS;
+ }
+
+ if (grabError) {
+ return kinds.DANGER;
+ }
+
+ return kinds.DEFAULT;
+}
+
+function getDownloadTooltip(
+ isGrabbing: boolean,
+ isGrabbed: boolean,
+ grabError?: string
+) {
+ if (isGrabbing) {
+ return '';
+ } else if (isGrabbed) {
+ return translate('AddedToDownloadQueue');
+ } else if (grabError) {
+ return grabError;
+ }
+
+ return translate('AddToDownloadQueue');
+}
+
+interface InteractiveSearchRowProps {
+ guid: string;
+ protocol: DownloadProtocol;
+ age: number;
+ ageHours: number;
+ ageMinutes: number;
+ publishDate: string;
+ title: string;
+ infoUrl: string;
+ indexerId: number;
+ indexer: string;
+ size: number;
+ seeders?: number;
+ leechers?: number;
+ quality: QualityModel;
+ customFormats: CustomFormat[];
+ customFormatScore: number;
+ mappedArtistId?: number;
+ mappedAlbumInfo: ReleaseAlbum[];
+ rejections: string[];
+ downloadAllowed: boolean;
+ isGrabbing: boolean;
+ isGrabbed: boolean;
+ grabError?: string;
+ longDateFormat: string;
+ timeFormat: string;
+ searchPayload: object;
+ onGrabPress(...args: unknown[]): void;
+}
+
+function InteractiveSearchRow(props: InteractiveSearchRowProps) {
+ const {
+ guid,
+ indexerId,
+ protocol,
+ age,
+ ageHours,
+ ageMinutes,
+ publishDate,
+ title,
+ infoUrl,
+ indexer,
+ size,
+ seeders,
+ leechers,
+ quality,
+ customFormatScore,
+ customFormats,
+ mappedArtistId,
+ mappedAlbumInfo,
+ rejections = [],
+ downloadAllowed,
+ isGrabbing = false,
+ isGrabbed = false,
+ longDateFormat,
+ timeFormat,
+ grabError,
+ searchPayload,
+ onGrabPress,
+ } = props;
+
+ const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false);
+ const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
+
+ const onGrabPressWrapper = useCallback(() => {
+ if (downloadAllowed) {
+ onGrabPress({
+ guid,
+ indexerId,
+ });
+
+ return;
+ }
+
+ setIsConfirmGrabModalOpen(true);
+ }, [
+ guid,
+ indexerId,
+ downloadAllowed,
+ onGrabPress,
+ setIsConfirmGrabModalOpen,
+ ]);
+
+ const onGrabConfirm = useCallback(() => {
+ setIsConfirmGrabModalOpen(false);
+
+ onGrabPress({
+ guid,
+ indexerId,
+ ...searchPayload,
+ });
+ }, [guid, indexerId, searchPayload, onGrabPress, setIsConfirmGrabModalOpen]);
+
+ const onGrabCancel = useCallback(() => {
+ setIsConfirmGrabModalOpen(false);
+ }, [setIsConfirmGrabModalOpen]);
+
+ const onOverridePress = useCallback(() => {
+ setIsOverrideModalOpen(true);
+ }, [setIsOverrideModalOpen]);
+
+ const onOverrideModalClose = useCallback(() => {
+ setIsOverrideModalOpen(false);
+ }, [setIsOverrideModalOpen]);
+
+ return (
+
+
+
+
+
+
+ {formatAge(age, ageHours, ageMinutes)}
+
+
+
+
+ {title}
+
+
+
+ {indexer}
+
+ {formatBytes(size)}
+
+
+ {protocol === 'torrent' ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ }
+ position={tooltipPositions.BOTTOM}
+ />
+
+
+
+ {rejections.length ? (
+ }
+ title={translate('ReleaseRejected')}
+ body={
+
+ {rejections.map((rejection, index) => {
+ return - {rejection}
;
+ })}
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default InteractiveSearchRow;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx
new file mode 100644
index 000000000..81bf86e59
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { sizes } from 'Helpers/Props';
+import SelectDownloadClientModalContent from './SelectDownloadClientModalContent';
+
+interface SelectDownloadClientModalProps {
+ isOpen: boolean;
+ protocol: DownloadProtocol;
+ modalTitle: string;
+ onDownloadClientSelect(downloadClientId: number): void;
+ onModalClose(): void;
+}
+
+function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
+ const { isOpen, protocol, modalTitle, onDownloadClientSelect, onModalClose } =
+ props;
+
+ return (
+
+
+
+ );
+}
+
+export default SelectDownloadClientModal;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx
new file mode 100644
index 000000000..63e15808f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import Alert from 'Components/Alert';
+import Form from 'Components/Form/Form';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { kinds } from 'Helpers/Props';
+import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
+import translate from 'Utilities/String/translate';
+import SelectDownloadClientRow from './SelectDownloadClientRow';
+
+interface SelectDownloadClientModalContentProps {
+ protocol: DownloadProtocol;
+ modalTitle: string;
+ onDownloadClientSelect(downloadClientId: number): void;
+ onModalClose(): void;
+}
+
+function SelectDownloadClientModalContent(
+ props: SelectDownloadClientModalContentProps
+) {
+ const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props;
+
+ const { isFetching, isPopulated, error, items } = useSelector(
+ createEnabledDownloadClientsSelector(protocol)
+ );
+
+ return (
+
+
+ {translate('SelectDownloadClientModalTitle', { modalTitle })}
+
+
+
+ {isFetching ? : null}
+
+ {!isFetching && error ? (
+
+ {translate('DownloadClientsLoadError')}
+
+ ) : null}
+
+ {isPopulated && !error ? (
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+export default SelectDownloadClientModalContent;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css
new file mode 100644
index 000000000..6525db977
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css
@@ -0,0 +1,6 @@
+.downloadClient {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px;
+ border-bottom: 1px solid var(--borderColor);
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
new file mode 100644
index 000000000..10c2d3948
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'downloadClient': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx
new file mode 100644
index 000000000..4b1866d12
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx
@@ -0,0 +1,32 @@
+import React, { useCallback } from 'react';
+import Link from 'Components/Link/Link';
+import translate from 'Utilities/String/translate';
+import styles from './SelectDownloadClientRow.css';
+
+interface SelectDownloadClientRowProps {
+ id: number;
+ name: string;
+ priority: number;
+ onDownloadClientSelect(downloadClientId: number): unknown;
+}
+
+function SelectDownloadClientRow(props: SelectDownloadClientRowProps) {
+ const { id, name, priority, onDownloadClientSelect } = props;
+
+ const onDownloadClientSelectWrapper = useCallback(() => {
+ onDownloadClientSelect(id);
+ }, [id, onDownloadClientSelect]);
+
+ return (
+
+
{name}
+ {translate('PrioritySettings', { priority })}
+
+ );
+}
+
+export default SelectDownloadClientRow;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModal.tsx b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModal.tsx
new file mode 100644
index 000000000..3d1c5606e
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModal.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectAlbumModalContent, {
+ SelectedAlbum,
+} from './SelectAlbumModalContent';
+
+interface SelectAlbumModalProps {
+ isOpen: boolean;
+ selectedIds: number[] | string[];
+ artistId?: number;
+ selectedDetails?: string;
+ modalTitle: string;
+ onAlbumsSelect(selectedAlbums: SelectedAlbum[]): void;
+ onModalClose(): void;
+}
+
+function SelectAlbumModal(props: SelectAlbumModalProps) {
+ const {
+ isOpen,
+ selectedIds,
+ artistId,
+ selectedDetails,
+ modalTitle,
+ onAlbumsSelect,
+ onModalClose,
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default SelectAlbumModal;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.css b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.css
new file mode 100644
index 000000000..5e1f4d0fa
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.css
@@ -0,0 +1,52 @@
+.modalBody {
+ composes: modalBody from '~Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.filterInput {
+ composes: input from '~Components/Form/TextInput.css';
+
+ flex: 0 0 auto;
+ margin-bottom: 20px;
+}
+
+.scroller {
+ flex: 1 1 auto;
+}
+
+.footer {
+ composes: modalFooter from '~Components/Modal/ModalFooter.css';
+
+ display: flex;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.details {
+ margin-right: 20px;
+ color: var(--dimColor);
+ word-break: break-word;
+}
+
+.buttons {
+ display: flex;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .footer {
+ display: block;
+ }
+
+ .details {
+ margin-right: 0;
+ margin-bottom: 10px;
+ }
+
+ .buttons {
+ justify-content: space-between;
+ flex-grow: 1;
+ }
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.css.d.ts
new file mode 100644
index 000000000..b567737bd
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'buttons': string;
+ 'details': string;
+ 'filterInput': string;
+ 'footer': string;
+ 'modalBody': string;
+ 'scroller': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.tsx b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.tsx
new file mode 100644
index 000000000..76f730508
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumModalContent.tsx
@@ -0,0 +1,288 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import Album from 'Album/Album';
+import AlbumAppState from 'App/State/AlbumAppState';
+import TextInput from 'Components/Form/TextInput';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import Scroller from 'Components/Scroller/Scroller';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import useSelectState from 'Helpers/Hooks/useSelectState';
+import { kinds, scrollDirections } from 'Helpers/Props';
+import SortDirection from 'Helpers/Props/SortDirection';
+import {
+ clearAlbums,
+ fetchAlbums,
+ setAlbumsSort,
+} from 'Store/Actions/albumSelectionActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { CheckInputChanged } from 'typings/inputs';
+import { SelectStateInputProps } from 'typings/props';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import SelectAlbumRow from './SelectAlbumRow';
+import styles from './SelectAlbumModalContent.css';
+
+const columns = [
+ {
+ name: 'title',
+ label: () => translate('AlbumTitle'),
+ isSortable: true,
+ isVisible: true,
+ },
+ {
+ name: 'albumType',
+ label: () => translate('AlbumType'),
+ isVisible: true,
+ },
+ {
+ name: 'releaseDate',
+ label: () => translate('ReleaseDate'),
+ isSortable: true,
+ isVisible: true,
+ },
+ {
+ name: 'status',
+ label: () => translate('AlbumStatus'),
+ isVisible: true,
+ },
+ {
+ name: 'foreignAlbumId',
+ label: () => translate('MusicbrainzId'),
+ isVisible: true,
+ },
+];
+
+function albumsSelector() {
+ return createSelector(
+ createClientSideCollectionSelector('albumSelection'),
+ (albums: AlbumAppState) => {
+ return albums;
+ }
+ );
+}
+
+export interface SelectedAlbum {
+ id: number;
+ albums: Album[];
+}
+
+interface SelectAlbumModalContentProps {
+ selectedIds: number[] | string[];
+ artistId?: number;
+ selectedDetails?: string;
+ modalTitle: string;
+ onAlbumsSelect(selectedAlbums: SelectedAlbum[]): unknown;
+ onModalClose(): unknown;
+}
+
+//
+// Render
+
+function SelectAlbumModalContent(props: SelectAlbumModalContentProps) {
+ const {
+ selectedIds,
+ artistId,
+ selectedDetails,
+ modalTitle,
+ onAlbumsSelect,
+ onModalClose,
+ } = props;
+
+ const [filter, setFilter] = useState('');
+ const [selectState, setSelectState] = useSelectState();
+
+ const { allSelected, allUnselected, selectedState } = selectState;
+ const { isFetching, isPopulated, items, error, sortKey, sortDirection } =
+ useSelector(albumsSelector());
+ const dispatch = useDispatch();
+
+ const errorMessage = getErrorMessage(error, translate('AlbumsLoadError'));
+ const selectedCount = selectedIds.length;
+ const selectedAlbumsCount = getSelectedIds(selectedState).length;
+ const selectionIsValid =
+ selectedAlbumsCount > 0 && selectedAlbumsCount % selectedCount === 0;
+
+ const onFilterChange = useCallback(
+ ({ value }: { value: string }) => {
+ setFilter(value.toLowerCase());
+ },
+ [setFilter]
+ );
+
+ const onSelectAllChange = useCallback(
+ ({ value }: CheckInputChanged) => {
+ setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
+ },
+ [items, setSelectState]
+ );
+
+ const onSelectedChange = useCallback(
+ ({ id, value, shiftKey = false }: SelectStateInputProps) => {
+ setSelectState({
+ type: 'toggleSelected',
+ items,
+ id,
+ isSelected: value,
+ shiftKey,
+ });
+ },
+ [items, setSelectState]
+ );
+
+ const onSortPress = useCallback(
+ (newSortKey: string, newSortDirection: SortDirection) => {
+ dispatch(
+ setAlbumsSort({
+ sortKey: newSortKey,
+ sortDirection: newSortDirection,
+ })
+ );
+ },
+ [dispatch]
+ );
+
+ const onAlbumsSelectWrapper = useCallback(() => {
+ const albumIds: number[] = getSelectedIds(selectedState);
+
+ const selectedAlbums = items.reduce((acc: Album[], item) => {
+ if (albumIds.indexOf(item.id) > -1) {
+ acc.push(item);
+ }
+
+ return acc;
+ }, []);
+
+ const albumsPerFile = selectedAlbums.length / selectedIds.length;
+ const sortedAlbums = selectedAlbums.sort((a, b) =>
+ a.title.localeCompare(b.title)
+ );
+
+ const mappedAlbums = selectedIds.map((id, index): SelectedAlbum => {
+ const startingIndex = index * albumsPerFile;
+ const albums = sortedAlbums.slice(
+ startingIndex,
+ startingIndex + albumsPerFile
+ );
+
+ return {
+ id: id as number,
+ albums,
+ };
+ });
+
+ onAlbumsSelect(mappedAlbums);
+ }, [selectedIds, items, selectedState, onAlbumsSelect]);
+
+ useEffect(
+ () => {
+ dispatch(fetchAlbums({ artistId }));
+
+ return () => {
+ dispatch(clearAlbums());
+ };
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
+ let details = selectedDetails;
+
+ if (!details) {
+ details =
+ selectedCount > 1
+ ? translate('CountSelectedFiles', { selectedCount })
+ : translate('CountSelectedFile', { selectedCount });
+ }
+
+ return (
+
+
+ {translate('SelectAlbumsModalTitle', { modalTitle })}
+
+
+
+
+
+
+ {isFetching ? : null}
+
+ {error ? {errorMessage}
: null}
+
+ {isPopulated && !!items.length ? (
+
+
+ {items.map((item) => {
+ return item.title.toLowerCase().includes(filter) ||
+ item.foreignAlbumId.toLowerCase().includes(filter) ? (
+
+ ) : null;
+ })}
+
+
+ ) : null}
+
+ {isPopulated && !items.length
+ ? translate('NoAlbumsFoundForSelectedArtist')
+ : null}
+
+
+
+
+ {details}
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SelectAlbumModalContent;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.css b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.css
new file mode 100644
index 000000000..267cd9577
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.css
@@ -0,0 +1,5 @@
+.foreignAlbumId {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.css.d.ts
new file mode 100644
index 000000000..e7384a23f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'foreignAlbumId': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.js b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.js
new file mode 100644
index 000000000..c9fb23fae
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Album/SelectAlbumRow.js
@@ -0,0 +1,112 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Label from 'Components/Label';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import TableRowButton from 'Components/Table/TableRowButton';
+import { kinds, sizes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './SelectAlbumRow.css';
+
+function getTrackCountKind(monitored, trackFileCount, trackCount) {
+ if (trackFileCount === trackCount && trackCount > 0) {
+ return kinds.SUCCESS;
+ }
+
+ if (!monitored) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+class SelectAlbumRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ id,
+ isSelected
+ } = this.props;
+
+ this.props.onSelectedChange({ id, value: !isSelected });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ foreignAlbumId,
+ title,
+ disambiguation,
+ albumType,
+ releaseDate,
+ statistics = {},
+ monitored,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ const {
+ trackCount = 0,
+ trackFileCount = 0,
+ totalTrackCount = 0
+ } = statistics;
+
+ const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title;
+
+ return (
+
+
+
+
+ {extendedTitle}
+
+
+
+ {albumType}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+SelectAlbumRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ foreignAlbumId: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string.isRequired,
+ albumType: PropTypes.string.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ statistics: PropTypes.object.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default SelectAlbumRow;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModal.tsx b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModal.tsx
new file mode 100644
index 000000000..c8916e1ee
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModal.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import Artist from 'Artist/Artist';
+import Modal from 'Components/Modal/Modal';
+import SelectArtistModalContent from './SelectArtistModalContent';
+
+interface SelectArtistModalProps {
+ isOpen: boolean;
+ modalTitle: string;
+ onArtistSelect(artist: Artist): void;
+ onModalClose(): void;
+}
+
+function SelectArtistModal(props: SelectArtistModalProps) {
+ const { isOpen, modalTitle, onArtistSelect, onModalClose } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default SelectArtistModal;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.css b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.css
new file mode 100644
index 000000000..54f67bb07
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.css
@@ -0,0 +1,18 @@
+.modalBody {
+ composes: modalBody from '~Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.filterInput {
+ composes: input from '~Components/Form/TextInput.css';
+
+ flex: 0 0 auto;
+ margin-bottom: 20px;
+}
+
+.scroller {
+ flex: 1 1 auto;
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.css.d.ts
new file mode 100644
index 000000000..3e7d584e8
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'filterInput': string;
+ 'modalBody': string;
+ 'scroller': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.tsx b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.tsx
new file mode 100644
index 000000000..cfbd90096
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalContent.tsx
@@ -0,0 +1,217 @@
+import { throttle } from 'lodash';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useSelector } from 'react-redux';
+import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
+import Artist from 'Artist/Artist';
+import TextInput from 'Components/Form/TextInput';
+import Button from 'Components/Link/Button';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import Scroller from 'Components/Scroller/Scroller';
+import Column from 'Components/Table/Column';
+import VirtualTableRowButton from 'Components/Table/VirtualTableRowButton';
+import { scrollDirections } from 'Helpers/Props';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import dimensions from 'Styles/Variables/dimensions';
+import translate from 'Utilities/String/translate';
+import SelectArtistModalTableHeader from './SelectArtistModalTableHeader';
+import SelectArtistRow from './SelectArtistRow';
+import styles from './SelectArtistModalContent.css';
+
+const columns = [
+ {
+ name: 'artistName',
+ label: () => translate('Artist'),
+ isVisible: true,
+ },
+ {
+ name: 'foreignArtistId',
+ label: () => translate('MusicbrainzId'),
+ isVisible: true,
+ },
+];
+
+const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
+
+interface SelectArtistModalContentProps {
+ modalTitle: string;
+ onArtistSelect(artist: Artist): void;
+ onModalClose(): void;
+}
+
+interface RowItemData {
+ items: Artist[];
+ columns: Column[];
+ onArtistSelect(artistId: number): void;
+}
+
+const Row: React.FC> = ({
+ index,
+ style,
+ data,
+}) => {
+ const { items, columns, onArtistSelect } = data;
+
+ if (index >= items.length) {
+ return null;
+ }
+
+ const artist = items[index];
+
+ return (
+ onArtistSelect(artist.id)}
+ >
+
+
+ );
+};
+
+function SelectArtistModalContent(props: SelectArtistModalContentProps) {
+ const { modalTitle, onArtistSelect, onModalClose } = props;
+
+ const listRef = useRef>(null);
+ const scrollerRef = useRef(null);
+ const allArtist: Artist[] = useSelector(createAllArtistSelector());
+ const [filter, setFilter] = useState('');
+ const [size, setSize] = useState({ width: 0, height: 0 });
+ const windowHeight = window.innerHeight;
+
+ useEffect(() => {
+ const current = scrollerRef?.current as HTMLElement;
+
+ if (current) {
+ const width = current.clientWidth;
+ const height = current.clientHeight;
+ const padding = bodyPadding - 5;
+
+ setSize({
+ width: width - padding * 2,
+ height: height + padding,
+ });
+ }
+ }, [windowHeight, scrollerRef]);
+
+ useEffect(() => {
+ const currentScrollerRef = scrollerRef.current as HTMLElement;
+ const currentScrollListener = currentScrollerRef;
+
+ const handleScroll = throttle(() => {
+ const { offsetTop = 0 } = currentScrollerRef;
+ const scrollTop = currentScrollerRef.scrollTop - offsetTop;
+
+ listRef.current?.scrollTo(scrollTop);
+ }, 10);
+
+ currentScrollListener.addEventListener('scroll', handleScroll);
+
+ return () => {
+ handleScroll.cancel();
+
+ if (currentScrollListener) {
+ currentScrollListener.removeEventListener('scroll', handleScroll);
+ }
+ };
+ }, [listRef, scrollerRef]);
+
+ const onFilterChange = useCallback(
+ ({ value }: { value: string }) => {
+ setFilter(value);
+ },
+ [setFilter]
+ );
+
+ const onArtistSelectWrapper = useCallback(
+ (artistId: number) => {
+ const artist = allArtist.find((s) => s.id === artistId) as Artist;
+
+ onArtistSelect(artist);
+ },
+ [allArtist, onArtistSelect]
+ );
+
+ const items = useMemo(() => {
+ const sorted = [...allArtist].sort((a, b) =>
+ a.sortName.localeCompare(b.sortName)
+ );
+
+ return sorted.filter(
+ (item) =>
+ item.artistName.toLowerCase().includes(filter.toLowerCase()) ||
+ item.foreignArtistId.toLowerCase().includes(filter.toLowerCase())
+ );
+ }, [allArtist, filter]);
+
+ return (
+
+ {modalTitle} - Select Artist
+
+
+
+
+
+
+
+ ref={listRef}
+ style={{
+ width: '100%',
+ height: '100%',
+ overflow: 'none',
+ }}
+ width={size.width}
+ height={size.height}
+ itemCount={items.length}
+ itemSize={38}
+ itemData={{
+ items,
+ columns,
+ onArtistSelect: onArtistSelectWrapper,
+ }}
+ >
+ {Row}
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SelectArtistModalContent;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.css b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.css
new file mode 100644
index 000000000..ef7d798b2
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.css
@@ -0,0 +1,11 @@
+.artistName {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 4 0 200px;
+}
+
+.foreignArtistId {
+ composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 250px;
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.css.d.ts
new file mode 100644
index 000000000..f5ec69459
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'artistName': string;
+ 'foreignArtistId': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.tsx b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.tsx
new file mode 100644
index 000000000..5fbfa13a8
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistModalTableHeader.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import Column from 'Components/Table/Column';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import styles from './SelectArtistModalTableHeader.css';
+
+interface SelectArtistModalTableHeaderProps {
+ columns: Column[];
+}
+
+function SelectArtistModalTableHeader(
+ props: SelectArtistModalTableHeaderProps
+) {
+ const { columns } = props;
+
+ return (
+
+ {columns.map((column) => {
+ const { name, label, isVisible } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+
+ {typeof label === 'function' ? label() : label}
+
+ );
+ })}
+
+ );
+}
+
+export default SelectArtistModalTableHeader;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.css b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.css
new file mode 100644
index 000000000..734527a80
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.css
@@ -0,0 +1,19 @@
+.cell {
+ composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: flex;
+ align-items: center;
+}
+
+.artistName {
+ composes: cell;
+
+ flex: 4 0 200px;
+}
+
+.foreignArtistId {
+ composes: cell;
+
+ flex: 0 0 250px;
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.css.d.ts
new file mode 100644
index 000000000..905f0a827
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'artistName': string;
+ 'cell': string;
+ 'foreignArtistId': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.js b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.js
new file mode 100644
index 000000000..b26960368
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Artist/SelectArtistRow.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Label from 'Components/Label';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import styles from './SelectArtistRow.css';
+
+class SelectArtistRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onArtistSelect(this.props.id);
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+ <>
+
+ {this.props.artistName}
+
+
+
+
+
+ >
+ );
+ }
+}
+
+SelectArtistRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ onArtistSelect: PropTypes.func.isRequired
+};
+
+export default SelectArtistRow;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Quality/SelectQualityModal.tsx b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Quality/SelectQualityModal.tsx
new file mode 100644
index 000000000..89401142f
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Quality/SelectQualityModal.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import { QualityModel } from 'Quality/Quality';
+import SelectQualityModalContent from './SelectQualityModalContent';
+
+interface SelectQualityModalProps {
+ isOpen: boolean;
+ qualityId: number;
+ proper: boolean;
+ real: boolean;
+ modalTitle: string;
+ onQualitySelect(quality: QualityModel): void;
+ onModalClose(): void;
+}
+
+function SelectQualityModal(props: SelectQualityModalProps) {
+ const {
+ isOpen,
+ qualityId,
+ proper,
+ real,
+ modalTitle,
+ onQualitySelect,
+ onModalClose,
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default SelectQualityModal;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Quality/SelectQualityModalContent.tsx b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Quality/SelectQualityModalContent.tsx
new file mode 100644
index 000000000..edb65663c
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/InterarctiveSearch/Quality/SelectQualityModalContent.tsx
@@ -0,0 +1,185 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import { Error } from 'App/State/AppSectionState';
+import AppState from 'App/State/AppState';
+import Alert from 'Components/Alert';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Quality, { QualityModel } from 'Quality/Quality';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import { CheckInputChanged } from 'typings/inputs';
+import getQualities from 'Utilities/Quality/getQualities';
+import translate from 'Utilities/String/translate';
+
+interface QualitySchemaState {
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ items: Quality[];
+}
+
+function createQualitySchemaSelector() {
+ return createSelector(
+ (state: AppState) => state.settings.qualityProfiles,
+ (qualityProfiles): QualitySchemaState => {
+ const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
+ qualityProfiles;
+
+ const items = getQualities(schema.items) as Quality[];
+
+ return {
+ isFetching: isSchemaFetching,
+ isPopulated: isSchemaPopulated,
+ error: schemaError,
+ items,
+ };
+ }
+ );
+}
+
+interface SelectQualityModalContentProps {
+ qualityId: number;
+ proper: boolean;
+ real: boolean;
+ modalTitle: string;
+ onQualitySelect(quality: QualityModel): void;
+ onModalClose(): void;
+}
+
+function SelectQualityModalContent(props: SelectQualityModalContentProps) {
+ const { modalTitle, onQualitySelect, onModalClose } = props;
+
+ const [qualityId, setQualityId] = useState(props.qualityId);
+ const [proper, setProper] = useState(props.proper);
+ const [real, setReal] = useState(props.real);
+
+ const { isFetching, isPopulated, error, items } = useSelector(
+ createQualitySchemaSelector()
+ );
+ const dispatch = useDispatch();
+
+ useEffect(
+ () => {
+ dispatch(fetchQualityProfileSchema());
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
+ const qualityOptions = useMemo(() => {
+ return items.map(({ id, name }) => {
+ return {
+ key: id,
+ value: name,
+ };
+ });
+ }, [items]);
+
+ const onQualityChange = useCallback(
+ ({ value }: { value: string }) => {
+ setQualityId(parseInt(value));
+ },
+ [setQualityId]
+ );
+
+ const onProperChange = useCallback(
+ ({ value }: CheckInputChanged) => {
+ setProper(value);
+ },
+ [setProper]
+ );
+
+ const onRealChange = useCallback(
+ ({ value }: CheckInputChanged) => {
+ setReal(value);
+ },
+ [setReal]
+ );
+
+ const onQualitySelectWrapper = useCallback(() => {
+ const quality = items.find((item) => item.id === qualityId) as Quality;
+
+ const revision = {
+ version: proper ? 2 : 1,
+ real: real ? 1 : 0,
+ isRepack: false,
+ };
+
+ onQualitySelect({
+ quality,
+ revision,
+ });
+ }, [items, qualityId, proper, real, onQualitySelect]);
+
+ return (
+
+ {modalTitle} - Select Quality
+
+
+ {isFetching && }
+
+ {!isFetching && error ? (
+ {translate('QualitiesLoadError')}
+ ) : null}
+
+ {isPopulated && !error ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SelectQualityModalContent;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css
new file mode 100644
index 000000000..bd4d2f788
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css
@@ -0,0 +1,17 @@
+.link {
+ composes: link from '~Components/Link/Link.css';
+
+ width: 100%;
+}
+
+.placeholder {
+ display: inline-block;
+ margin: -2px 0;
+ width: 100%;
+ outline: 2px dashed var(--dangerColor);
+ outline-offset: -2px;
+}
+
+.optional {
+ outline: 2px dashed var(--gray);
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts
new file mode 100644
index 000000000..dd3ac4575
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'link': string;
+ 'optional': string;
+ 'placeholder': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.tsx b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.tsx
new file mode 100644
index 000000000..82d6bd812
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchData.tsx
@@ -0,0 +1,35 @@
+import classNames from 'classnames';
+import React from 'react';
+import Link from 'Components/Link/Link';
+import styles from './OverrideMatchData.css';
+
+interface OverrideMatchDataProps {
+ value?: string | number | JSX.Element | JSX.Element[];
+ isDisabled?: boolean;
+ isOptional?: boolean;
+ onPress: () => void;
+}
+
+function OverrideMatchData(props: OverrideMatchDataProps) {
+ const { value, isDisabled = false, isOptional, onPress } = props;
+
+ return (
+
+ {(value == null || (Array.isArray(value) && value.length === 0)) &&
+ !isDisabled ? (
+
+
+
+ ) : (
+ value
+ )}
+
+ );
+}
+
+export default OverrideMatchData;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx
new file mode 100644
index 000000000..21a974304
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModal.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import { sizes } from 'Helpers/Props';
+import ReleaseAlbum from 'InteractiveSearch/ReleaseAlbum';
+import { QualityModel } from 'Quality/Quality';
+import OverrideMatchModalContent from './OverrideMatchModalContent';
+
+interface OverrideMatchModalProps {
+ isOpen: boolean;
+ title: string;
+ indexerId: number;
+ guid: string;
+ artistId?: number;
+ albums: ReleaseAlbum[];
+ quality: QualityModel;
+ protocol: DownloadProtocol;
+ isGrabbing: boolean;
+ grabError?: string;
+ onModalClose(): void;
+}
+
+function OverrideMatchModal(props: OverrideMatchModalProps) {
+ const {
+ isOpen,
+ title,
+ indexerId,
+ guid,
+ artistId,
+ albums,
+ quality,
+ protocol,
+ isGrabbing,
+ grabError,
+ onModalClose,
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default OverrideMatchModal;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css
new file mode 100644
index 000000000..a5b4b8d52
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css
@@ -0,0 +1,49 @@
+.label {
+ composes: label from '~Components/Label.css';
+
+ cursor: pointer;
+}
+
+.item {
+ display: block;
+ margin-bottom: 5px;
+ margin-left: 50px;
+}
+
+.footer {
+ composes: modalFooter from '~Components/Modal/ModalFooter.css';
+
+ display: flex;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.error {
+ margin-right: 20px;
+ color: var(--dangerColor);
+ word-break: break-word;
+}
+
+.buttons {
+ display: flex;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .item {
+ margin-left: 0;
+ }
+
+ .footer {
+ display: block;
+ }
+
+ .error {
+ margin-right: 0;
+ margin-bottom: 10px;
+ }
+
+ .buttons {
+ justify-content: space-between;
+ flex-grow: 1;
+ }
+}
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
new file mode 100644
index 000000000..79c77d6b5
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'buttons': string;
+ 'error': string;
+ 'footer': string;
+ 'item': string;
+ 'label': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx
new file mode 100644
index 000000000..25fc23d1c
--- /dev/null
+++ b/frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.tsx
@@ -0,0 +1,310 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Album from 'Album/Album';
+import TrackQuality from 'Album/TrackQuality';
+import Artist from 'Artist/Artist';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import ReleaseAlbum from 'InteractiveSearch/ReleaseAlbum';
+import { QualityModel } from 'Quality/Quality';
+import { grabRelease } from 'Store/Actions/releaseActions';
+import { fetchDownloadClients } from 'Store/Actions/settingsActions';
+import { createArtistSelectorForHook } from 'Store/Selectors/createArtistSelector';
+import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
+import translate from 'Utilities/String/translate';
+import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
+import SelectAlbumModal from './InterarctiveSearch/Album/SelectAlbumModal';
+import SelectArtistModal from './InterarctiveSearch/Artist/SelectArtistModal';
+import SelectQualityModal from './InterarctiveSearch/Quality/SelectQualityModal';
+import OverrideMatchData from './OverrideMatchData';
+import styles from './OverrideMatchModalContent.css';
+
+type SelectType =
+ | 'select'
+ | 'artist'
+ | 'album'
+ | 'quality'
+ | 'language'
+ | 'downloadClient';
+
+interface SelectedAlbum {
+ id: number;
+ albums: Album[];
+}
+
+interface OverrideMatchModalContentProps {
+ indexerId: number;
+ title: string;
+ guid: string;
+ artistId?: number;
+ albums: ReleaseAlbum[];
+ quality: QualityModel;
+ protocol: DownloadProtocol;
+ isGrabbing: boolean;
+ grabError?: string;
+ onModalClose(): void;
+}
+
+function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
+ const modalTitle = translate('ManualGrab');
+ const {
+ indexerId,
+ title,
+ guid,
+ protocol,
+ isGrabbing,
+ grabError,
+ onModalClose,
+ } = props;
+
+ const [artistId, setArtistId] = useState(props.artistId);
+ const [albums, setAlbums] = useState(props.albums);
+ const [quality, setQuality] = useState(props.quality);
+ const [downloadClientId, setDownloadClientId] = useState(null);
+ const [error, setError] = useState(null);
+ const [selectModalOpen, setSelectModalOpen] = useState(
+ null
+ );
+ const previousIsGrabbing = usePrevious(isGrabbing);
+
+ const dispatch = useDispatch();
+ const artist: Artist | undefined = useSelector(
+ createArtistSelectorForHook(artistId)
+ );
+ const { items: downloadClients } = useSelector(
+ createEnabledDownloadClientsSelector(protocol)
+ );
+
+ const albumInfo = useMemo(() => {
+ return albums.map((album) => {
+ return {album.title}
;
+ });
+ }, [albums]);
+
+ const onSelectModalClose = useCallback(() => {
+ setSelectModalOpen(null);
+ }, [setSelectModalOpen]);
+
+ const onSelectArtistPress = useCallback(() => {
+ setSelectModalOpen('artist');
+ }, [setSelectModalOpen]);
+
+ const onArtistSelect = useCallback(
+ (s: Artist) => {
+ setArtistId(s.id);
+ setAlbums([]);
+ setSelectModalOpen(null);
+ },
+ [setArtistId, setAlbums, setSelectModalOpen]
+ );
+
+ const onSelectAlbumPress = useCallback(() => {
+ setSelectModalOpen('album');
+ }, [setSelectModalOpen]);
+
+ const onAlbumsSelect = useCallback(
+ (albumMap: SelectedAlbum[]) => {
+ setAlbums(albumMap[0].albums);
+ setSelectModalOpen(null);
+ },
+ [setAlbums, setSelectModalOpen]
+ );
+
+ const onSelectQualityPress = useCallback(() => {
+ setSelectModalOpen('quality');
+ }, [setSelectModalOpen]);
+
+ const onQualitySelect = useCallback(
+ (quality: QualityModel) => {
+ setQuality(quality);
+ setSelectModalOpen(null);
+ },
+ [setQuality, setSelectModalOpen]
+ );
+
+ const onSelectDownloadClientPress = useCallback(() => {
+ setSelectModalOpen('downloadClient');
+ }, [setSelectModalOpen]);
+
+ const onDownloadClientSelect = useCallback(
+ (downloadClientId: number) => {
+ setDownloadClientId(downloadClientId);
+ setSelectModalOpen(null);
+ },
+ [setDownloadClientId, setSelectModalOpen]
+ );
+
+ const onGrabPress = useCallback(() => {
+ if (!artistId) {
+ setError(translate('OverrideGrabNoArtist'));
+ return;
+ } else if (!albums.length) {
+ setError(translate('OverrideGrabNoAlbum'));
+ return;
+ } else if (!quality) {
+ setError(translate('OverrideGrabNoQuality'));
+ return;
+ }
+
+ dispatch(
+ grabRelease({
+ indexerId,
+ guid,
+ artistId,
+ albumsIds: albums.map((a) => a.id),
+ quality,
+ downloadClientId,
+ shouldOverride: true,
+ })
+ );
+ }, [
+ indexerId,
+ guid,
+ artistId,
+ albums,
+ quality,
+ downloadClientId,
+ setError,
+ dispatch,
+ ]);
+
+ useEffect(() => {
+ if (!isGrabbing && previousIsGrabbing) {
+ onModalClose();
+ }
+ }, [isGrabbing, previousIsGrabbing, onModalClose]);
+
+ useEffect(
+ () => {
+ dispatch(fetchDownloadClients());
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
+ return (
+
+
+ {translate('OverrideGrabModalTitle', { title })}
+
+
+
+
+
+ }
+ />
+
+
+ }
+ />
+
+
+ }
+ onPress={onSelectQualityPress}
+ />
+ }
+ />
+
+ {downloadClients.length > 1 ? (
+ downloadClient.id === downloadClientId
+ )?.name ?? translate('Default')
+ }
+ onPress={onSelectDownloadClientPress}
+ />
+ }
+ />
+ ) : null}
+
+
+
+
+ {error || grabError}
+
+
+
+
+
+ {translate('GrabRelease')}
+
+
+
+
+
+
+
+
+ 1 : false}
+ real={quality ? quality.revision.real > 0 : false}
+ modalTitle={modalTitle}
+ onQualitySelect={onQualitySelect}
+ onModalClose={onSelectModalClose}
+ />
+
+
+
+ );
+}
+
+export default OverrideMatchModalContent;
diff --git a/frontend/src/InteractiveSearch/ReleaseAlbum.ts b/frontend/src/InteractiveSearch/ReleaseAlbum.ts
new file mode 100644
index 000000000..6915582ae
--- /dev/null
+++ b/frontend/src/InteractiveSearch/ReleaseAlbum.ts
@@ -0,0 +1,6 @@
+interface ReleaseAlbum {
+ id: number;
+ title: string;
+}
+
+export default ReleaseAlbum;
diff --git a/frontend/src/Store/Actions/albumSelectionActions.js b/frontend/src/Store/Actions/albumSelectionActions.js
new file mode 100644
index 000000000..69d352849
--- /dev/null
+++ b/frontend/src/Store/Actions/albumSelectionActions.js
@@ -0,0 +1,61 @@
+import { createAction } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import { createThunk, handleThunks } from 'Store/thunks';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import createFetchHandler from './Creators/createFetchHandler';
+import createHandleActions from './Creators/createHandleActions';
+import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
+
+//
+// Variables
+
+export const section = 'albumSelection';
+
+//
+// State
+
+export const defaultState = {
+ isFetching: false,
+ isReprocessing: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ items: []
+};
+
+//
+// Actions Types
+
+export const FETCH_ALBUMS = 'albumSelection/fetchAlbums';
+export const SET_ALBUMS_SORT = 'albumSelection/setAlbumsSort';
+export const CLEAR_ALBUMS = 'albumSelection/clearAlbums';
+
+//
+// Action Creators
+
+export const fetchAlbums = createThunk(FETCH_ALBUMS);
+export const setAlbumsSort = createAction(SET_ALBUMS_SORT);
+export const clearAlbums = createAction(CLEAR_ALBUMS);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+ [FETCH_ALBUMS]: createFetchHandler(section, '/album')
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(section),
+
+ [CLEAR_ALBUMS]: (state) => {
+ return updateSectionState(state, section, {
+ ...defaultState
+ });
+ }
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js
index 95b02d089..11b864e77 100644
--- a/frontend/src/Store/Actions/index.js
+++ b/frontend/src/Store/Actions/index.js
@@ -1,5 +1,6 @@
import * as albums from './albumActions';
import * as albumHistory from './albumHistoryActions';
+import * as albumSelection from './albumSelectionActions';
import * as app from './appActions';
import * as artist from './artistActions';
import * as artistHistory from './artistHistoryActions';
@@ -36,6 +37,7 @@ export default [
albums,
trackFiles,
albumHistory,
+ albumSelection,
history,
interactiveImportActions,
oAuth,
diff --git a/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts
new file mode 100644
index 000000000..ac31e5210
--- /dev/null
+++ b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts
@@ -0,0 +1,22 @@
+import { createSelector } from 'reselect';
+import { DownloadClientAppState } from 'App/State/SettingsAppState';
+import DownloadProtocol from 'DownloadClient/DownloadProtocol';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import sortByName from 'Utilities/Array/sortByName';
+
+export default function createEnabledDownloadClientsSelector(
+ protocol: DownloadProtocol
+) {
+ return createSelector(
+ createSortedSectionSelector('settings.downloadClients', sortByName),
+ (downloadClients: DownloadClientAppState) => {
+ const { isFetching, isPopulated, error, items } = downloadClients;
+
+ const clients = items.filter(
+ (item) => item.protocol === protocol && item.enable
+ );
+
+ return { isFetching, isPopulated, error, items: clients };
+ }
+ );
+}
diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseController.cs b/src/Lidarr.Api.V1/Indexers/ReleaseController.cs
index e36618e04..efed137ff 100644
--- a/src/Lidarr.Api.V1/Indexers/ReleaseController.cs
+++ b/src/Lidarr.Api.V1/Indexers/ReleaseController.cs
@@ -6,6 +6,7 @@ using Lidarr.Http;
using Microsoft.AspNetCore.Mvc;
using NLog;
using NzbDrone.Common.Cache;
+using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download;
@@ -81,6 +82,30 @@ namespace Lidarr.Api.V1.Indexers
try
{
+ if (release.ShouldOverride == true)
+ {
+ Ensure.That(release.ArtistId, () => release.ArtistId).IsNotNull();
+ Ensure.That(release.AlbumIds, () => release.AlbumIds).IsNotNull();
+ Ensure.That(release.AlbumIds, () => release.AlbumIds).HasItems();
+ Ensure.That(release.Quality, () => release.Quality).IsNotNull();
+
+ // Clone the remote episode so we don't overwrite anything on the original
+ remoteAlbum = new RemoteAlbum
+ {
+ Release = remoteAlbum.Release,
+ ParsedAlbumInfo = remoteAlbum.ParsedAlbumInfo.JsonClone(),
+ DownloadAllowed = remoteAlbum.DownloadAllowed,
+ SeedConfiguration = remoteAlbum.SeedConfiguration,
+ CustomFormats = remoteAlbum.CustomFormats,
+ CustomFormatScore = remoteAlbum.CustomFormatScore,
+ ReleaseSource = remoteAlbum.ReleaseSource
+ };
+
+ remoteAlbum.Artist = _artistService.GetArtist(release.ArtistId!.Value);
+ remoteAlbum.Albums = _albumService.GetAlbums(release.AlbumIds);
+ remoteAlbum.ParsedAlbumInfo.Quality = release.Quality;
+ }
+
if (remoteAlbum.Artist == null)
{
if (release.AlbumId.HasValue)
diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs
index 5ad2b70d0..6c895f188 100644
--- a/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs
+++ b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs
@@ -6,6 +6,7 @@ using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http.REST;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Music;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;
@@ -31,6 +32,8 @@ namespace Lidarr.Api.V1.Indexers
public string AirDate { get; set; }
public string ArtistName { get; set; }
public string AlbumTitle { get; set; }
+ public int? MappedArtistId { get; set; }
+ public IEnumerable MappedAlbumInfo { get; set; }
public bool Approved { get; set; }
public bool TemporarilyRejected { get; set; }
public bool Rejected { get; set; }
@@ -52,20 +55,22 @@ namespace Lidarr.Api.V1.Indexers
// Sent when queuing an unknown release
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
-
- // [JsonIgnore]
public int? ArtistId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
-
- // [JsonIgnore]
public int? AlbumId { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public List AlbumIds { get; set; }
+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? DownloadClientId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public string DownloadClient { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public bool? ShouldOverride { get; set; }
}
public static class ReleaseResourceMapper
@@ -96,6 +101,8 @@ namespace Lidarr.Api.V1.Indexers
ArtistName = parsedAlbumInfo.ArtistName,
AlbumTitle = parsedAlbumInfo.AlbumTitle,
Discography = parsedAlbumInfo.Discography,
+ MappedArtistId = remoteAlbum.Artist?.Id,
+ MappedAlbumInfo = remoteAlbum.Albums.Select(v => new ReleaseAlbumResource(v)),
Approved = model.Approved,
TemporarilyRejected = model.TemporarilyRejected,
Rejected = model.Rejected,
@@ -151,4 +158,20 @@ namespace Lidarr.Api.V1.Indexers
return model;
}
}
+
+ public class ReleaseAlbumResource
+ {
+ public int Id { get; set; }
+ public string Title { get; set; }
+
+ public ReleaseAlbumResource()
+ {
+ }
+
+ public ReleaseAlbumResource(Album album)
+ {
+ Id = album.Id;
+ Title = album.Title;
+ }
+ }
}
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 520837a8f..cd8baf900 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -41,6 +41,7 @@
"AddReleaseProfile": "Add Release Profile",
"AddRemotePathMapping": "Add Remote Path Mapping",
"AddRootFolder": "Add Root Folder",
+ "AddToDownloadQueue": "Add to download queue",
"Added": "Added",
"AddedArtistSettings": "Added Artist Settings",
"AddingTag": "Adding tag",
@@ -267,6 +268,7 @@
"DateAdded": "Date Added",
"Dates": "Dates",
"Deceased": "Deceased",
+ "Default": "Default",
"DefaultCase": "Default Case",
"DefaultDelayProfileHelpText": "This is the default profile. It applies to all artist that don't have an explicit profile.",
"DefaultLidarrTags": "Default {appName} Tags",
@@ -371,6 +373,7 @@
"DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}",
"DownloadClientTagHelpText": "Only use this download client for artists with at least one matching tag. Leave blank to use with all artists.",
"DownloadClients": "Download Clients",
+ "DownloadClientsLoadError": "Unable to load download clients",
"DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings",
"DownloadFailed": "Download Failed",
"DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details",
@@ -462,6 +465,7 @@
"Filename": "Filename",
"Files": "Files",
"FilterAlbumPlaceholder": "Filter album",
+ "FilterAlbumsPlaceholder": "Filter albums by title or Musicbrainz Id",
"FilterArtistPlaceholder": "Filter artist",
"Filters": "Filters",
"FirstAlbum": "First Album",
@@ -507,6 +511,7 @@
"GrabId": "Grab ID",
"GrabRelease": "Grab Release",
"GrabReleaseMessageText": "{appName} was unable to determine which artist and album this release was for. {appName} may be unable to automatically import this release. Do you want to grab '{0}'?",
+ "GrabReleaseUnknownArtistOrAlbumMessageText": "{appName} was unable to determine which artist and album this release was for. {appName} may be unable to automatically import this release. Do you want to grab '{title}'?",
"GrabSelected": "Grab Selected",
"Grabbed": "Grabbed",
"Group": "Group",
@@ -651,6 +656,7 @@
"ManageTracks": "Manage Tracks",
"Manual": "Manual",
"ManualDownload": "Manual Download",
+ "ManualGrab": "Manual Grab",
"ManualImport": "Manual Import",
"MarkAsFailed": "Mark as Failed",
"MarkAsFailedMessageText": "Are you sure you want to mark '{0}' as failed?",
@@ -740,6 +746,7 @@
"NextExecution": "Next Execution",
"No": "No",
"NoAlbums": "No albums",
+ "NoAlbumsFoundForSelectedArtist": "No albums were found for the selected artist",
"NoBackupsAreAvailable": "No backups are available",
"NoChange": "No Change",
"NoCutoffUnmetItems": "No cutoff unmet items",
@@ -789,6 +796,11 @@
"Original": "Original",
"Other": "Other",
"OutputPath": "Output Path",
+ "OverrideAndAddToDownloadQueue": "Override and add to download queue",
+ "OverrideGrabModalTitle": "Override and Grab - {title}",
+ "OverrideGrabNoAlbum": "At least one album must be selected",
+ "OverrideGrabNoArtist": "Artist must be selected",
+ "OverrideGrabNoQuality": "Quality must be selected",
"Overview": "Overview",
"OverviewOptions": "Overview Options",
"PackageVersion": "Package Version",
@@ -822,6 +834,7 @@
"PrimaryAlbumTypes": "Primary Album Types",
"PrimaryTypes": "Primary Types",
"Priority": "Priority",
+ "PrioritySettings": "Priority: {priority}",
"Proceed": "Proceed",
"Profiles": "Profiles",
"ProfilesSettingsArtistSummary": "Quality, Metadata, Delay, and Release profiles",
@@ -840,6 +853,7 @@
"ProxyUsernameHelpText": "You only need to enter a username and password if one is required. Leave them blank otherwise.",
"PublishedDate": "Published Date",
"QualitiesHelpText": "Qualities higher in the list are more preferred even if not checked. Qualities within the same group are equal. Only checked qualities are wanted",
+ "QualitiesLoadError": "Unable to load qualities",
"Quality": "Quality",
"QualityDefinitions": "Quality Definitions",
"QualityLimitsHelpText": "Limits are automatically adjusted for the album duration.",
@@ -1013,7 +1027,10 @@
"Select...": "Select...",
"SelectAlbum": "Select Album",
"SelectAlbumRelease": "Select Album Release",
+ "SelectAlbums": "Select Album(s)",
+ "SelectAlbumsModalTitle": "{modalTitle} - Select Album(s)",
"SelectArtist": "Select Artist",
+ "SelectDownloadClientModalTitle": "{modalTitle} - Select Download Client",
"SelectFolder": "Select Folder",
"SelectQuality": "Select Quality",
"SelectReleaseGroup": "Select Release Group",
@@ -1127,7 +1144,7 @@
"Total": "Total",
"TotalFileSize": "Total File Size",
"TotalSpace": "Total Space",
- "TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "{0} tracks total. {1} tracks with files.",
+ "TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "{totalTrackCount} tracks total. {trackFileCount} tracks with files.",
"Track": "Track",
"TrackArtist": "Track Artist",
"TrackCount": "Track Count",