mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-30 11:48:26 -07:00
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:
parent
8bf364945f
commit
bb02d73c42
174 changed files with 11577 additions and 3490 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
65
frontend/src/InteractiveImport/FileDetails.css
Normal file
65
frontend/src/InteractiveImport/FileDetails.css
Normal 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;
|
||||
}
|
||||
}
|
258
frontend/src/InteractiveImport/FileDetails.js
Normal file
258
frontend/src/InteractiveImport/FileDetails.js
Normal 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;
|
|
@ -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' }
|
||||
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'qualityProfileId',
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue