Whole album matching and fingerprinting (#592)

* Cache result of GetAllArtists

* Fixed: Manual import not respecting album import notifications

* Fixed: partial album imports stay in queue, prompting manual import

* Fixed: Allow release if tracks are missing

* Fixed: Be tolerant of missing/extra "The" at start of artist name

* Improve manual import UI

* Omit video tracks from DB entirely

* Revert "faster test packaging in build.sh"

This reverts commit 2723e2a7b8.

-u and -T are not supported on macOS

* Fix tests on linux and macOS

* Actually lint on linux

On linux yarn runs scripts with sh not bash so ** doesn't recursively glob

* Match whole albums

* Option to disable fingerprinting

* Rip out MediaInfo

* Don't split up things that have the same album selected in manual import

* Try to speed up IndentificationService

* More speedups

* Some fixes and increase power of recording id

* Fix NRE when no tags

* Fix NRE when some (but not all) files in a directory have missing tags

* Bump taglib, tidy up tag parsing

* Add a health check

* Remove media info setting

* Tags -> audioTags

* Add some tests where tags are null

* Rename history events

* Add missing method to interface

* Reinstate MediaInfo tags and update info with artist scan

Also adds migration to remove old format media info

* This file no longer exists

* Don't penalise year if missing from tags

* Formatting improvements

* Use correct system newline

* Switch to the netstandard2.0 library to support net 461

* TagLib.File is IDisposable so should be in a using

* Improve filename matching and add tests

* Neater logging of parsed tags

* Fix disk scan tests for new media info update

* Fix quality detection source

* Fix Inexact Artist/Album match

* Add button to clear track mapping

* Fix warning

* Pacify eslint

* Use \ not /

* Fix UI updates

* Fix media covers

Prevent localizing URL propaging back to the metadata object

* Reduce database overhead broadcasting UI updates

* Relax timings a bit to make test pass

* Remove irrelevant tests

* Test framework for identification service

* Fix PreferMissingToBadMatch test case

* Make fingerprinting more robust

* More logging

* Penalize unknown media format and country

* Prefer USA to UK

* Allow Data CD

* Fix exception if fingerprinting fails for all files

* Fix tests

* Fix NRE

* Allow apostrophes and remove accents in filename aggregation

* Address codacy issues

* Cope with old versions of fpcalc and suggest upgrade

* fpcalc health check passes if fingerprinting disabled

* Get the Artist meta with the artist

* Fix the mapper so that lazy loaded lists will be populated on Join

And therefore we can join TrackFiles on Tracks by default and avoid an
extra query

* Rename subtitle -> lyric

* Tidy up MediaInfoFormatter
This commit is contained in:
ta264 2019-02-16 14:49:24 +00:00 committed by Qstick
parent 8bf364945f
commit bb02d73c42
174 changed files with 11577 additions and 3490 deletions

View file

@ -8,6 +8,33 @@ import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
function getDetailedList(statusMessages) {
return (
<div>
{
statusMessages.map(({ title, messages }) => {
return (
<div key={title}>
{title}
<ul>
{
messages.map((message) => {
return (
<li key={message}>
{message}
</li>
);
})
}
</ul>
</div>
);
})
}
</div>
);
}
function HistoryDetails(props) {
const {
eventType,
@ -124,7 +151,7 @@ function HistoryDetails(props) {
);
}
if (eventType === 'downloadFolderImported') {
if (eventType === 'trackFileImported') {
const {
droppedPath,
importedPath
@ -224,6 +251,113 @@ function HistoryDetails(props) {
</DescriptionList>
);
}
if (eventType === 'albumImportIncomplete') {
const {
statusMessages
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!statusMessages &&
<DescriptionListItem
title="Import failures"
data={getDetailedList(JSON.parse(statusMessages))}
/>
}
</DescriptionList>
);
}
if (eventType === 'downloadImported') {
const {
indexer,
releaseGroup,
nzbInfoUrl,
downloadClient,
downloadId,
age,
ageHours,
ageMinutes,
publishedDate
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!indexer &&
<DescriptionListItem
title="Indexer"
data={indexer}
/>
}
{
!!releaseGroup &&
<DescriptionListItem
title="Release Group"
data={releaseGroup}
/>
}
{
!!nzbInfoUrl &&
<span>
<DescriptionListItemTitle>
Info URL
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span>
}
{
!!downloadClient &&
<DescriptionListItem
title="Download Client"
data={downloadClient}
/>
}
{
!!downloadId &&
<DescriptionListItem
title="Grab ID"
data={downloadId}
/>
}
{
!!indexer &&
<DescriptionListItem
title="Age (when grabbed)"
data={formatAge(age, ageHours, ageMinutes)}
/>
}
{
!!publishedDate &&
<DescriptionListItem
title="Published Date"
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
/>
}
</DescriptionList>
);
}
}
HistoryDetails.propTypes = {

View file

@ -17,12 +17,16 @@ function getHeaderTitle(eventType) {
return 'Grabbed';
case 'downloadFailed':
return 'Download Failed';
case 'downloadFolderImported':
case 'trackFileImported':
return 'Track Imported';
case 'trackFileDeleted':
return 'Track File Deleted';
case 'trackFileRenamed':
return 'Track File Renamed';
case 'albumImportIncomplete':
return 'Album Import Incomplete';
case 'downloadImported':
return 'Download Completed';
default:
return 'Unknown';
}

View file

@ -11,7 +11,7 @@ function getIconName(eventType) {
return icons.DOWNLOADING;
case 'artistFolderImported':
return icons.DRIVE;
case 'downloadFolderImported':
case 'trackFileImported':
return icons.DOWNLOADED;
case 'downloadFailed':
return icons.DOWNLOADING;
@ -19,6 +19,10 @@ function getIconName(eventType) {
return icons.DELETE;
case 'trackFileRenamed':
return icons.ORGANIZE;
case 'albumImportIncomplete':
return icons.DOWNLOADED;
case 'downloadImported':
return icons.DOWNLOADED;
default:
return icons.UNKNOWN;
}
@ -28,6 +32,8 @@ function getIconKind(eventType) {
switch (eventType) {
case 'downloadFailed':
return kinds.DANGER;
case 'albumImportIncomplete':
return kinds.WARNING;
default:
return kinds.DEFAULT;
}
@ -39,7 +45,7 @@ function getTooltip(eventType, data) {
return `Album grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
case 'artistFolderImported':
return 'Track imported from artist folder';
case 'downloadFolderImported':
case 'trackFileImported':
return 'Track downloaded successfully and picked up from download client';
case 'downloadFailed':
return 'Album download failed';
@ -47,6 +53,10 @@ function getTooltip(eventType, data) {
return 'Track file deleted';
case 'trackFileRenamed':
return 'Track file renamed';
case 'albumImportIncomplete':
return 'Files downloaded but not all could be imported';
case 'downloadImported':
return 'Download completed and successfully imported';
default:
return 'Unknown event';
}

View file

@ -18,7 +18,7 @@ function getTitle(eventType) {
switch (eventType) {
case 'grabbed': return 'Grabbed';
case 'artistFolderImported': return 'Artist Folder Imported';
case 'downloadFolderImported': return 'Download Folder Imported';
case 'trackFileImported': return 'Download Folder Imported';
case 'downloadFailed': return 'Download Failed';
case 'trackFileDeleted': return 'Track File Deleted';
case 'trackFileRenamed': return 'Track File Renamed';

View file

@ -53,6 +53,7 @@ import {
faEye as fasEye,
faFastBackward as fasFastBackward,
faFastForward as fasFastForward,
faFileImport as fasFileImport,
faFilter as fasFilter,
faFolderOpen as fasFolderOpen,
faForward as fasForward,
@ -137,6 +138,7 @@ export const EXPAND_INDETERMINATE = fasChevronCircleRight;
export const EXTERNAL_LINK = fasExternalLinkAlt;
export const FATAL = fasTimesCircle;
export const FILE = farFile;
export const FILEIMPORT = fasFileImport;
export const FILTER = fasFilter;
export const FOLDER = farFolder;
export const FOLDER_OPEN = fasFolderOpen;

View file

@ -59,16 +59,19 @@ class SelectAlbumModalContentConnector extends Component {
onAlbumSelect = (albumId) => {
const album = _.find(this.props.items, { id: albumId });
this.props.ids.forEach((id) => {
const ids = this.props.ids;
ids.forEach((id) => {
this.props.updateInteractiveImportItem({
id,
album,
tracks: [],
rejections: []
});
this.props.saveInteractiveImportItem({ id });
});
this.props.saveInteractiveImportItem({ id: ids });
this.props.onModalClose(true);
}

View file

@ -0,0 +1,65 @@
.fileDetails {
margin-bottom: 20px;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
&:last-of-type {
margin-bottom: 0;
}
}
.header {
position: relative;
display: flex;
align-items: center;
width: 100%;
font-size: 18px;
}
.filename {
flex-grow: 1;
margin-right: 10px;
margin-left: 10px;
}
.expandButton {
position: relative;
width: 60px;
height: 60px;
}
.actionButton {
composes: button from 'Components/Link/IconButton.css';
width: 30px;
}
.audioTags {
padding-top: 15px;
padding-bottom: 15px;
border-top: 1px solid $borderColor;
}
.expandButtonIcon {
composes: actionButton;
position: absolute;
top: 50%;
left: 50%;
margin-top: -12px;
margin-left: -15px;
}
@media only screen and (max-width: $breakpointSmall) {
.medium {
border-right: 0;
border-left: 0;
border-radius: 0;
}
.expandButtonIcon {
position: static;
margin: 0;
}
}

View file

@ -0,0 +1,258 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import styles from './FileDetails.css';
class FileDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isExpanded: props.isExpanded
};
}
//
// Listeners
onExpandPress = () => {
const {
isExpanded
} = this.state;
this.setState({ isExpanded: !isExpanded });
}
//
// Render
renderRejections() {
const {
rejections
} = this.props;
return (
<span>
<DescriptionListItemTitle>
Rejections
</DescriptionListItemTitle>
{
_.map(rejections, (item, key) => {
return (
<DescriptionListItemDescription key={key}>
{item.reason}
</DescriptionListItemDescription>
);
})
}
</span>
);
}
render() {
const {
filename,
audioTags,
rejections
} = this.props;
const {
isExpanded
} = this.state;
return (
<div
className={styles.fileDetails}
>
<div className={styles.header} onClick={this.onExpandPress}>
<div className={styles.filename}>
{filename}
</div>
<div className={styles.expandButton}>
<Icon
className={styles.expandButtonIcon}
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
title={isExpanded ? 'Hide file info' : 'Show file info'}
size={24}
/>
</div>
</div>
<div>
{
isExpanded &&
<div className={styles.audioTags}>
<DescriptionList>
{
audioTags.title !== undefined &&
<DescriptionListItem
title="Track Title"
data={audioTags.title}
/>
}
{
audioTags.trackNumbers[0] > 0 &&
<DescriptionListItem
title="Track Number"
data={audioTags.trackNumbers[0]}
/>
}
{
audioTags.discNumber > 0 &&
<DescriptionListItem
title="Disc Number"
data={audioTags.discNumber}
/>
}
{
audioTags.discCount > 0 &&
<DescriptionListItem
title="Disc Count"
data={audioTags.discCount}
/>
}
{
audioTags.albumTitle !== undefined &&
<DescriptionListItem
title="Album"
data={audioTags.albumTitle}
/>
}
{
audioTags.artistTitle !== undefined &&
<DescriptionListItem
title="Artist"
data={audioTags.artistTitle}
/>
}
{
audioTags.country !== undefined &&
<DescriptionListItem
title="Country"
data={audioTags.country.name}
/>
}
{
audioTags.year > 0 &&
<DescriptionListItem
title="Year"
data={audioTags.year}
/>
}
{
audioTags.label !== undefined &&
<DescriptionListItem
title="Label"
data={audioTags.label}
/>
}
{
audioTags.catalogNumber !== undefined &&
<DescriptionListItem
title="Catalog Number"
data={audioTags.catalogNumber}
/>
}
{
audioTags.disambiguation !== undefined &&
<DescriptionListItem
title="Disambiguation"
data={audioTags.disambiguation}
/>
}
{
audioTags.duration !== undefined &&
<DescriptionListItem
title="Duration"
data={formatTimeSpan(audioTags.duration)}
/>
}
{
audioTags.artistMBId !== undefined &&
<Link
to={`https://musicbrainz.org/artist/${audioTags.artistMBId}`}
>
<DescriptionListItem
title="MusicBrainz Artist ID"
data={audioTags.artistMBId}
/>
</Link>
}
{
audioTags.albumMBId !== undefined &&
<Link
to={`https://musicbrainz.org/release-group/${audioTags.albumMBId}`}
>
<DescriptionListItem
title="MusicBrainz Album ID"
data={audioTags.albumMBId}
/>
</Link>
}
{
audioTags.releaseMBId !== undefined &&
<Link
to={`https://musicbrainz.org/release/${audioTags.releaseMBId}`}
>
<DescriptionListItem
title="MusicBrainz Release ID"
data={audioTags.releaseMBId}
/>
</Link>
}
{
audioTags.recordingMBId !== undefined &&
<Link
to={`https://musicbrainz.org/recording/${audioTags.recordingMBId}`}
>
<DescriptionListItem
title="MusicBrainz Recording ID"
data={audioTags.recordingMBId}
/>
</Link>
}
{
audioTags.trackMBId !== undefined &&
<Link
to={`https://musicbrainz.org/track/${audioTags.trackMBId}`}
>
<DescriptionListItem
title="MusicBrainz Track ID"
data={audioTags.trackMBId}
/>
</Link>
}
{
rejections.length > 0 &&
this.renderRejections()
}
</DescriptionList>
</div>
}
</div>
</div>
);
}
}
FileDetails.propTypes = {
audioTags: PropTypes.object.isRequired,
filename: PropTypes.string.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
isExpanded: PropTypes.bool
};
export default FileDetails;

