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: {
expect: false,
chai: false,
sinon: false
sinon: false,
JSX: true
},
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,
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;

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

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

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

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

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

@ -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",

Loading…
Cancel
Save