pull/4572/merge
Bogdan 2 months ago committed by GitHub
commit a120c0da29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -28,7 +28,8 @@ module.exports = {
globals: { globals: {
expect: false, expect: false,
chai: false, chai: false,
sinon: false sinon: false,
JSX: true
}, },
parserOptions: { parserOptions: {

@ -0,0 +1,4 @@
.row {
composes: link from '~Components/Link/Link.css';
composes: row from '~./VirtualTableRow.css';
}

@ -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;

@ -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 (
<Link
className={styles.row}
component={VirtualTableRow}
{...props}
/>
);
}
export default VirtualTableRowButton;

@ -0,0 +1,7 @@
enum DownloadProtocol {
Unknown = 'unknown',
Usenet = 'usenet',
Torrent = 'torrent',
}
export default DownloadProtocol;

@ -40,6 +40,7 @@ import {
faChevronCircleRight as fasChevronCircleRight, faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp, faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle, faCircle as fasCircle,
faCircleDown as fasCircleDown,
faCloud as fasCloud, faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt, faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog, faCog as fasCog,
@ -134,6 +135,7 @@ export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasSquareCheck; export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle; export const CIRCLE = fasCircle;
export const CIRCLE_DOWN = fasCircleDown;
export const CIRCLE_OUTLINE = farCircle; export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt; export const CLEAR = fasTrashAlt;
export const CLIPBOARD = fasCopy; export const CLIPBOARD = fasCopy;

@ -4,9 +4,10 @@
width: 80px; width: 80px;
} }
.title { .titleContent {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; display: flex;
align-items: center;
justify-content: space-between;
word-break: break-all; word-break: break-all;
} }
@ -34,8 +35,7 @@
cursor: default; cursor: default;
} }
.rejected, .rejected {
.download {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 50px; width: 50px;
@ -53,3 +53,39 @@
width: 75px; 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;
}

@ -4,13 +4,16 @@ interface CssExports {
'age': string; 'age': string;
'customFormatScore': string; 'customFormatScore': string;
'download': string; 'download': string;
'downloadIcon': string;
'indexer': string; 'indexer': string;
'interactiveIcon': string;
'manualDownloadContent': string;
'peers': string; 'peers': string;
'protocol': string; 'protocol': string;
'quality': string; 'quality': string;
'rejected': string; 'rejected': string;
'size': string; 'size': string;
'title': string; 'titleContent': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default cssExports; export default cssExports;

@ -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 (
<TableRow>
<TableRowCell className={styles.protocol}>
<ProtocolLabel
protocol={protocol}
/>
</TableRowCell>
<TableRowCell
className={styles.age}
title={formatDateTime(publishDate, longDateFormat, timeFormat, { includeSeconds: true })}
>
{formatAge(age, ageHours, ageMinutes)}
</TableRowCell>
<TableRowCell className={styles.title}>
<Link to={infoUrl}>
{title}
</Link>
</TableRowCell>
<TableRowCell className={styles.indexer}>
{indexer}
</TableRowCell>
<TableRowCell className={styles.size}>
{formatBytes(size)}
</TableRowCell>
<TableRowCell className={styles.peers}>
{
protocol === 'torrent' &&
<Peers
seeders={seeders}
leechers={leechers}
/>
}
</TableRowCell>
<TableRowCell className={styles.quality}>
<TrackQuality quality={quality} showRevision={true} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={
formatCustomFormatScore(customFormatScore, customFormats.length)
}
tooltip={<AlbumFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.rejected}>
{
!!rejections.length &&
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title={translate('ReleaseRejected')}
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
/>
}
</TableRowCell>
<TableRowCell className={styles.download}>
{
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError, downloadAllowed)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={this.state.isConfirmGrabModalOpen}
kind={kinds.WARNING}
title={translate('GrabRelease')}
message={translate('GrabReleaseMessageText', [title])}
confirmLabel={translate('Grab')}
onConfirm={this.onGrabConfirm}
onCancel={this.onGrabCancel}
/>
</TableRow>
);
}
}
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;