View file

@ -19,12 +19,13 @@
.centerButtons,
.rightButtons {
display: flex;
flex: 1 0 33%;
flex: 1 2 25%;
flex-wrap: wrap;
}
.centerButtons {
justify-content: center;
flex: 2 1 50%;
}
.rightButtons {

View file

@ -155,6 +155,18 @@ class InteractiveImportModalContent extends Component {
this.setState({ isSelectAlbumModalOpen: true });
}
onClearTrackMappingPress = () => {
const selectedIds = this.getSelectedIds();
selectedIds.forEach((id) => {
this.props.updateInteractiveImportItem({
id,
tracks: [],
rejections: []
});
});
}
onSelectArtistModalClose = () => {
this.setState({ isSelectArtistModalOpen: false });
}
@ -328,6 +340,10 @@ class InteractiveImportModalContent extends Component {
>
Select Album
</Button>
<Button onPress={this.onClearTrackMappingPress}>
Clear Track Mapping
</Button>
</div>
<div className={styles.rightButtons}>
@ -362,6 +378,7 @@ class InteractiveImportModalContent extends Component {
artistId={selectedItem && selectedItem.artist && selectedItem.artist.id}
onModalClose={this.onSelectAlbumModalClose}
/>
</ModalContent>
);
}
@ -387,6 +404,7 @@ InteractiveImportModalContent.propTypes = {
onFilterExistingFilesChange: PropTypes.func.isRequired,
onImportModeChange: PropTypes.func.isRequired,
onImportSelectedPress: PropTypes.func.isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View file

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions';
import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
@ -23,6 +23,7 @@ const mapDispatchToProps = {
setInteractiveImportSort,
setInteractiveImportMode,
clearInteractiveImport,
updateInteractiveImportItem,
executeCommand
};
@ -195,6 +196,7 @@ InteractiveImportModalContentConnector.propTypes = {
setInteractiveImportSort: PropTypes.func.isRequired,
clearInteractiveImport: PropTypes.func.isRequired,
setInteractiveImportMode: PropTypes.func.isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View file

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import { icons, kinds, tooltipPositions, sortDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@ -167,11 +167,13 @@ class InteractiveImportRow extends Component {
relativePath,
artist,
album,
albumReleaseId,
tracks,
quality,
language,
size,
rejections,
audioTags,
isSelected,
onSelectedChange
} = this.props;
@ -327,6 +329,11 @@ class InteractiveImportRow extends Component {
id={id}
artistId={artist && artist.id}
albumId={album && album.id}
albumReleaseId={albumReleaseId}
rejections={rejections}
audioTags={audioTags}
sortKey='mediumNumber'
sortDirection={sortDirections.ASCENDING}
filename={relativePath}
onModalClose={this.onSelectTrackModalClose}
/>
@ -358,11 +365,13 @@ InteractiveImportRow.propTypes = {
relativePath: PropTypes.string.isRequired,
artist: PropTypes.object,
album: PropTypes.object,
albumReleaseId: PropTypes.number,
tracks: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object,
language: PropTypes.object,
size: PropTypes.number.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
audioTags: PropTypes.object.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
onValidRowChange: PropTypes.func.isRequired

View file

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import _ from 'lodash';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
@ -14,6 +15,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import SelectTrackRow from './SelectTrackRow';
import FileDetails from '../FileDetails';
const columns = [
{
@ -32,6 +34,19 @@ const columns = [
name: 'title',
label: 'Title',
isVisible: true
},
{
name: 'trackStatus',
label: 'Status',
isVisible: true
}
];
const selectAllBlankColumn = [
{
name: 'dummy',
label: ' ',
isVisible: true
}
];
@ -43,12 +58,17 @@ class SelectTrackModalContent extends Component {
constructor(props, context) {
super(props, context);
const selectedTracks = _.filter(props.selectedTracksByItem, ['id', props.id])[0].tracks;
const init = _.zipObject(selectedTracks, _.times(selectedTracks.length, _.constant(true)));
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {}
selectedState: init
};
props.onSortPress( props.sortKey, props.sortDirection );
}
//
@ -80,6 +100,9 @@ class SelectTrackModalContent extends Component {
render() {
const {
id,
audioTags,
rejections,
isFetching,
isPopulated,
error,
@ -88,6 +111,7 @@ class SelectTrackModalContent extends Component {
sortDirection,
onSortPress,
onModalClose,
selectedTracksByItem,
filename
} = this.props;
@ -97,13 +121,23 @@ class SelectTrackModalContent extends Component {
selectedState
} = this.state;
const title = `Manual Import - Select Track(s): ${filename}`;
const errorMessage = getErrorMessage(error, 'Unable to load tracks');
// all tracks selected for other items
const otherSelected = _.map(_.filter(selectedTracksByItem, (item) => {
return item.id !== id;
}), (x) => {
return x.tracks;
}).flat();
// tracks selected for the current file
const currentSelected = _.keys(_.pickBy(selectedState, _.identity)).map(Number);
// only enable selectAll if no other files have any tracks selected.
const selectAllEnabled = otherSelected.length === 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
Manual Import - Select Track(s):
</ModalHeader>
<ModalBody>
@ -117,11 +151,18 @@ class SelectTrackModalContent extends Component {
<div>{errorMessage}</div>
}
<FileDetails
audioTags={audioTags}
filename={filename}
rejections={rejections}
isExpanded={false}
/>
{
isPopulated && !!items.length &&
<Table
columns={columns}
selectAll={true}
columns={selectAllEnabled ? columns : selectAllBlankColumn.concat(columns)}
selectAll={selectAllEnabled}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
@ -139,6 +180,9 @@ class SelectTrackModalContent extends Component {
mediumNumber={item.mediumNumber}
trackNumber={item.absoluteTrackNumber}
title={item.title}
hasFile={item.hasFile}
importSelected={otherSelected.concat(currentSelected).includes(item.id)}
isDisabled={otherSelected.includes(item.id)}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
@ -173,6 +217,9 @@ class SelectTrackModalContent extends Component {
}
SelectTrackModalContent.propTypes = {
id: PropTypes.number.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
audioTags: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
@ -182,6 +229,7 @@ SelectTrackModalContent.propTypes = {
onSortPress: PropTypes.func.isRequired,
onTracksSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
selectedTracksByItem: PropTypes.arrayOf(PropTypes.object).isRequired,
filename: PropTypes.string.isRequired
};

View file

@ -11,8 +11,19 @@ import SelectTrackModalContent from './SelectTrackModalContent';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('tracks'),
(tracks) => {
return tracks;
createClientSideCollectionSelector('interactiveImport'),
(tracks, interactiveImport) => {
const selectedTracksByItem = _.map(interactiveImport.items, (item) => {
return { id: item.id, tracks: _.map(item.tracks, (track) => {
return track.id;
}) };
});
return {
...tracks,
selectedTracksByItem
};
}
);
}
@ -32,10 +43,11 @@ class SelectTrackModalContentConnector extends Component {
componentDidMount() {
const {
artistId,
albumId
albumId,
albumReleaseId
} = this.props;
this.props.fetchTracks({ artistId, albumId });
this.props.fetchTracks({ artistId, albumId, albumReleaseId });
}
componentWillUnmount() {
@ -86,6 +98,9 @@ SelectTrackModalContentConnector.propTypes = {
id: PropTypes.number.isRequired,
artistId: PropTypes.number.isRequired,
albumId: PropTypes.number.isRequired,
albumReleaseId: PropTypes.number.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
audioTags: PropTypes.object.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchTracks: PropTypes.func.isRequired,
setTracksSort: PropTypes.func.isRequired,

View file

@ -3,6 +3,9 @@ import React, { Component } from 'react';
import TableRowButton from 'Components/Table/TableRowButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
class SelectTrackRow extends Component {
@ -27,16 +30,50 @@ class SelectTrackRow extends Component {
mediumNumber,
trackNumber,
title,
hasFile,
importSelected,
isSelected,
isDisabled,
onSelectedChange
} = this.props;
let iconName = icons.UNKNOWN;
let iconKind = kinds.DEFAULT;
let iconTip = '';
if (hasFile && !importSelected) {
iconName = icons.DOWNLOADED;
iconKind = kinds.DEFAULT;
iconTip = 'Track already in library.';
} else if (!hasFile && !importSelected) {
iconName = icons.UNKNOWN;
iconKind = kinds.DEFAULT;
iconTip = 'Track missing from library and no import selected.';
} else if (importSelected && hasFile) {
iconName = icons.FILEIMPORT;
iconKind = kinds.WARNING;
iconTip = 'Warning: Existing track will be replaced by download.';
} else if (importSelected && !hasFile) {
iconName = icons.FILEIMPORT;
iconKind = kinds.DEFAULT;
iconTip = 'Track missing from library and selected for import.';
}
// isDisabled can only be true if importSelected is true
if (isDisabled) {
iconTip = `${iconTip}\nAnother file is selected to import for this track.`;
}
return (
<TableRowButton onPress={this.onPress}>
<TableRowButton
onPress={this.onPress}
isDisabled={isDisabled}
>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
isDisabled={isDisabled}
/>
<TableRowCell>
@ -51,6 +88,19 @@ class SelectTrackRow extends Component {
{title}
</TableRowCell>
<TableRowCell>
<Popover
anchor={
<Icon
name={iconName}
kind={iconKind}
/>
}
title={'Track status'}
body={iconTip}
position={tooltipPositions.LEFT}
/>
</TableRowCell>
</TableRowButton>
);
}
@ -61,7 +111,10 @@ SelectTrackRow.propTypes = {
mediumNumber: PropTypes.number.isRequired,
trackNumber: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
hasFile: PropTypes.bool.isRequired,
importSelected: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
isDisabled: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired
};

View file

@ -18,6 +18,12 @@ const rescanAfterRefreshOptions = [
{ key: 'never', value: 'Never' }
];
const allowFingerprintingOptions = [
{ key: 'allFiles', value: 'Always' },
{ key: 'newFiles', value: 'For new imports only' },
{ key: 'never', value: 'Never' }
];
const fileDateOptions = [
{ key: 'none', value: 'None' },
{ key: 'albumReleaseDate', value: 'Album Release Date' }
@ -209,22 +215,6 @@ class MediaManagement extends Component {
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>Analyse audio files</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableMediaInfo"
helpText="Extract audio information such as bitrate, runtime and codec information from files. This requires Lidarr to read parts of the file which may cause high disk or network activity during scans."
onChange={onInputChange}
{...settings.enableMediaInfo}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
@ -242,6 +232,23 @@ class MediaManagement extends Component {
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>Allow Fingerprinting</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="allowFingerprinting"
helpText="Use fingerprinting to improve accuracy of track matching"
helpTextWarning="This requires Lidarr to read parts of the file which will slow down scans and may cause high disk or network activity."
values={allowFingerprintingOptions}
onChange={onInputChange}
{...settings.allowFingerprinting}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}

View file

@ -168,11 +168,10 @@ class NamingModal extends Component {
];
const mediaInfoTokens = [
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' },
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
{ token: '{MediaInfo AudioFormat}', example: 'DTS' },
{ token: '{MediaInfo AudioChannels}', example: '5.1' }
{ token: '{MediaInfo AudioCodec}', example: 'FLAC' },
{ token: '{MediaInfo AudioChannels}', example: '2.0' },
{ token: '{MediaInfo AudioBitsPerSample}', example: '24bit' },
{ token: '{MediaInfo AudioSampleRate}', example: '44.1kHz' }
];
const releaseGroupTokens = [

View file

@ -25,7 +25,7 @@ function createSaveProviderHandler(section, url, options = {}) {
...otherPayload
} = payload;
const saveData = getProviderState({ id, ...otherPayload }, getState, section);
const saveData = Array.isArray(id) ? id.map((x) => getProviderState({ id: x, ...otherPayload }, getState, section)) : getProviderState({ id, ...otherPayload }, getState, section);
const ajaxOptions = {
url: `${url}?${$.param(queryParams, true)}`,
@ -36,8 +36,10 @@ function createSaveProviderHandler(section, url, options = {}) {
};
if (id) {
ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`;
ajaxOptions.method = 'PUT';
if (!Array.isArray(id)) {
ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`;
}
}
const { request, abortRequest } = createAjaxRequest(ajaxOptions);
@ -45,16 +47,18 @@ function createSaveProviderHandler(section, url, options = {}) {
abortCurrentRequests[section] = abortRequest;
request.done((data) => {
dispatch(batchActions([
updateItem({ section, ...data }),
set({
section,
isSaving: false,
saveError: null,
pendingChanges: {}
})
]));
if (!Array.isArray(data)) {
data = [data];
}
dispatch(batchActions(
data.map((item) => updateItem({ section, ...item })).concat(
set({
section,
isSaving: false,
saveError: null,
pendingChanges: {}
})
)));
});
request.fail((xhr) => {

View file

@ -76,8 +76,7 @@ export const defaultState = {
name: 'artistType',
label: 'Type',
isSortable: true,
isVisible: true,
isModifiable: false
isVisible: true
},
{
name: 'qualityProfileId',

View file

@ -109,8 +109,8 @@ export const defaultState = {
]
},
{
key: 'imported',
label: 'Imported',
key: 'trackFileImported',
label: 'Track Imported',
filters: [
{
key: 'eventType',
@ -121,7 +121,7 @@ export const defaultState = {
},
{
key: 'failed',
label: 'Failed',
label: 'Download Failed',
filters: [
{
key: 'eventType',
@ -130,6 +130,28 @@ export const defaultState = {
}
]
},
{
key: 'importFailed',
label: 'Import Failed',
filters: [
{
key: 'eventType',
value: '7',
type: filterTypes.EQUAL
}
]
},
{
key: 'downloadImported',
label: 'Download Imported',
filters: [
{
key: 'eventType',
value: '8',
type: filterTypes.EQUAL
}
]
},
{
key: 'deleted',
label: 'Deleted',