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
pull/635/head
ta264 5 years ago committed by Qstick
parent 8bf364945f
commit bb02d73c42

4
.gitignore vendored

@ -45,6 +45,10 @@ _dotCover*
# DevExpress CodeRush
src/.cr/
# Emacs
*~
\#*\#
# NCrunch
*.ncrunch*
.*crunch*.local.xml

@ -2,17 +2,24 @@ language: csharp
os:
- linux
- osx
addons:
apt:
packages:
- dos2unix
- nuget
- libchromaprint-tools
update: true
homebrew:
packages:
- yarn
- dos2unix
- nuget
update: true
solution: src/Lidarr.sln
before_install:
- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew update; fi
- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew install yarn; fi
- nvm install 8
- nvm use 8
script:
- ./build.sh
- chmod +x test.sh
- ./test.sh Linux Unit
after_success:
- chmod +x package.sh
- ./package.sh
- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then ./test.sh Mac Unit; fi
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then ./test.sh Linux Unit; fi

@ -5,7 +5,7 @@ outputFolderLinux='./_output_linux'
outputFolderMacOS='./_output_macos'
outputFolderMacOSApp='./_output_macos_app'
testPackageFolder='./_tests/'
testSearchPattern='*.Test/bin/x86/Release'
testSearchPattern='*.Test/bin/x86/Release/*'
sourceFolder='./src'
slnFile=$sourceFolder/Lidarr.sln
updateFolder=$outputFolder/Lidarr.Update
@ -97,7 +97,11 @@ LintUI()
ProgressEnd 'ESLint'
ProgressStart 'Stylelint'
CheckExitCode yarn stylelint
if [ $runtime = "dotnet" ] ; then
CheckExitCode yarn stylelint-windows
else
CheckExitCode yarn stylelint-linux
fi
ProgressEnd 'Stylelint'
}
@ -171,12 +175,9 @@ PackageMono()
rm -f $outputFolderLinux/ServiceUninstall.*
rm -f $outputFolderLinux/ServiceInstall.*
echo "Removing native windows binaries Sqlite, MediaInfo"
echo "Removing native windows binaries Sqlite, fpcalc"
rm -f $outputFolderLinux/sqlite3.*
rm -f $outputFolderLinux/MediaInfo.*
echo "Adding Lidarr.Core.dll.config (for dllmap)"
cp $sourceFolder/NzbDrone.Core/Lidarr.Core.dll.config $outputFolderLinux
rm -f $outputFolderLinux/fpcalc*
echo "Adding CurlSharp.dll.config (for dllmap)"
cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $outputFolderLinux
@ -209,13 +210,11 @@ PackageMacOS()
echo "Copying Binaries"
cp -r $outputFolderLinux/* $outputFolderMacOS
cp $outputFolder/fpcalc $outputFolderMacOS
echo "Adding sqlite dylibs"
cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderMacOS
echo "Adding MediaInfo dylib"
cp $sourceFolder/Libraries/MediaInfo/*.dylib $outputFolderMacOS
ProgressEnd 'Creating MacOS Package'
}
@ -234,13 +233,11 @@ PackageMacOSApp()
echo "Copying Binaries"
cp -r $outputFolderLinux/* $outputFolderMacOSApp/Lidarr.app/Contents/MacOS
cp $outputFolder/fpcalc $outputFolderMacOSApp/Lidarr.app/Contents/MacOS
echo "Adding sqlite dylibs"
cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderMacOSApp/Lidarr.app/Contents/MacOS
echo "Adding MediaInfo dylib"
cp $sourceFolder/Libraries/MediaInfo/*.dylib $outputFolderMacOSApp/Lidarr.app/Contents/MacOS
echo "Removing Update Folder"
rm -r $outputFolderMacOSApp/Lidarr.app/Contents/MacOS/Lidarr.Update
@ -254,15 +251,17 @@ PackageTests()
rm -rf $testPackageFolder
mkdir $testPackageFolder
find $sourceFolder -path $testSearchPattern -exec cp -r -u -T "{}" $testPackageFolder \;
find . -maxdepth 6 -path $testSearchPattern -exec cp -r "{}" $testPackageFolder \;
if [ $runtime = "dotnet" ] ; then
$nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder
else
mono $nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder
nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder
fi
cp $outputFolder/*.dll $testPackageFolder
cp $outputFolder/*.exe $testPackageFolder
cp $outputFolder/fpcalc $testPackageFolder
cp ./*.sh $testPackageFolder
echo "Creating MDBs for tests"
@ -281,6 +280,9 @@ PackageTests()
echo "Copying CurlSharp libraries"
cp $sourceFolder/ExternalModules/CurlSharp/libs/i386/* $testPackageFolder
echo "Copying dylibs"
cp -r $outputFolderMacOS/*.dylib $testPackageFolder
ProgressEnd 'Creating Test Package'
}
@ -294,6 +296,9 @@ CleanupWindowsPackage()
echo "Adding Lidarr.Windows to UpdatePackage"
cp $outputFolder/Lidarr.Windows.* $updateFolder
echo "Removing MacOS fpcalc"
rm $outputFolder/fpcalc
ProgressEnd 'Cleaning Windows Package'
}

@ -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 = {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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' }
@ -212,16 +218,17 @@ class MediaManagement extends Component {
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>Analyse audio files</FormLabel>
<FormLabel>Rescan Artist Folder after Refresh</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."
type={inputTypes.SELECT}
name="rescanAfterRefresh"
helpText="Rescan the artist folder after refreshing the artist"
helpTextWarning="Lidarr will not automatically detect changes to files when not set to 'Always'"
values={rescanAfterRefreshOptions}
onChange={onInputChange}
{...settings.enableMediaInfo}
{...settings.rescanAfterRefresh}
/>
</FormGroup>
@ -229,16 +236,16 @@ class MediaManagement extends Component {
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>Rescan Artist Folder after Refresh</FormLabel>
<FormLabel>Allow Fingerprinting</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="rescanAfterRefresh"
helpText="Rescan the artist folder after refreshing the artist"
helpTextWarning="Lidarr will not automatically detect changes to files when not set to 'Always'"
values={rescanAfterRefreshOptions}
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.rescanAfterRefresh}
{...settings.allowFingerprinting}
/>
</FormGroup>

@ -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 = [

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

@ -76,8 +76,7 @@ export const defaultState = {
name: 'artistType',
label: 'Type',