@ -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 (
<TableRow>
<TableRowCell className={styles.protocol}>
<ProtocolLabel protocol={protocol} />
</TableRowCell>
<TableRowCell
className={styles.age}
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
includeSeconds: true,
})}
>
{formatAge(age, ageHours, ageMinutes)}
</TableRowCell>
<TableRowCell>
<div className={styles.titleContent}>
<Link to={infoUrl}>{title}</Link>
</div>
</TableRowCell>
<TableRowCell className={styles.indexer}>{indexer}</TableRowCell>
<TableRowCell className={styles.size}>{formatBytes(size)}</TableRowCell>
<TableRowCell className={styles.peers}>
{protocol === 'torrent' ? (
<Peers seeders={seeders} leechers={leechers} />
) : null}
</TableRowCell>
<TableRowCell className={styles.quality}>
<TrackQuality quality={quality} showRevision={true} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats.length
)}
tooltip={<AlbumFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.rejected}>
{rejections.length ? (
<Popover
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
title={translate('ReleaseRejected')}
body={
<ul>
{rejections.map((rejection, index) => {
return <li key={index}>{rejection}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
/>
) : null}
</TableRowCell>
<TableRowCell className={styles.download}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={onGrabPressWrapper}
/>
<Link
className={styles.manualDownloadContent}
title={translate('OverrideAndAddToDownloadQueue')}
onPress={onOverridePress}
>
<div className={styles.manualDownloadContent}>
<Icon
className={styles.interactiveIcon}
name={icons.INTERACTIVE}
size={12}
/>
<Icon
className={styles.downloadIcon}
name={icons.CIRCLE_DOWN}
size={10}
/>
</div>
</Link>
</TableRowCell>
<ConfirmModal
isOpen={isConfirmGrabModalOpen}
kind={kinds.WARNING}
title={translate('GrabRelease')}
message={translate('GrabReleaseUnknownArtistOrAlbumMessageText', {
title,
})}
confirmLabel={translate('Grab')}
onConfirm={onGrabConfirm}
onCancel={onGrabCancel}
/>
<OverrideMatchModal
isOpen={isOverrideModalOpen}
title={title}
indexerId={indexerId}
guid={guid}
artistId={mappedArtistId}
albums={mappedAlbumInfo}
quality={quality}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onOverrideModalClose}
/>
</TableRow>
);
}
export default InteractiveSearchRow;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<SelectDownloadClientModalContent
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectDownloadClientModal;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('SelectDownloadClientModalTitle', { modalTitle })}
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('DownloadClientsLoadError')}
</Alert>
) : null}
{isPopulated && !error ? (
<Form>
{items.map((downloadClient) => {
const { id, name, priority } = downloadClient;
return (
<SelectDownloadClientRow
key={id}
id={id}
name={name}
priority={priority}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})}
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectDownloadClientModalContent;

@ -0,0 +1,6 @@
.downloadClient {
display: flex;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--borderColor);
}

@ -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;

@ -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 (
<Link
className={styles.downloadClient}
component="div"
onPress={onDownloadClientSelectWrapper}
>
<div>{name}</div>
<div>{translate('PrioritySettings', { priority })}</div>
</Link>
);
}
export default SelectDownloadClientRow;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectAlbumModalContent
selectedIds={selectedIds}
artistId={artistId}
selectedDetails={selectedDetails}
modalTitle={modalTitle}
onAlbumsSelect={onAlbumsSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectAlbumModal;

@ -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;
}
}

@ -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;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('SelectAlbumsModalTitle', { modalTitle })}
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder={translate('FilterAlbumsPlaceholder')}
name="filter"
value={filter}
autoFocus={true}
onChange={onFilterChange}
/>
<Scroller className={styles.scroller} autoFocus={false}>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length ? (
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return item.title.toLowerCase().includes(filter) ||
item.foreignAlbumId.toLowerCase().includes(filter) ? (
<SelectAlbumRow
key={item.id}
id={item.id}
foreignAlbumId={item.foreignAlbumId}
title={item.title}
disambiguation={item.disambiguation}
albumType={item.albumType}
releaseDate={item.releaseDate}
statistics={item.statistics}
monitored={item.monitored}
isSelected={selectedState[item.id]}
onSelectedChange={onSelectedChange}
/>
) : null;
})}
</TableBody>
</Table>
) : null}
{isPopulated && !items.length
? translate('NoAlbumsFoundForSelectedArtist')
: null}
</Scroller>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.details}>{details}</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button
kind={kinds.SUCCESS}
isDisabled={!selectionIsValid}
onPress={onAlbumsSelectWrapper}
>
{translate('SelectAlbums')}
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default SelectAlbumModalContent;

