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 ? ( +
+ {items.map((downloadClient) => { + const { id, name, priority } = downloadClient; + + return ( + + ); + })} + + ) : 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 ? ( +
+ + {translate('Quality')} + + + + + + {translate('Proper')} + + + + + + {translate('Real')} + + + +
+ ) : 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 ff5d7380b..2a795fd26 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", @@ -269,6 +270,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", @@ -377,6 +379,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", @@ -468,6 +471,7 @@ "Filename": "Filename", "Files": "Files", "FilterAlbumPlaceholder": "Filter album", + "FilterAlbumsPlaceholder": "Filter albums by title or Musicbrainz Id", "FilterArtistPlaceholder": "Filter artist", "Filters": "Filters", "FirstAlbum": "First Album", @@ -513,6 +517,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", @@ -658,6 +663,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?", @@ -747,6 +753,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", @@ -815,6 +822,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", @@ -848,6 +860,7 @@ "PrimaryAlbumTypes": "Primary Album Types", "PrimaryTypes": "Primary Types", "Priority": "Priority", + "PrioritySettings": "Priority: {priority}", "Proceed": "Proceed", "Profiles": "Profiles", "ProfilesSettingsArtistSummary": "Quality, Metadata, Delay, and Release profiles", @@ -866,6 +879,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.", @@ -1039,7 +1053,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", @@ -1153,7 +1170,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",