mirror of https://github.com/lidarr/Lidarr
New: Option to override release and grab
(cherry picked from commit 07f0fbf9a51d54e44681fd0f74df4e048bff561a)
This commit is contained in:
parent
616b529c9a
commit
d10285e40d
|
@ -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);
|
||||||
|
}
|
7
frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
vendored
Normal file
7
frontend/src/InteractiveSearch/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts
vendored
Normal file
|
@ -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
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
11
frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
vendored
Normal file
11
frontend/src/InteractiveSearch/OverrideMatch/OverrideMatchModalContent.css.d.ts
vendored
Normal file
|
@ -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",
|
||||||
|
@ -267,6 +268,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",
|
||||||
|
@ -371,6 +373,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",
|
||||||
|
@ -462,6 +465,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",
|
||||||
|
@ -507,6 +511,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",
|
||||||
|
@ -651,6 +656,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?",
|
||||||
|
@ -740,6 +746,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",
|
||||||
|
@ -789,6 +796,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",
|
||||||
|
@ -822,6 +834,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",
|
||||||
|
@ -840,6 +853,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.",
|
||||||
|
@ -1013,7 +1027,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",
|
||||||
|
@ -1127,7 +1144,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…
Reference in New Issue