@ -0,0 +1,5 @@
.foreignAlbumId {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
font-family: $monoSpaceFontFamily;
}

@ -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;

@ -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 (
<TableRowButton onPress={this.onPress}>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
<TableRowCell>
{extendedTitle}
</TableRowCell>
<TableRowCell>
{albumType}
</TableRowCell>
<RelativeDateCellConnector date={releaseDate} />
<TableRowCell>
<Label
title={translate('TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp', { totalTrackCount, trackFileCount })}
kind={getTrackCountKind(monitored, trackFileCount, trackCount)}
size={sizes.MEDIUM}
>
<span>{trackFileCount} / {trackCount}</span>
</Label>
</TableRowCell>
<TableRowCell className={styles.foreignAlbumId}>
<Label>{foreignAlbumId}</Label>
</TableRowCell>
</TableRowButton>
);
}
}
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;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectArtistModalContent
modalTitle={modalTitle}
onArtistSelect={onArtistSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectArtistModal;

@ -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;
}

@ -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;

@ -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<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
const { items, columns, onArtistSelect } = data;
if (index >= items.length) {
return null;
}
const artist = items[index];
return (
<VirtualTableRowButton
style={{
display: 'flex',
justifyContent: 'space-between',
...style,
}}
onPress={() => onArtistSelect(artist.id)}
>
<SelectArtistRow
key={artist.id}
id={artist.id}
artistName={artist.artistName}
foreignArtistId={artist.foreignArtistId}
columns={columns}
onArtistSelect={onArtistSelect}
/>
</VirtualTableRowButton>
);
};
function SelectArtistModalContent(props: SelectArtistModalContentProps) {
const { modalTitle, onArtistSelect, onModalClose } = props;
const listRef = useRef<List<RowItemData>>(null);
const scrollerRef = useRef<HTMLDivElement>(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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Artist</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<TextInput
className={styles.filterInput}
placeholder={translate('FilterArtistPlaceholder')}
name="filter"
value={filter}
autoFocus={true}
onChange={onFilterChange}
/>
<Scroller
className={styles.scroller}
autoFocus={false}
ref={scrollerRef}
>
<SelectArtistModalTableHeader columns={columns} />
<List<RowItemData>
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}
</List>
</Scroller>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectArtistModalContent;

@ -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;
}

@ -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;

@ -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 (
<VirtualTableHeader>
{columns.map((column) => {
const { name, label, isVisible } = column;
if (!isVisible) {
return null;
}
return (
<VirtualTableHeaderCell
key={name}
className={
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
styles[name]
}
name={name}
>
{typeof label === 'function' ? label() : label}
</VirtualTableHeaderCell>
);
})}
</VirtualTableHeader>
);
}
export default SelectArtistModalTableHeader;

@ -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;
}

@ -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;

@ -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 (
<>
<VirtualTableRowCell className={styles.artistName}>
{this.props.artistName}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.foreignArtistId}>
<Label>{this.props.foreignArtistId}</Label>
</VirtualTableRowCell>
</>
);
}
}
SelectArtistRow.propTypes = {
id: PropTypes.number.isRequired,
artistName: PropTypes.string.isRequired,
foreignArtistId: PropTypes.string.isRequired,
onArtistSelect: PropTypes.func.isRequired
};
export default SelectArtistRow;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SelectQualityModalContent
qualityId={qualityId}
proper={proper}
real={real}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectQualityModal;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Quality</ModalHeader>
<ModalBody>
{isFetching && <LoadingIndicator />}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('QualitiesLoadError')}</Alert>
) : null}
{isPopulated && !error ? (
<Form>
<FormGroup>
<FormLabel>{translate('Quality')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="quality"
value={qualityId}
values={qualityOptions}
onChange={onQualityChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Proper')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="proper"
value={proper}
onChange={onProperChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Real')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="real"
value={real}
onChange={onRealChange}
/>
</FormGroup>
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.SUCCESS} onPress={onQualitySelectWrapper}>
{translate('SelectQuality')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectQualityModalContent;

@ -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);
}

@ -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;

@ -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 (
<Link className={styles.link} isDisabled={isDisabled} onPress={onPress}>
{(value == null || (Array.isArray(value) && value.length === 0)) &&
!isDisabled ? (
<span
className={classNames(
styles.placeholder,
isOptional && styles.optional
)}
>
&nbsp;
</span>
) : (
value
)}
</Link>
);
}
export default OverrideMatchData;

@ -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 (
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
<OverrideMatchModalContent
title={title}
indexerId={indexerId}
guid={guid}
artistId={artistId}
albums={albums}
quality={quality}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default OverrideMatchModal;

@ -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;
}
}

@ -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;

@ -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<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
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 <div key={album.id}>{album.title}</div>;
});
}, [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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('OverrideGrabModalTitle', { title })}
</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem
className={styles.item}
title={translate('Artist')}
data={
<OverrideMatchData
value={artist?.artistName}
onPress={onSelectArtistPress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Albums')}
data={
<OverrideMatchData
value={albumInfo}
isDisabled={!artist}
onPress={onSelectAlbumPress}
/>
}
/>
<DescriptionListItem
className={styles.item}
title={translate('Quality')}
data={
<OverrideMatchData
value={
<TrackQuality className={styles.label} quality={quality} />
}
onPress={onSelectQualityPress}
/>
}
/>
{downloadClients.length > 1 ? (
<DescriptionListItem
className={styles.item}
title={translate('DownloadClient')}
data={
<OverrideMatchData
value={
downloadClients.find(
(downloadClient) => downloadClient.id === downloadClientId
)?.name ?? translate('Default')
}
onPress={onSelectDownloadClientPress}
/>
}
/>
) : null}
</DescriptionList>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.error}>{error || grabError}</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isGrabbing}
error={grabError}
onPress={onGrabPress}
>
{translate('GrabRelease')}
</SpinnerErrorButton>
</div>
</ModalFooter>
<SelectArtistModal
isOpen={selectModalOpen === 'artist'}
modalTitle={modalTitle}
onArtistSelect={onArtistSelect}
onModalClose={onSelectModalClose}
/>
<SelectAlbumModal
isOpen={selectModalOpen === 'album'}
selectedIds={[guid]}
artistId={artistId}
selectedDetails={title}
modalTitle={modalTitle}
onAlbumsSelect={onAlbumsSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<SelectDownloadClientModal
isOpen={selectModalOpen === 'downloadClient'}
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onSelectModalClose}
/>
</ModalContent>
);
}
export default OverrideMatchModalContent;

@ -0,0 +1,6 @@
interface ReleaseAlbum {
id: number;
title: string;
}
export default ReleaseAlbum;

@ -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);

@ -1,5 +1,6 @@
import * as albums from './albumActions'; import * as albums from './albumActions';
import * as albumHistory from './albumHistoryActions'; import * as albumHistory from './albumHistoryActions';
import * as albumSelection from './albumSelectionActions';
import * as app from './appActions'; import * as app from './appActions';
import * as artist from './artistActions'; import * as artist from './artistActions';
import * as artistHistory from './artistHistoryActions'; import * as artistHistory from './artistHistoryActions';
@ -36,6 +37,7 @@ export default [
albums, albums,
trackFiles, trackFiles,
albumHistory, albumHistory,
albumSelection,
history, history,
interactiveImportActions, interactiveImportActions,
oAuth, oAuth,

@ -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 };
}
);
}

@ -6,6 +6,7 @@ using Lidarr.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NLog; using NLog;
using NzbDrone.Common.Cache; using NzbDrone.Common.Cache;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
@ -81,6 +82,30 @@ namespace Lidarr.Api.V1.Indexers
try 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 (remoteAlbum.Artist == null)
{ {
if (release.AlbumId.HasValue) if (release.AlbumId.HasValue)

@ -6,6 +6,7 @@ using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http.REST; using Lidarr.Http.REST;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.Music;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -31,6 +32,8 @@ namespace Lidarr.Api.V1.Indexers
public string AirDate { get; set; } public string AirDate { get; set; }
public string ArtistName { get; set; } public string ArtistName { get; set; }
public string AlbumTitle { get; set; } public string AlbumTitle { get; set; }
public int? MappedArtistId { get; set; }
public IEnumerable<ReleaseAlbumResource> MappedAlbumInfo { get; set; }
public bool Approved { get; set; } public bool Approved { get; set; }
public bool TemporarilyRejected { get; set; } public bool TemporarilyRejected { get; set; }
public bool Rejected { get; set; } public bool Rejected { get; set; }
@ -52,20 +55,22 @@ namespace Lidarr.Api.V1.Indexers
// Sent when queuing an unknown release // Sent when queuing an unknown release
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
// [JsonIgnore]
public int? ArtistId { get; set; } public int? ArtistId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
// [JsonIgnore]
public int? AlbumId { get; set; } public int? AlbumId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public List<int> AlbumIds { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? DownloadClientId { get; set; } public int? DownloadClientId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public string DownloadClient { get; set; } public string DownloadClient { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool? ShouldOverride { get; set; }
} }
public static class ReleaseResourceMapper public static class ReleaseResourceMapper
@ -96,6 +101,8 @@ namespace Lidarr.Api.V1.Indexers
ArtistName = parsedAlbumInfo.ArtistName, ArtistName = parsedAlbumInfo.ArtistName,
AlbumTitle = parsedAlbumInfo.AlbumTitle, AlbumTitle = parsedAlbumInfo.AlbumTitle,
Discography = parsedAlbumInfo.Discography, Discography = parsedAlbumInfo.Discography,
MappedArtistId = remoteAlbum.Artist?.Id,
MappedAlbumInfo = remoteAlbum.Albums.Select(v => new ReleaseAlbumResource(v)),
Approved = model.Approved, Approved = model.Approved,
TemporarilyRejected = model.TemporarilyRejected, TemporarilyRejected = model.TemporarilyRejected,
Rejected = model.Rejected, Rejected = model.Rejected,
@ -151,4 +158,20 @@ namespace Lidarr.Api.V1.Indexers
return model; 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;
}
}
} }

@ -41,6 +41,7 @@
"AddReleaseProfile": "Add Release Profile", "AddReleaseProfile": "Add Release Profile",
"AddRemotePathMapping": "Add Remote Path Mapping", "AddRemotePathMapping": "Add Remote Path Mapping",
"AddRootFolder": "Add Root Folder", "AddRootFolder": "Add Root Folder",
"AddToDownloadQueue": "Add to download queue",
"Added": "Added", "Added": "Added",
"AddedArtistSettings": "Added Artist Settings", "AddedArtistSettings": "Added Artist Settings",
"AddingTag": "Adding tag", "AddingTag": "Adding tag",
@ -269,6 +270,7 @@
"DateAdded": "Date Added", "DateAdded": "Date Added",
"Dates": "Dates", "Dates": "Dates",
"Deceased": "Deceased", "Deceased": "Deceased",
"Default": "Default",
"DefaultCase": "Default Case", "DefaultCase": "Default Case",
"DefaultDelayProfileHelpText": "This is the default profile. It applies to all artist that don't have an explicit profile.", "DefaultDelayProfileHelpText": "This is the default profile. It applies to all artist that don't have an explicit profile.",
"DefaultLidarrTags": "Default {appName} Tags", "DefaultLidarrTags": "Default {appName} Tags",
@ -377,6 +379,7 @@
"DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", "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.", "DownloadClientTagHelpText": "Only use this download client for artists with at least one matching tag. Leave blank to use with all artists.",
"DownloadClients": "Download Clients", "DownloadClients": "Download Clients",
"DownloadClientsLoadError": "Unable to load download clients",
"DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings", "DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings",
"DownloadFailed": "Download Failed", "DownloadFailed": "Download Failed",
"DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details", "DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details",
@ -468,6 +471,7 @@
"Filename": "Filename", "Filename": "Filename",
"Files": "Files", "Files": "Files",
"FilterAlbumPlaceholder": "Filter album", "FilterAlbumPlaceholder": "Filter album",
"FilterAlbumsPlaceholder": "Filter albums by title or Musicbrainz Id",
"FilterArtistPlaceholder": "Filter artist", "FilterArtistPlaceholder": "Filter artist",
"Filters": "Filters", "Filters": "Filters",
"FirstAlbum": "First Album", "FirstAlbum": "First Album",
@ -513,6 +517,7 @@
"GrabId": "Grab ID", "GrabId": "Grab ID",
"GrabRelease": "Grab Release", "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}'?", "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", "GrabSelected": "Grab Selected",
"Grabbed": "Grabbed", "Grabbed": "Grabbed",
"Group": "Group", "Group": "Group",
@ -658,6 +663,7 @@
"ManageTracks": "Manage Tracks", "ManageTracks": "Manage Tracks",
"Manual": "Manual", "Manual": "Manual",
"ManualDownload": "Manual Download", "ManualDownload": "Manual Download",
"ManualGrab": "Manual Grab",
"ManualImport": "Manual Import", "ManualImport": "Manual Import",
"MarkAsFailed": "Mark as Failed", "MarkAsFailed": "Mark as Failed",
"MarkAsFailedMessageText": "Are you sure you want to mark '{0}' as failed?", "MarkAsFailedMessageText": "Are you sure you want to mark '{0}' as failed?",
@ -747,6 +753,7 @@
"NextExecution": "Next Execution", "NextExecution": "Next Execution",
"No": "No", "No": "No",
"NoAlbums": "No albums", "NoAlbums": "No albums",
"NoAlbumsFoundForSelectedArtist": "No albums were found for the selected artist",
"NoBackupsAreAvailable": "No backups are available", "NoBackupsAreAvailable": "No backups are available",
"NoChange": "No Change", "NoChange": "No Change",
"NoCutoffUnmetItems": "No cutoff unmet items", "NoCutoffUnmetItems": "No cutoff unmet items",
@ -815,6 +822,11 @@
"Original": "Original", "Original": "Original",
"Other": "Other", "Other": "Other",
"OutputPath": "Output Path", "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", "Overview": "Overview",
"OverviewOptions": "Overview Options", "OverviewOptions": "Overview Options",
"PackageVersion": "Package Version", "PackageVersion": "Package Version",
@ -848,6 +860,7 @@
"PrimaryAlbumTypes": "Primary Album Types", "PrimaryAlbumTypes": "Primary Album Types",
"PrimaryTypes": "Primary Types", "PrimaryTypes": "Primary Types",
"Priority": "Priority", "Priority": "Priority",
"PrioritySettings": "Priority: {priority}",
"Proceed": "Proceed", "Proceed": "Proceed",
"Profiles": "Profiles", "Profiles": "Profiles",
"ProfilesSettingsArtistSummary": "Quality, Metadata, Delay, and Release 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.", "ProxyUsernameHelpText": "You only need to enter a username and password if one is required. Leave them blank otherwise.",
"PublishedDate": "Published Date", "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", "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", "Quality": "Quality",
"QualityDefinitions": "Quality Definitions", "QualityDefinitions": "Quality Definitions",
"QualityLimitsHelpText": "Limits are automatically adjusted for the album duration.", "QualityLimitsHelpText": "Limits are automatically adjusted for the album duration.",
@ -1039,7 +1053,10 @@
"Select...": "Select...", "Select...": "Select...",
"SelectAlbum": "Select Album", "SelectAlbum": "Select Album",
"SelectAlbumRelease": "Select Album Release", "SelectAlbumRelease": "Select Album Release",
"SelectAlbums": "Select Album(s)",
"SelectAlbumsModalTitle": "{modalTitle} - Select Album(s)",
"SelectArtist": "Select Artist", "SelectArtist": "Select Artist",
"SelectDownloadClientModalTitle": "{modalTitle} - Select Download Client",
"SelectFolder": "Select Folder", "SelectFolder": "Select Folder",
"SelectQuality": "Select Quality", "SelectQuality": "Select Quality",
"SelectReleaseGroup": "Select Release Group", "SelectReleaseGroup": "Select Release Group",
@ -1153,7 +1170,7 @@
"Total": "Total", "Total": "Total",
"TotalFileSize": "Total File Size", "TotalFileSize": "Total File Size",
"TotalSpace": "Total Space", "TotalSpace": "Total Space",
"TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "{0} tracks total. {1} tracks with files.", "TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp": "{totalTrackCount} tracks total. {trackFileCount} tracks with files.",
"Track": "Track", "Track": "Track",
"TrackArtist": "Track Artist", "TrackArtist": "Track Artist",
"TrackCount": "Track Count", "TrackCount": "Track Count",

Loading…
Cancel
Save