New: Manual import improvements (#683)

* New: Manual import improvements

 - Detect and merge import with files already in library.
 - Allow selection of album release from Manual Import modal.
 - Loading indicator while fetching updated decisions

* Disable release switching if user manually overrode release
This commit is contained in:
ta264 2019-04-04 09:20:47 +01:00 committed by GitHub
commit 188e0e1040
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1295 additions and 371 deletions

View file

@ -321,6 +321,8 @@ class QueueRow extends Component {
downloadId={downloadId} downloadId={downloadId}
title={title} title={title}
onModalClose={this.onInteractiveImportModalClose} onModalClose={this.onInteractiveImportModalClose}
showReplaceExistingFiles={true}
replaceExistingFiles={true}
/> />
<RemoveQueueItemModal <RemoveQueueItemModal

View file

@ -229,16 +229,6 @@ class SignalRConnector extends Component {
} }
} }
handleManualimport = (body) => {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({
section: 'interactiveImport',
updateOnly: true,
...body.resource
});
}
}
handleQueue = () => { handleQueue = () => {
if (this.props.isQueuePopulated) { if (this.props.isQueuePopulated) {
this.props.dispatchFetchQueue(); this.props.dispatchFetchQueue();

View file

@ -65,6 +65,7 @@ class SelectAlbumModalContentConnector extends Component {
this.props.updateInteractiveImportItem({ this.props.updateInteractiveImportItem({
id, id,
album, album,
albumReleaseId: undefined,
tracks: [], tracks: [],
rejections: [] rejections: []
}); });

View file

@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectAlbumReleaseModalContentConnector from './SelectAlbumReleaseModalContentConnector';
class SelectAlbumReleaseModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectAlbumReleaseModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectAlbumReleaseModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectAlbumReleaseModal;

View file

@ -0,0 +1,18 @@
.modalBody {
composes: modalBody from '~Components/Modal/ModalBody.css';
display: flex;
flex: 1 1 auto;
flex-direction: column;
}
.filterInput {
composes: input from '~Components/Form/TextInput.css';
flex: 0 0 auto;
margin-bottom: 20px;
}
.scroller {
flex: 1 1 auto;
}

View file

@ -0,0 +1,93 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import { scrollDirections } from 'Helpers/Props';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import SelectAlbumReleaseRow from './SelectAlbumReleaseRow';
import Alert from 'Components/Alert';
import styles from './SelectAlbumReleaseModalContent.css';
const columns = [
{
name: 'album',
label: 'Album',
isVisible: true
},
{
name: 'release',
label: 'Album Release',
isVisible: true
}
];
class SelectAlbumReleaseModalContent extends Component {
//
// Render
render() {
const {
albums,
onAlbumReleaseSelect,
onModalClose,
...otherProps
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Manual Import - Select Album Release
</ModalHeader>
<ModalBody
className={styles.modalBody}
scrollDirection={scrollDirections.NONE}
>
<Alert>
Overrriding a release here will <b>disable automatic release selection</b> for that album in future.
</Alert>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
albums.map((item) => {
return (
<SelectAlbumReleaseRow
key={item.album.id}
matchedReleaseId={item.matchedReleaseId}
columns={columns}
onAlbumReleaseSelect={onAlbumReleaseSelect}
{...item.album}
/>
);
})
}
</TableBody>
</Table>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectAlbumReleaseModalContent.propTypes = {
albums: PropTypes.arrayOf(PropTypes.object).isRequired,
onAlbumReleaseSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectAlbumReleaseModalContent;

View file

@ -0,0 +1,67 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
updateInteractiveImportItem,
saveInteractiveImportItem
} from 'Store/Actions/interactiveImportActions';
import SelectAlbumReleaseModalContent from './SelectAlbumReleaseModalContent';
function createMapStateToProps() {
return {};
}
const mapDispatchToProps = {
updateInteractiveImportItem,
saveInteractiveImportItem
};
class SelectAlbumReleaseModalContentConnector extends Component {
//
// Listeners
// onSortPress = (sortKey, sortDirection) => {
// this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection });
// }
onAlbumReleaseSelect = (albumId, albumReleaseId) => {
const ids = this.props.importIdsByAlbum[albumId];
ids.forEach((id) => {
this.props.updateInteractiveImportItem({
id,
albumReleaseId,
disableReleaseSwitching: true,
tracks: [],
rejections: []
});
});
this.props.saveInteractiveImportItem({ id: ids });
this.props.onModalClose(true);
}
//
// Render
render() {
return (
<SelectAlbumReleaseModalContent
{...this.props}
onAlbumReleaseSelect={this.onAlbumReleaseSelect}
/>
);
}
}
SelectAlbumReleaseModalContentConnector.propTypes = {
importIdsByAlbum: PropTypes.object.isRequired,
albums: PropTypes.arrayOf(PropTypes.object).isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired,
saveInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectAlbumReleaseModalContentConnector);

View file

@ -0,0 +1,3 @@
.albumRow {
cursor: pointer;
}

View file

@ -0,0 +1,96 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import FormInputGroup from 'Components/Form/FormInputGroup';
import titleCase from 'Utilities/String/titleCase';
class SelectAlbumReleaseRow extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.onAlbumReleaseSelect(parseInt(name), parseInt(value));
}
//
// Render
render() {
const {
id,
matchedReleaseId,
title,
disambiguation,
releases,
columns
} = this.props;
const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title;
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'album') {
return (
<TableRowCell key={name}>
{extendedTitle}
</TableRowCell>
);
}
if (name === 'release') {
return (
<TableRowCell key={name}>
<FormInputGroup
type={inputTypes.SELECT}
name={id.toString()}
values={_.map(releases, (r) => ({
key: r.id,
value: `${r.title}` +
`${r.disambiguation ? ' (' : ''}${titleCase(r.disambiguation)}${r.disambiguation ? ')' : ''}` +
`, ${r.mediumCount} med, ${r.trackCount} tracks` +
`${r.country.length > 0 ? ', ' : ''}${r.country}` +
`${r.format ? ', [' : ''}${r.format}${r.format ? ']' : ''}` +
`${r.monitored ? ', Monitored' : ''}`
}))}
value={matchedReleaseId}
onChange={this.onInputChange}
/>
</TableRowCell>
);
}
return null;
})
}
</TableRow>
);
}
}
SelectAlbumReleaseRow.propTypes = {
id: PropTypes.number.isRequired,
matchedReleaseId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
disambiguation: PropTypes.string.isRequired,
releases: PropTypes.arrayOf(PropTypes.object).isRequired,
onAlbumReleaseSelect: PropTypes.func.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default SelectAlbumReleaseRow;

View file

@ -41,17 +41,21 @@ class SelectArtistModalContentConnector extends Component {
onArtistSelect = (artistId) => { onArtistSelect = (artistId) => {
const artist = _.find(this.props.items, { id: artistId }); const artist = _.find(this.props.items, { id: artistId });
this.props.ids.forEach((id) => { const ids = this.props.ids;
ids.forEach((id) => {
this.props.updateInteractiveImportItem({ this.props.updateInteractiveImportItem({
id, id,
artist, artist,
album: undefined, album: undefined,
albumReleaseId: undefined,
tracks: [], tracks: [],
rejections: [] rejections: []
}); });
this.props.saveInteractiveImportItem({ id });
}); });
this.props.saveInteractiveImportItem({ id: ids });
this.props.onModalClose(true); this.props.onModalClose(true);
} }

View file

@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import ConfirmImportModalContentConnector from './ConfirmImportModalContentConnector';
class ConfirmImportModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ConfirmImportModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
ConfirmImportModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ConfirmImportModal;

View file

@ -0,0 +1,135 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Alert from 'Components/Alert';
function formatAlbumFiles(items, album) {
return (
<div key={album.id}>
<b> {album.title} </b>
<ul>
{
_.sortBy(items, 'path').map((item) => {
return (
<li key={item.id}>
{item.path}
</li>
);
})
}
</ul>
</div>
);
}
class ConfirmImportModalContent extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
items,
isFetching,
isPopulated
} = this.props;
if (!isFetching && isPopulated && !items.length) {
this.props.onModalClose();
this.props.onConfirmImportPress();
}
}
//
// Render
render() {
const {
albums,
items,
onConfirmImportPress,
onModalClose,
isFetching,
isPopulated
} = this.props;
// don't render if nothing to do
if (!isFetching && isPopulated && !items.length) {
return null;
}
return (
<ModalContent onModalClose={onModalClose}>
{
!isFetching && isPopulated &&
<ModalHeader>
Are you sure?
</ModalHeader>
}
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && isPopulated &&
<div>
<Alert>
You are already have files imported for the albums listed below. If you continue, the existing files <b>will be deleted</b> and the new files imported in their place.
To avoid deleting existing files, press 'Cancel' and use the 'Combine with existing files' option.
</Alert>
{ _.chain(items)
.groupBy('albumId')
.mapValues((value, key) => formatAlbumFiles(value, _.find(albums, (a) => a.id === parseInt(key))))
.values()
.value() }
</div>
}
</ModalBody>
{
!isFetching && isPopulated &&
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={onConfirmImportPress}
>
Proceed
</Button>
</ModalFooter>
}
</ModalContent>
);
}
}
ConfirmImportModalContent.propTypes = {
albums: PropTypes.arrayOf(PropTypes.object).isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
onConfirmImportPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ConfirmImportModalContent;

View file

@ -0,0 +1,60 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchInteractiveImportTrackFiles, clearInteractiveImportTrackFiles } from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import ConfirmImportModalContent from './ConfirmImportModalContent';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('interactiveImport.trackFiles'),
(trackFiles) => {
return trackFiles;
}
);
}
const mapDispatchToProps = {
fetchInteractiveImportTrackFiles,
clearInteractiveImportTrackFiles
};
class ConfirmImportModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
albums
} = this.props;
this.props.fetchInteractiveImportTrackFiles({ albumId: albums.map((x) => x.id) });
}
componentWillUnmount() {
this.props.clearInteractiveImportTrackFiles();
}
//
// Render
render() {
return (
<ConfirmImportModalContent
{...this.props}
/>
);
}
}
ConfirmImportModalContentConnector.propTypes = {
albums: PropTypes.arrayOf(PropTypes.object).isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchInteractiveImportTrackFiles: PropTypes.func.isRequired,
clearInteractiveImportTrackFiles: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ConfirmImportModalContentConnector);

View file

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

View file

@ -22,6 +22,8 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal';
import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal';
import SelectAlbumReleaseModal from 'InteractiveImport/AlbumRelease/SelectAlbumReleaseModal';
import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal';
import InteractiveImportRow from './InteractiveImportRow'; import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css'; import styles from './InteractiveImportModalContent.css';
@ -80,6 +82,11 @@ const filterExistingFilesOptions = {
NEW: 'new' NEW: 'new'
}; };
const replaceExistingFilesOptions = {
COMBINE: 'combine',
DELETE: 'delete'
};
class InteractiveImportModalContent extends Component { class InteractiveImportModalContent extends Component {
// //
@ -95,10 +102,36 @@ class InteractiveImportModalContent extends Component {
selectedState: {}, selectedState: {},
invalidRowsSelected: [], invalidRowsSelected: [],
isSelectArtistModalOpen: false, isSelectArtistModalOpen: false,
isSelectAlbumModalOpen: false isSelectAlbumModalOpen: false,
isSelectAlbumReleaseModalOpen: false,
albumsImported: [],
isConfirmImportModalOpen: false,
showClearTracks: false,
inconsistentAlbumReleases: false
}; };
} }
componentDidUpdate(prevProps) {
const selectedIds = this.getSelectedIds();
const selectedItems = _.filter(this.props.items, (x) => _.includes(selectedIds, x.id));
const selectionHasTracks = _.some(selectedItems, (x) => x.tracks.length);
if (this.state.showClearTracks !== selectionHasTracks) {
this.setState({ showClearTracks: selectionHasTracks });
}
const inconsistent = _(selectedItems)
.map((x) => ({ albumId: x.album ? x.album.id : 0, releaseId: x.albumReleaseId }))
.groupBy('albumId')
.mapValues((album) => _(album).groupBy((x) => x.releaseId).values().value().length)
.values()
.some((x) => x !== undefined && x > 1);
if (inconsistent !== this.state.inconsistentAlbumReleases) {
this.setState({ inconsistentAlbumReleases: inconsistent });
}
}
// //
// Control // Control
@ -120,20 +153,38 @@ class InteractiveImportModalContent extends Component {
} }
onValidRowChange = (id, isValid) => { onValidRowChange = (id, isValid) => {
this.setState((state) => { this.setState((state, props) => {
if (isValid) { // make sure to exclude any invalidRows that are no longer present in props
return { const diff = _.difference(state.invalidRowsSelected, _.map(props.items, 'id'));
invalidRowsSelected: _.without(state.invalidRowsSelected, id) const currentInvalid = _.difference(state.invalidRowsSelected, diff);
}; const newstate = isValid ? _.without(currentInvalid, id) : _.union(currentInvalid, [id]);
} return { invalidRowsSelected: newstate };
return {
invalidRowsSelected: [...state.invalidRowsSelected, id]
};
}); });
} }
onImportSelectedPress = () => { onImportSelectedPress = () => {
if (!this.props.replaceExistingFiles) {
this.onConfirmImportPress();
return;
}
// potentially deleting files
const selectedIds = this.getSelectedIds();
const albumsImported = _(this.props.items)
.filter((x) => _.includes(selectedIds, x.id))
.keyBy((x) => x.album.id)
.map((x) => x.album)
.value();
console.log(albumsImported);
this.setState({
albumsImported,
isConfirmImportModalOpen: true
});
}
onConfirmImportPress = () => {
const { const {
downloadId, downloadId,
showImportMode, showImportMode,
@ -151,6 +202,10 @@ class InteractiveImportModalContent extends Component {
this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL);
} }
onReplaceExistingFilesChange = (value) => {
this.props.onReplaceExistingFilesChange(value === replaceExistingFilesOptions.DELETE);
}
onImportModeChange = ({ value }) => { onImportModeChange = ({ value }) => {
this.props.onImportModeChange(value); this.props.onImportModeChange(value);
} }
@ -163,6 +218,10 @@ class InteractiveImportModalContent extends Component {
this.setState({ isSelectAlbumModalOpen: true }); this.setState({ isSelectAlbumModalOpen: true });
} }
onSelectAlbumReleasePress = () => {
this.setState({ isSelectAlbumReleaseModalOpen: true });
}
onClearTrackMappingPress = () => { onClearTrackMappingPress = () => {
const selectedIds = this.getSelectedIds(); const selectedIds = this.getSelectedIds();
@ -175,6 +234,10 @@ class InteractiveImportModalContent extends Component {
}); });
} }
onGetTrackMappingPress = () => {
this.props.saveInteractiveImportItem({ id: this.getSelectedIds() });
}
onSelectArtistModalClose = () => { onSelectArtistModalClose = () => {
this.setState({ isSelectArtistModalOpen: false }); this.setState({ isSelectArtistModalOpen: false });
} }
@ -183,6 +246,14 @@ class InteractiveImportModalContent extends Component {
this.setState({ isSelectAlbumModalOpen: false }); this.setState({ isSelectAlbumModalOpen: false });
} }
onSelectAlbumReleaseModalClose = () => {
this.setState({ isSelectAlbumReleaseModalOpen: false });
}
onConfirmImportModalClose = () => {
this.setState({ isConfirmImportModalOpen: false });
}
// //
// Render // Render
@ -191,12 +262,15 @@ class InteractiveImportModalContent extends Component {
downloadId, downloadId,
allowArtistChange, allowArtistChange,
showFilterExistingFiles, showFilterExistingFiles,
showReplaceExistingFiles,
showImportMode, showImportMode,
filterExistingFiles, filterExistingFiles,
replaceExistingFiles,
title, title,
folder, folder,
isFetching, isFetching,
isPopulated, isPopulated,
isSaving,
error, error,
items, items,
sortKey, sortKey,
@ -213,7 +287,12 @@ class InteractiveImportModalContent extends Component {
selectedState, selectedState,
invalidRowsSelected, invalidRowsSelected,
isSelectArtistModalOpen, isSelectArtistModalOpen,
isSelectAlbumModalOpen isSelectAlbumModalOpen,
isSelectAlbumReleaseModalOpen,
albumsImported,
isConfirmImportModalOpen,
showClearTracks,
inconsistentAlbumReleases
} = this.state; } = this.state;
const selectedIds = this.getSelectedIds(); const selectedIds = this.getSelectedIds();
@ -232,43 +311,78 @@ class InteractiveImportModalContent extends Component {
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
{ <div className={styles.filterContainer}>
showFilterExistingFiles && {
<div className={styles.filterContainer}> showFilterExistingFiles &&
<Menu alignMenu={align.RIGHT}> <Menu alignMenu={align.RIGHT}>
<MenuButton> <MenuButton>
<Icon <Icon
name={icons.FILTER} name={icons.FILTER}
size={22} size={22}
/> />
<div className={styles.filterText}> <div className={styles.filterText}>
{ {
filterExistingFiles ? 'Unmapped Files Only' : 'All Files' filterExistingFiles ? 'Unmapped Files Only' : 'All Files'
} }
</div> </div>
</MenuButton> </MenuButton>
<MenuContent> <MenuContent>
<SelectedMenuItem <SelectedMenuItem
name={filterExistingFilesOptions.ALL} name={filterExistingFilesOptions.ALL}
isSelected={!filterExistingFiles} isSelected={!filterExistingFiles}
onPress={this.onFilterExistingFilesChange} onPress={this.onFilterExistingFilesChange}
> >
All Files All Files
</SelectedMenuItem> </SelectedMenuItem>
<SelectedMenuItem <SelectedMenuItem
name={filterExistingFilesOptions.NEW} name={filterExistingFilesOptions.NEW}
isSelected={filterExistingFiles} isSelected={filterExistingFiles}
onPress={this.onFilterExistingFilesChange} onPress={this.onFilterExistingFilesChange}
> >
Unmapped Files Only Unmapped Files Only
</SelectedMenuItem> </SelectedMenuItem>
</MenuContent> </MenuContent>
</Menu> </Menu>
</div> }
} {
showReplaceExistingFiles &&
<Menu alignMenu={align.RIGHT}>
<MenuButton>
<Icon
name={icons.CLONE}
size={22}
/>
<div className={styles.filterText}>
{
replaceExistingFiles ? 'Existing files will be deleted' : 'Combine with existing files'
}
</div>
</MenuButton>
<MenuContent>
<SelectedMenuItem
name={replaceExistingFiles.COMBINE}
isSelected={!replaceExistingFiles}
onPress={this.onReplaceExistingFilesChange}
>
Combine With Existing Files
</SelectedMenuItem>
<SelectedMenuItem
name={replaceExistingFilesOptions.DELETE}
isSelected={replaceExistingFiles}
onPress={this.onReplaceExistingFilesChange}
>
Replace Existing Files
</SelectedMenuItem>
</MenuContent>
</Menu>
}
</div>
{ {
isFetching && isFetching &&
@ -299,6 +413,7 @@ class InteractiveImportModalContent extends Component {
<InteractiveImportRow <InteractiveImportRow
key={item.id} key={item.id}
isSelected={selectedState[item.id]} isSelected={selectedState[item.id]}
isSaving={isSaving}
{...item} {...item}
allowArtistChange={allowArtistChange} allowArtistChange={allowArtistChange}
onSelectedChange={this.onSelectedChange} onSelectedChange={this.onSelectedChange}
@ -349,9 +464,30 @@ class InteractiveImportModalContent extends Component {
Select Album Select Album
</Button> </Button>
<Button onPress={this.onClearTrackMappingPress}> <Button
Clear Track Mapping onPress={this.onSelectAlbumReleasePress}
isDisabled={!selectedIds.length}
>
Select Release
</Button> </Button>
{
showClearTracks ? (
<Button
onPress={this.onClearTrackMappingPress}
isDisabled={!selectedIds.length}
>
Clear Tracks
</Button>
) : (
<Button
onPress={this.onGetTrackMappingPress}
isDisabled={!selectedIds.length}
>
Map Tracks
</Button>
)
}
</div> </div>
<div className={styles.rightButtons}> <div className={styles.rightButtons}>
@ -366,7 +502,7 @@ class InteractiveImportModalContent extends Component {
<Button <Button
kind={kinds.SUCCESS} kind={kinds.SUCCESS}
isDisabled={!selectedIds.length || !!invalidRowsSelected.length} isDisabled={!selectedIds.length || !!invalidRowsSelected.length || inconsistentAlbumReleases}
onPress={this.onImportSelectedPress} onPress={this.onImportSelectedPress}
> >
Import Import
@ -387,6 +523,20 @@ class InteractiveImportModalContent extends Component {
onModalClose={this.onSelectAlbumModalClose} onModalClose={this.onSelectAlbumModalClose}
/> />
<SelectAlbumReleaseModal
isOpen={isSelectAlbumReleaseModalOpen}
importIdsByAlbum={_.chain(items).filter((x) => x.album).groupBy((x) => x.album.id).mapValues((x) => x.map((y) => y.id)).value()}
albums={_.chain(items).filter((x) => x.album).keyBy((x) => x.album.id).mapValues((x) => ({ matchedReleaseId: x.albumReleaseId, album: x.album })).values().value()}
onModalClose={this.onSelectAlbumReleaseModalClose}
/>
<ConfirmImportModal
isOpen={isConfirmImportModalOpen}
albums={albumsImported}
onModalClose={this.onConfirmImportModalClose}
onConfirmImportPress={this.onConfirmImportPress}
/>
</ModalContent> </ModalContent>
); );
} }
@ -397,12 +547,15 @@ InteractiveImportModalContent.propTypes = {
allowArtistChange: PropTypes.bool.isRequired, allowArtistChange: PropTypes.bool.isRequired,
showImportMode: PropTypes.bool.isRequired, showImportMode: PropTypes.bool.isRequired,
showFilterExistingFiles: PropTypes.bool.isRequired, showFilterExistingFiles: PropTypes.bool.isRequired,
showReplaceExistingFiles: PropTypes.bool.isRequired,
filterExistingFiles: PropTypes.bool.isRequired, filterExistingFiles: PropTypes.bool.isRequired,
replaceExistingFiles: PropTypes.bool.isRequired,
importMode: PropTypes.string.isRequired, importMode: PropTypes.string.isRequired,
title: PropTypes.string, title: PropTypes.string,
folder: PropTypes.string, folder: PropTypes.string,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string, sortKey: PropTypes.string,
@ -410,8 +563,10 @@ InteractiveImportModalContent.propTypes = {
interactiveImportErrorMessage: PropTypes.string, interactiveImportErrorMessage: PropTypes.string,
onSortPress: PropTypes.func.isRequired, onSortPress: PropTypes.func.isRequired,
onFilterExistingFilesChange: PropTypes.func.isRequired, onFilterExistingFilesChange: PropTypes.func.isRequired,
onReplaceExistingFilesChange: PropTypes.func.isRequired,
onImportModeChange: PropTypes.func.isRequired, onImportModeChange: PropTypes.func.isRequired,
onImportSelectedPress: PropTypes.func.isRequired, onImportSelectedPress: PropTypes.func.isRequired,
saveInteractiveImportItem: PropTypes.func.isRequired,
updateInteractiveImportItem: PropTypes.func.isRequired, updateInteractiveImportItem: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired
}; };
@ -419,6 +574,7 @@ InteractiveImportModalContent.propTypes = {
InteractiveImportModalContent.defaultProps = { InteractiveImportModalContent.defaultProps = {
allowArtistChange: true, allowArtistChange: true,
showFilterExistingFiles: false, showFilterExistingFiles: false,
showReplaceExistingFiles: false,
showImportMode: true, showImportMode: true,
importMode: 'move' importMode: 'move'
}; };

View file

@ -3,7 +3,14 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; import {
fetchInteractiveImportItems,
setInteractiveImportSort,
clearInteractiveImport,
setInteractiveImportMode,
updateInteractiveImportItem,
saveInteractiveImportItem
} from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
@ -24,6 +31,7 @@ const mapDispatchToProps = {
setInteractiveImportMode, setInteractiveImportMode,
clearInteractiveImport, clearInteractiveImport,
updateInteractiveImportItem, updateInteractiveImportItem,
saveInteractiveImportItem,
executeCommand executeCommand
}; };
@ -37,7 +45,8 @@ class InteractiveImportModalContentConnector extends Component {
this.state = { this.state = {
interactiveImportErrorMessage: null, interactiveImportErrorMessage: null,
filterExistingFiles: true filterExistingFiles: props.filterExistingFiles,
replaceExistingFiles: props.replaceExistingFiles
}; };
} }
@ -48,22 +57,26 @@ class InteractiveImportModalContentConnector extends Component {
} = this.props; } = this.props;
const { const {
filterExistingFiles filterExistingFiles,
replaceExistingFiles
} = this.state; } = this.state;
this.props.fetchInteractiveImportItems({ this.props.fetchInteractiveImportItems({
downloadId, downloadId,
folder, folder,
filterExistingFiles filterExistingFiles,
replaceExistingFiles
}); });
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { const {
filterExistingFiles filterExistingFiles,
replaceExistingFiles
} = this.state; } = this.state;
if (prevState.filterExistingFiles !== filterExistingFiles) { if (prevState.filterExistingFiles !== filterExistingFiles ||
prevState.replaceExistingFiles !== replaceExistingFiles) {
const { const {
downloadId, downloadId,
folder folder
@ -72,7 +85,8 @@ class InteractiveImportModalContentConnector extends Component {
this.props.fetchInteractiveImportItems({ this.props.fetchInteractiveImportItems({
downloadId, downloadId,
folder, folder,
filterExistingFiles filterExistingFiles,
replaceExistingFiles
}); });
} }
} }
@ -92,6 +106,10 @@ class InteractiveImportModalContentConnector extends Component {
this.setState({ filterExistingFiles }); this.setState({ filterExistingFiles });
} }
onReplaceExistingFilesChange = (replaceExistingFiles) => {
this.setState({ replaceExistingFiles });
}
onImportModeChange = (importMode) => { onImportModeChange = (importMode) => {
this.props.setInteractiveImportMode({ importMode }); this.props.setInteractiveImportMode({ importMode });
} }
@ -109,7 +127,8 @@ class InteractiveImportModalContentConnector extends Component {
albumReleaseId, albumReleaseId,
tracks, tracks,
quality, quality,
language language,
disableReleaseSwitching
} = item; } = item;
if (!artist) { if (!artist) {
@ -146,7 +165,8 @@ class InteractiveImportModalContentConnector extends Component {
trackIds: _.map(tracks, 'id'), trackIds: _.map(tracks, 'id'),
quality, quality,
language, language,
downloadId: this.props.downloadId downloadId: this.props.downloadId,
disableReleaseSwitching
}); });
} }
}); });
@ -158,7 +178,8 @@ class InteractiveImportModalContentConnector extends Component {
this.props.executeCommand({ this.props.executeCommand({
name: commandNames.INTERACTIVE_IMPORT, name: commandNames.INTERACTIVE_IMPORT,
files, files,
importMode importMode,
replaceExistingFiles: this.state.replaceExistingFiles
}); });
this.props.onModalClose(); this.props.onModalClose();
@ -170,7 +191,8 @@ class InteractiveImportModalContentConnector extends Component {
render() { render() {
const { const {
interactiveImportErrorMessage, interactiveImportErrorMessage,
filterExistingFiles filterExistingFiles,
replaceExistingFiles
} = this.state; } = this.state;
return ( return (
@ -178,8 +200,10 @@ class InteractiveImportModalContentConnector extends Component {
{...this.props} {...this.props}
interactiveImportErrorMessage={interactiveImportErrorMessage} interactiveImportErrorMessage={interactiveImportErrorMessage}
filterExistingFiles={filterExistingFiles} filterExistingFiles={filterExistingFiles}
replaceExistingFiles={replaceExistingFiles}
onSortPress={this.onSortPress} onSortPress={this.onSortPress}
onFilterExistingFilesChange={this.onFilterExistingFilesChange} onFilterExistingFilesChange={this.onFilterExistingFilesChange}
onReplaceExistingFilesChange={this.onReplaceExistingFilesChange}
onImportModeChange={this.onImportModeChange} onImportModeChange={this.onImportModeChange}
onImportSelectedPress={this.onImportSelectedPress} onImportSelectedPress={this.onImportSelectedPress}
/> />
@ -191,6 +215,7 @@ InteractiveImportModalContentConnector.propTypes = {
downloadId: PropTypes.string, downloadId: PropTypes.string,
folder: PropTypes.string, folder: PropTypes.string,
filterExistingFiles: PropTypes.bool.isRequired, filterExistingFiles: PropTypes.bool.isRequired,
replaceExistingFiles: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchInteractiveImportItems: PropTypes.func.isRequired, fetchInteractiveImportItems: PropTypes.func.isRequired,
setInteractiveImportSort: PropTypes.func.isRequired, setInteractiveImportSort: PropTypes.func.isRequired,
@ -202,7 +227,8 @@ InteractiveImportModalContentConnector.propTypes = {
}; };
InteractiveImportModalContentConnector.defaultProps = { InteractiveImportModalContentConnector.defaultProps = {
filterExistingFiles: true filterExistingFiles: true,
replaceExistingFiles: false
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector); export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector);

View file

@ -16,3 +16,15 @@
cursor: pointer; cursor: pointer;
} }
.loading {
composes: loading from '~Components/Loading/LoadingIndicator.css';
margin-top: 0;
}
.additionalFile {
composes: row from '~Components/Table/TableRow.css';
color: $disabledColor;
}

View file

@ -9,6 +9,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import TrackQuality from 'Album/TrackQuality'; import TrackQuality from 'Album/TrackQuality';
import TrackLanguage from 'Album/TrackLanguage'; import TrackLanguage from 'Album/TrackLanguage';
import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal';
@ -17,6 +18,7 @@ import SelectTrackModal from 'InteractiveImport/Track/SelectTrackModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import styles from './InteractiveImportRow.css'; import styles from './InteractiveImportRow.css';
class InteractiveImportRow extends Component { class InteractiveImportRow extends Component {
@ -174,7 +176,9 @@ class InteractiveImportRow extends Component {
size, size,
rejections, rejections,
audioTags, audioTags,
additionalFile,
isSelected, isSelected,
isSaving,
onSelectedChange onSelectedChange
} = this.props; } = this.props;
@ -196,12 +200,29 @@ class InteractiveImportRow extends Component {
const showArtistPlaceholder = isSelected && !artist; const showArtistPlaceholder = isSelected && !artist;
const showAlbumNumberPlaceholder = isSelected && !!artist && !album; const showAlbumNumberPlaceholder = isSelected && !!artist && !album;
const showTrackNumbersPlaceholder = isSelected && !!album && !tracks.length; const showTrackNumbersPlaceholder = !isSaving && isSelected && !!album && !tracks.length;
const showTrackNumbersLoading = isSaving && !tracks.length;
const showQualityPlaceholder = isSelected && !quality; const showQualityPlaceholder = isSelected && !quality;
const showLanguagePlaceholder = isSelected && !language; const showLanguagePlaceholder = isSelected && !language;
const pathCellContents = (
<div>
{relativePath}
</div>
);
const pathCell = additionalFile ? (
<Tooltip
anchor={pathCellContents}
tooltip='This file is already in your library for a release you are currently importing'
position={tooltipPositions.TOP}
/>
) : pathCellContents;
return ( return (
<TableRow> <TableRow
className={additionalFile ? styles.additionalFile : undefined}
>
<TableSelectCell <TableSelectCell
id={id} id={id}
isSelected={isSelected} isSelected={isSelected}
@ -212,7 +233,7 @@ class InteractiveImportRow extends Component {
className={styles.relativePath} className={styles.relativePath}
title={relativePath} title={relativePath}
> >
{relativePath} {pathCell}
</TableRowCell> </TableRowCell>
<TableRowCellButton <TableRowCellButton
@ -237,6 +258,9 @@ class InteractiveImportRow extends Component {
isDisabled={!artist || !album} isDisabled={!artist || !album}
onPress={this.onSelectTrackPress} onPress={this.onSelectTrackPress}
> >
{
showTrackNumbersLoading && <LoadingIndicator size={20} className={styles.loading} />
}
{ {
showTrackNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : trackNumbers showTrackNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : trackNumbers
} }
@ -372,7 +396,9 @@ InteractiveImportRow.propTypes = {
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object).isRequired, rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
audioTags: PropTypes.object.isRequired, audioTags: PropTypes.object.isRequired,
additionalFile: PropTypes.bool.isRequired,
isSelected: PropTypes.bool, isSelected: PropTypes.bool,
isSaving: PropTypes.bool.isRequired,
onSelectedChange: PropTypes.func.isRequired, onSelectedChange: PropTypes.func.isRequired,
onValidRowChange: PropTypes.func.isRequired onValidRowChange: PropTypes.func.isRequired
}; };

View file

@ -1,8 +1,9 @@
import _ from 'lodash';
import $ from 'jquery'; import $ from 'jquery';
import { batchActions } from 'redux-batched-actions'; import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import getProviderState from 'Utilities/State/getProviderState'; import getProviderState from 'Utilities/State/getProviderState';
import { set, updateItem } from '../baseActions'; import { set, updateItem, removeItem } from '../baseActions';
const abortCurrentRequests = {}; const abortCurrentRequests = {};
@ -15,7 +16,7 @@ export function createCancelSaveProviderHandler(section) {
}; };
} }
function createSaveProviderHandler(section, url, options = {}) { function createSaveProviderHandler(section, url, options = {}, removeStale = false) {
return function(getState, payload, dispatch) { return function(getState, payload, dispatch) {
dispatch(set({ section, isSaving: true })); dispatch(set({ section, isSaving: true }));
@ -50,8 +51,13 @@ function createSaveProviderHandler(section, url, options = {}) {
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
data = [data]; data = [data];
} }
const toRemove = removeStale && Array.isArray(id) ? _.difference(id, _.map(data, 'id')) : [];
dispatch(batchActions( dispatch(batchActions(
data.map((item) => updateItem({ section, ...item })).concat( data.map((item) => updateItem({ section, ...item })).concat(
toRemove.map((item) => removeItem({ section, id: item }))
).concat(
set({ set({
section, section,
isSaving: false, isSaving: false,

View file

@ -17,6 +17,7 @@ import { set, update } from './baseActions';
export const section = 'interactiveImport'; export const section = 'interactiveImport';
const albumsSection = `${section}.albums`; const albumsSection = `${section}.albums`;
const trackFilesSection = `${section}.trackFiles`;
// //
// State // State
@ -24,6 +25,7 @@ const albumsSection = `${section}.albums`;
export const defaultState = { export const defaultState = {
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
isSaving: false,
error: null, error: null,
items: [], items: [],
pendingChanges: {}, pendingChanges: {},
@ -54,7 +56,16 @@ export const defaultState = {
isPopulated: false, isPopulated: false,
error: null, error: null,
sortKey: 'albumTitle', sortKey: 'albumTitle',
sortDirection: sortDirections.DESCENDING, sortDirection: sortDirections.ASCENDING,
items: []
},
trackFiles: {
isFetching: false,
isPopulated: false,
error: null,
sortKey: 'relataivePath',
sortDirection: sortDirections.ASCENDING,
items: [] items: []
} }
}; };
@ -80,6 +91,9 @@ export const FETCH_INTERACTIVE_IMPORT_ALBUMS = 'FETCH_INTERACTIVE_IMPORT_ALBUMS'
export const SET_INTERACTIVE_IMPORT_ALBUMS_SORT = 'SET_INTERACTIVE_IMPORT_ALBUMS_SORT'; export const SET_INTERACTIVE_IMPORT_ALBUMS_SORT = 'SET_INTERACTIVE_IMPORT_ALBUMS_SORT';
export const CLEAR_INTERACTIVE_IMPORT_ALBUMS = 'CLEAR_INTERACTIVE_IMPORT_ALBUMS'; export const CLEAR_INTERACTIVE_IMPORT_ALBUMS = 'CLEAR_INTERACTIVE_IMPORT_ALBUMS';
export const FETCH_INTERACTIVE_IMPORT_TRACKFILES = 'FETCH_INTERACTIVE_IMPORT_TRACKFILES';
export const CLEAR_INTERACTIVE_IMPORT_TRACKFILES = 'CLEAR_INTERACTIVE_IMPORT_TRACKFILES';
// //
// Action Creators // Action Creators
@ -96,6 +110,9 @@ export const fetchInteractiveImportAlbums = createThunk(FETCH_INTERACTIVE_IMPORT
export const setInteractiveImportAlbumsSort = createAction(SET_INTERACTIVE_IMPORT_ALBUMS_SORT); export const setInteractiveImportAlbumsSort = createAction(SET_INTERACTIVE_IMPORT_ALBUMS_SORT);
export const clearInteractiveImportAlbums = createAction(CLEAR_INTERACTIVE_IMPORT_ALBUMS); export const clearInteractiveImportAlbums = createAction(CLEAR_INTERACTIVE_IMPORT_ALBUMS);
export const fetchInteractiveImportTrackFiles = createThunk(FETCH_INTERACTIVE_IMPORT_TRACKFILES);
export const clearInteractiveImportTrackFiles = createAction(CLEAR_INTERACTIVE_IMPORT_TRACKFILES);
// //
// Action Handlers // Action Handlers
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
@ -135,9 +152,11 @@ export const actionHandlers = handleThunks({
}); });
}, },
[SAVE_INTERACTIVE_IMPORT_ITEM]: createSaveProviderHandler(section, '/manualimport'), [SAVE_INTERACTIVE_IMPORT_ITEM]: createSaveProviderHandler(section, '/manualimport', {}, true),
[FETCH_INTERACTIVE_IMPORT_ALBUMS]: createFetchHandler('interactiveImport.albums', '/album') [FETCH_INTERACTIVE_IMPORT_ALBUMS]: createFetchHandler(albumsSection, '/album'),
[FETCH_INTERACTIVE_IMPORT_TRACKFILES]: createFetchHandler(trackFilesSection, '/trackFile')
}); });
// //
@ -205,6 +224,12 @@ export const reducers = createHandleActions({
return updateSectionState(state, albumsSection, { return updateSectionState(state, albumsSection, {
...defaultState.albums ...defaultState.albums
}); });
},
[CLEAR_INTERACTIVE_IMPORT_TRACKFILES]: (state) => {
return updateSectionState(state, trackFilesSection, {
...defaultState.trackFiles
});
} }
}, defaultState, section); }, defaultState, section);

View file

@ -19,7 +19,7 @@ export const defaultState = {
isPopulated: false, isPopulated: false,
error: null, error: null,
sortKey: 'mediumNumber', sortKey: 'mediumNumber',
sortDirection: sortDirections.DESCENDING, sortDirection: sortDirections.ASCENDING,
secondarySortKey: 'absoluteTrackNumber', secondarySortKey: 'absoluteTrackNumber',
secondarySortDirection: sortDirections.ASCENDING, secondarySortDirection: sortDirections.ASCENDING,
items: [], items: [],

View file

@ -271,6 +271,7 @@ class Missing extends Component {
<InteractiveImportModal <InteractiveImportModal
isOpen={isInteractiveImportModalOpen} isOpen={isInteractiveImportModalOpen}
onModalClose={this.onInteractiveImportModalClose} onModalClose={this.onInteractiveImportModalClose}
showReplaceExistingFiles={true}
/> />
</PageContentBodyConnector> </PageContentBodyConnector>
</PageContent> </PageContent>

View file

@ -119,7 +119,6 @@
<Compile Include="Parse\ParseModule.cs" /> <Compile Include="Parse\ParseModule.cs" />
<Compile Include="Parse\ParseResource.cs" /> <Compile Include="Parse\ParseResource.cs" />
<Compile Include="ManualImport\ManualImportModule.cs" /> <Compile Include="ManualImport\ManualImportModule.cs" />
<Compile Include="ManualImport\ManualImportModuleWithSignalR.cs" />
<Compile Include="ManualImport\ManualImportResource.cs" /> <Compile Include="ManualImport\ManualImportResource.cs" />
<Compile Include="Profiles\Delay\DelayProfileModule.cs" /> <Compile Include="Profiles\Delay\DelayProfileModule.cs" />
<Compile Include="Profiles\Delay\DelayProfileResource.cs" /> <Compile Include="Profiles\Delay\DelayProfileResource.cs" />

View file

@ -3,38 +3,39 @@ using System.Linq;
using NzbDrone.Core.MediaFiles.TrackImport.Manual; using NzbDrone.Core.MediaFiles.TrackImport.Manual;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using Lidarr.Http.Extensions; using Lidarr.Http.Extensions;
using NzbDrone.SignalR;
using NzbDrone.Core.Music; using NzbDrone.Core.Music;
using NLog; using NLog;
using Nancy; using Nancy;
using Lidarr.Http;
namespace Lidarr.Api.V1.ManualImport namespace Lidarr.Api.V1.ManualImport
{ {
public class ManualImportModule : ManualImportModuleWithSignalR public class ManualImportModule : LidarrRestModule<ManualImportResource>
{ {
private readonly IArtistService _artistService; private readonly IArtistService _artistService;
private readonly IAlbumService _albumService; private readonly IAlbumService _albumService;
private readonly IReleaseService _releaseService; private readonly IReleaseService _releaseService;
private readonly IManualImportService _manualImportService;
private readonly Logger _logger;
public ManualImportModule(IManualImportService manualImportService, public ManualImportModule(IManualImportService manualImportService,
IArtistService artistService, IArtistService artistService,
IAlbumService albumService, IAlbumService albumService,
IReleaseService releaseService, IReleaseService releaseService,
IBroadcastSignalRMessage signalRBroadcaster,
Logger logger) Logger logger)
: base(manualImportService, signalRBroadcaster, logger)
{ {
_artistService = artistService; _artistService = artistService;
_albumService = albumService; _albumService = albumService;
_releaseService = releaseService; _releaseService = releaseService;
_manualImportService = manualImportService;
_logger = logger;
GetResourceAll = GetMediaFiles; GetResourceAll = GetMediaFiles;
Put["/"] = options => Put["/"] = options =>
{ {
var resource = Request.Body.FromJson<List<ManualImportResource>>(); var resource = Request.Body.FromJson<List<ManualImportResource>>();
UpdateImportItems(resource); return UpdateImportItems(resource).AsResponse(HttpStatusCode.Accepted);
return GetManualImportItems(resource.Select(x => x.Id)).AsResponse(HttpStatusCode.Accepted);
}; };
} }
@ -43,8 +44,9 @@ namespace Lidarr.Api.V1.ManualImport
var folder = (string)Request.Query.folder; var folder = (string)Request.Query.folder;
var downloadId = (string)Request.Query.downloadId; var downloadId = (string)Request.Query.downloadId;
var filterExistingFiles = Request.GetBooleanQueryParameter("filterExistingFiles", true); var filterExistingFiles = Request.GetBooleanQueryParameter("filterExistingFiles", true);
var replaceExistingFiles = Request.GetBooleanQueryParameter("replaceExistingFiles", true);
return _manualImportService.GetMediaFiles(folder, downloadId, filterExistingFiles).ToResource().Select(AddQualityWeight).ToList(); return _manualImportService.GetMediaFiles(folder, downloadId, filterExistingFiles, replaceExistingFiles).ToResource().Select(AddQualityWeight).ToList();
} }
private ManualImportResource AddQualityWeight(ManualImportResource item) private ManualImportResource AddQualityWeight(ManualImportResource item)
@ -59,7 +61,7 @@ namespace Lidarr.Api.V1.ManualImport
return item; return item;
} }
private void UpdateImportItems(List<ManualImportResource> resources) private List<ManualImportResource> UpdateImportItems(List<ManualImportResource> resources)
{ {
var items = new List<ManualImportItem>(); var items = new List<ManualImportItem>();
foreach (var resource in resources) foreach (var resource in resources)
@ -76,12 +78,14 @@ namespace Lidarr.Api.V1.ManualImport
Release = resource.AlbumReleaseId == 0 ? null : _releaseService.GetRelease(resource.AlbumReleaseId), Release = resource.AlbumReleaseId == 0 ? null : _releaseService.GetRelease(resource.AlbumReleaseId),
Quality = resource.Quality, Quality = resource.Quality,
Language = resource.Language, Language = resource.Language,
DownloadId = resource.DownloadId DownloadId = resource.DownloadId,
AdditionalFile = resource.AdditionalFile,
ReplaceExistingFiles = resource.ReplaceExistingFiles,
DisableReleaseSwitching = resource.DisableReleaseSwitching
}); });
} }
//recalculate import and broadcast return _manualImportService.UpdateItems(items).Select(x => x.ToResource()).ToList();
_manualImportService.UpdateItems(items);
} }
} }
} }

View file

@ -1,50 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.MediaFiles.TrackImport.Manual;
using NzbDrone.Core.Qualities;
using Lidarr.Http;
using Lidarr.Http.Extensions;
using NzbDrone.SignalR;
using NLog;
namespace Lidarr.Api.V1.ManualImport
{
public abstract class ManualImportModuleWithSignalR : LidarrRestModuleWithSignalR<ManualImportResource, ManualImportItem>
{
protected readonly IManualImportService _manualImportService;
protected readonly Logger _logger;
protected ManualImportModuleWithSignalR(IManualImportService manualImportService,
IBroadcastSignalRMessage signalRBroadcaster,
Logger logger)
: base(signalRBroadcaster)
{
_manualImportService = manualImportService;
_logger = logger;
GetResourceById = GetManualImportItem;
}
protected ManualImportModuleWithSignalR(IManualImportService manualImportService,
IBroadcastSignalRMessage signalRBroadcaster,
Logger logger,
string resource)
: base(signalRBroadcaster, resource)
{
_manualImportService = manualImportService;
_logger = logger;
GetResourceById = GetManualImportItem;
}
protected ManualImportResource GetManualImportItem(int id)
{
return _manualImportService.Find(id).ToResource();
}
protected List<ManualImportResource> GetManualImportItems(IEnumerable<int> ids)
{
return ids.Select(x => _manualImportService.Find(x).ToResource()).ToList();
}
}
}

View file

@ -1,4 +1,3 @@
using NzbDrone.Common.Crypto;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.MediaFiles.TrackImport.Manual; using NzbDrone.Core.MediaFiles.TrackImport.Manual;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -9,7 +8,6 @@ using Lidarr.Api.V1.Tracks;
using Lidarr.Http.REST; using Lidarr.Http.REST;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Core.Music;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
namespace Lidarr.Api.V1.ManualImport namespace Lidarr.Api.V1.ManualImport
@ -31,6 +29,9 @@ namespace Lidarr.Api.V1.ManualImport
public string DownloadId { get; set; } public string DownloadId { get; set; }
public IEnumerable<Rejection> Rejections { get; set; } public IEnumerable<Rejection> Rejections { get; set; }
public ParsedTrackInfo AudioTags { get; set; } public ParsedTrackInfo AudioTags { get; set; }
public bool AdditionalFile { get; set; }
public bool ReplaceExistingFiles { get; set; }
public bool DisableReleaseSwitching { get; set; }
} }
public static class ManualImportResourceMapper public static class ManualImportResourceMapper
@ -56,7 +57,10 @@ namespace Lidarr.Api.V1.ManualImport
//QualityWeight //QualityWeight
DownloadId = model.DownloadId, DownloadId = model.DownloadId,
Rejections = model.Rejections, Rejections = model.Rejections,
AudioTags = model.Tags AudioTags = model.Tags,
AdditionalFile = model.AdditionalFile,
ReplaceExistingFiles = model.ReplaceExistingFiles,
DisableReleaseSwitching = model.DisableReleaseSwitching
}; };
} }

View file

@ -78,11 +78,21 @@ namespace Lidarr.Api.V1.TrackFiles
if (albumIdQuery.HasValue) if (albumIdQuery.HasValue)
{ {
int albumId = Convert.ToInt32(albumIdQuery.Value); string albumIdValue = albumIdQuery.Value.ToString();
var album = _albumService.GetAlbum(albumId);
var albumArtist = _artistService.GetArtist(album.ArtistId);
return _mediaFileService.GetFilesByAlbum(album.Id).ConvertAll(f => f.ToResource(albumArtist, _upgradableSpecification)); var albumIds = albumIdValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(e => Convert.ToInt32(e))
.ToList();
var result = new List<TrackFileResource>();
foreach (var albumId in albumIds)
{
var album = _albumService.GetAlbum(albumId);
var albumArtist = _artistService.GetArtist(album.ArtistId);
result.AddRange(_mediaFileService.GetFilesByAlbum(album.Id).ConvertAll(f => f.ToResource(albumArtist, _upgradableSpecification)));
}
return result;
} }
else else

View file

@ -47,7 +47,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Returns(_rootFolder); .Returns(_rootFolder);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Setup(v => v.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Artist>())) .Setup(v => v.GetImportDecisions(It.IsAny<List<string>>(), It.IsAny<Artist>(), It.IsAny<bool>()))
.Returns(new List<ImportDecision<LocalTrack>>()); .Returns(new List<ImportDecision<LocalTrack>>());
Mocker.GetMock<IMediaFileService>() Mocker.GetMock<IMediaFileService>()
@ -115,7 +115,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Never()); .Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Never());
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.IsAny<List<string>>(), _artist), Times.Never()); .Verify(v => v.GetImportDecisions(It.IsAny<List<string>>(), _artist, false), Times.Never());
} }
[Test] [Test]
@ -162,7 +162,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Once()); .Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Once());
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.IsAny<List<string>>(), _artist), Times.Never()); .Verify(v => v.GetImportDecisions(It.IsAny<List<string>>(), _artist, false), Times.Never());
} }
[Test] [Test]
@ -180,7 +180,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Once()); .Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Once());
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.IsAny<List<string>>(), _artist), Times.Never()); .Verify(v => v.GetImportDecisions(It.IsAny<List<string>>(), _artist, false), Times.Never());
} }
[Test] [Test]
@ -197,7 +197,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 2), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 2), _artist, false), Times.Once());
} }
[Test] [Test]
@ -220,7 +220,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Verify(v => v.GetFiles(It.IsAny<string>(), It.IsAny<SearchOption>()), Times.Once()); .Verify(v => v.GetFiles(It.IsAny<string>(), It.IsAny<SearchOption>()), Times.Once());
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
[Test] [Test]
@ -238,7 +238,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
[Test] [Test]
@ -261,7 +261,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 4), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 4), _artist, false), Times.Once());
} }
[Test] [Test]
@ -277,7 +277,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
[Test] [Test]
@ -296,7 +296,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
[Test] [Test]
@ -316,7 +316,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
[Test] [Test]
@ -333,7 +333,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
[Test] [Test]
@ -350,7 +350,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
[Test] [Test]
@ -369,7 +369,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 2), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 2), _artist, false), Times.Once());
} }
[Test] [Test]
@ -387,7 +387,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Subject.Scan(_artist); Subject.Scan(_artist);
Mocker.GetMock<IMakeImportDecision>() Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist), Times.Once()); .Verify(v => v.GetImportDecisions(It.Is<List<string>>(l => l.Count == 1), _artist, false), Times.Once());
} }
} }
} }

View file

@ -160,7 +160,9 @@ namespace NzbDrone.Core.Test.MediaFiles
[Test] [Test]
public void should_not_move_existing_files() public void should_not_move_existing_files()
{ {
Subject.Import(new List<ImportDecision<LocalTrack>> { _approvedDecisions.First() }, false); var track = _approvedDecisions.First();
track.Item.ExistingFile = true;
Subject.Import(new List<ImportDecision<LocalTrack>> { track }, false);
Mocker.GetMock<IUpgradeMediaFiles>() Mocker.GetMock<IUpgradeMediaFiles>()
.Verify(v => v.UpgradeTrackFile(It.IsAny<TrackFile>(), _approvedDecisions.First().Item, false), .Verify(v => v.UpgradeTrackFile(It.IsAny<TrackFile>(), _approvedDecisions.First().Item, false),
@ -215,13 +217,15 @@ namespace NzbDrone.Core.Test.MediaFiles
} }
[Test] [Test]
public void should_delete_existing_metadata_files_with_the_same_path() public void should_delete_existing_trackfiles_with_the_same_path()
{ {
Mocker.GetMock<IMediaFileService>() Mocker.GetMock<IMediaFileService>()
.Setup(s => s.GetFilesWithRelativePath(It.IsAny<int>(), It.IsAny<string>())) .Setup(s => s.GetFilesWithRelativePath(It.IsAny<int>(), It.IsAny<string>()))
.Returns(Builder<TrackFile>.CreateListOfSize(1).BuildList()); .Returns(Builder<TrackFile>.CreateListOfSize(1).BuildList());
Subject.Import(new List<ImportDecision<LocalTrack>> { _approvedDecisions.First() }, false); var track = _approvedDecisions.First();
track.Item.ExistingFile = true;
Subject.Import(new List<ImportDecision<LocalTrack>> { track }, false);
Mocker.GetMock<IMediaFileService>() Mocker.GetMock<IMediaFileService>()
.Verify(v => v.Delete(It.IsAny<TrackFile>(), DeleteMediaFileReason.ManualOverride), Times.Once()); .Verify(v => v.Delete(It.IsAny<TrackFile>(), DeleteMediaFileReason.ManualOverride), Times.Once());

View file

@ -122,7 +122,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification
var local = GivenLocalAlbumRelease(); var local = GivenLocalAlbumRelease();
Subject.GetCandidatesFromFingerprint(local).ShouldBeEquivalentTo(new List<AlbumRelease>()); Subject.GetCandidatesFromFingerprint(local, null, null, null, false).ShouldBeEquivalentTo(new List<CandidateAlbumRelease>());
} }
[Test] [Test]
@ -133,7 +133,9 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification
var localTracks = GivenLocalTracks(tracks, release); var localTracks = GivenLocalTracks(tracks, release);
var localAlbumRelease = new LocalAlbumRelease(localTracks); var localAlbumRelease = new LocalAlbumRelease(localTracks);
Subject.GetCandidatesFromTags(localAlbumRelease, null, null, release).ShouldBeEquivalentTo(new List<AlbumRelease> { release }); Subject.GetCandidatesFromTags(localAlbumRelease, null, null, release, false).ShouldBeEquivalentTo(
new List<CandidateAlbumRelease> { new CandidateAlbumRelease(release) }
);
} }
[Test] [Test]
@ -149,7 +151,9 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification
.Setup(x => x.GetReleaseByForeignReleaseId("xxx")) .Setup(x => x.GetReleaseByForeignReleaseId("xxx"))
.Returns(release); .Returns(release);
Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null).ShouldBeEquivalentTo(new List<AlbumRelease> { release }); Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null, false).ShouldBeEquivalentTo(
new List<CandidateAlbumRelease> { new CandidateAlbumRelease(release) }
);
} }
} }
} }

View file

@ -176,7 +176,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification
GivenFingerprints(testcase.Fingerprints); GivenFingerprints(testcase.Fingerprints);
} }
var result = Subject.Identify(tracks, specifiedArtist, null, null, testcase.NewDownload, testcase.SingleRelease); var result = Subject.Identify(tracks, specifiedArtist, null, null, testcase.NewDownload, testcase.SingleRelease, false);
TestLogger.Debug($"Found releases:\n{result.Where(x => x.AlbumRelease != null).Select(x => x.AlbumRelease?.ForeignReleaseId).ToJson()}"); TestLogger.Debug($"Found releases:\n{result.Where(x => x.AlbumRelease != null).Select(x => x.AlbumRelease?.ForeignReleaseId).ToJson()}");

View file

@ -98,19 +98,23 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
Artist = _artist, Artist = _artist,
Quality = _quality, Quality = _quality,
Tracks = new List<Track> { new Track() }, Tracks = new List<Track> { new Track() },
Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi" Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic()
}; };
GivenVideoFiles(new List<string> { @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() }); GivenAudioFiles(new List<string> { @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() });
Mocker.GetMock<IIdentificationService>() Mocker.GetMock<IIdentificationService>()
.Setup(s => s.Identify(It.IsAny<List<LocalTrack>>(), It.IsAny<Artist>(), It.IsAny<Album>(), It.IsAny<AlbumRelease>(), It.IsAny<bool>(), It.IsAny<bool>())) .Setup(s => s.Identify(It.IsAny<List<LocalTrack>>(), It.IsAny<Artist>(), It.IsAny<Album>(), It.IsAny<AlbumRelease>(), It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<bool>()))
.Returns((List<LocalTrack> tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) => { .Returns((List<LocalTrack> tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) => {
var ret = new LocalAlbumRelease(tracks); var ret = new LocalAlbumRelease(tracks);
ret.AlbumRelease = _albumRelease; ret.AlbumRelease = _albumRelease;
return new List<LocalAlbumRelease> { ret }; return new List<LocalAlbumRelease> { ret };
}); });
Mocker.GetMock<IMediaFileService>()
.Setup(c => c.FilterExistingFiles(It.IsAny<List<string>>(), It.IsAny<Artist>()))
.Returns((List<string> files, Artist artist) => files);
GivenSpecifications(_albumpass1); GivenSpecifications(_albumpass1);
} }
@ -119,7 +123,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
Mocker.SetConstant(mocks.Select(c => c.Object)); Mocker.SetConstant(mocks.Select(c => c.Object));
} }
private void GivenVideoFiles(IEnumerable<string> videoFiles) private void GivenAudioFiles(IEnumerable<string> videoFiles)
{ {
_audioFiles = videoFiles.ToList(); _audioFiles = videoFiles.ToList();
@ -145,7 +149,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenAugmentationSuccess(); GivenAugmentationSuccess();
GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3);
Subject.GetImportDecisions(_audioFiles, new Artist(), null, downloadClientItem, null, false, false, false); Subject.GetImportDecisions(_audioFiles, new Artist(), null, null, downloadClientItem, null, false, false, false, false);
_albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>()), Times.Once()); _albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>()), Times.Once());
_albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>()), Times.Once()); _albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalAlbumRelease>()), Times.Once());
@ -162,7 +166,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenAugmentationSuccess(); GivenAugmentationSuccess();
GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3);
Subject.GetImportDecisions(_audioFiles, new Artist(), null, downloadClientItem, null, false, false, false); Subject.GetImportDecisions(_audioFiles, new Artist(), null, null, downloadClientItem, null, false, false, false, false);
_fail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Once()); _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Once());
_fail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Once()); _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Once());
@ -180,7 +184,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3);
GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3);
Subject.GetImportDecisions(_audioFiles, new Artist(), null, downloadClientItem, null, false, false, false); Subject.GetImportDecisions(_audioFiles, new Artist(), null, null, downloadClientItem, null, false, false, false, false);
_fail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Never()); _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Never());
_fail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Never()); _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny<LocalTrack>()), Times.Never());
@ -196,7 +200,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenSpecifications(_albumfail1); GivenSpecifications(_albumfail1);
GivenSpecifications(_pass1); GivenSpecifications(_pass1);
var result = Subject.GetImportDecisions(_audioFiles, new Artist()); var result = Subject.GetImportDecisions(_audioFiles, new Artist(), false);
result.Single().Approved.Should().BeFalse(); result.Single().Approved.Should().BeFalse();
} }
@ -207,7 +211,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenSpecifications(_albumpass1); GivenSpecifications(_albumpass1);
GivenSpecifications(_fail1); GivenSpecifications(_fail1);
var result = Subject.GetImportDecisions(_audioFiles, new Artist()); var result = Subject.GetImportDecisions(_audioFiles, new Artist(), false);
result.Single().Approved.Should().BeFalse(); result.Single().Approved.Should().BeFalse();
} }
@ -218,7 +222,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenSpecifications(_albumpass1, _albumfail1, _albumpass2, _albumpass3); GivenSpecifications(_albumpass1, _albumfail1, _albumpass2, _albumpass3);
GivenSpecifications(_pass1, _pass2, _pass3); GivenSpecifications(_pass1, _pass2, _pass3);
var result = Subject.GetImportDecisions(_audioFiles, new Artist()); var result = Subject.GetImportDecisions(_audioFiles, new Artist(), false);
result.Single().Approved.Should().BeFalse(); result.Single().Approved.Should().BeFalse();
} }
@ -229,7 +233,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); GivenSpecifications(_albumpass1, _albumpass2, _albumpass3);
GivenSpecifications(_pass1, _fail1, _pass2, _pass3); GivenSpecifications(_pass1, _fail1, _pass2, _pass3);
var result = Subject.GetImportDecisions(_audioFiles, new Artist()); var result = Subject.GetImportDecisions(_audioFiles, new Artist(), false);
result.Single().Approved.Should().BeFalse(); result.Single().Approved.Should().BeFalse();
} }
@ -241,7 +245,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); GivenSpecifications(_albumpass1, _albumpass2, _albumpass3);
GivenSpecifications(_pass1, _pass2, _pass3); GivenSpecifications(_pass1, _pass2, _pass3);
var result = Subject.GetImportDecisions(_audioFiles, new Artist()); var result = Subject.GetImportDecisions(_audioFiles, new Artist(), false);
result.Single().Approved.Should().BeTrue(); result.Single().Approved.Should().BeTrue();
} }
@ -252,7 +256,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
GivenAugmentationSuccess(); GivenAugmentationSuccess();
GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3);
var result = Subject.GetImportDecisions(_audioFiles, new Artist()); var result = Subject.GetImportDecisions(_audioFiles, new Artist(), false);
result.Single().Rejections.Should().HaveCount(3); result.Single().Rejections.Should().HaveCount(3);
} }
@ -265,16 +269,14 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
.Setup(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>())) .Setup(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()))
.Throws<TestException>(); .Throws<TestException>();
_audioFiles = new List<string> GivenAudioFiles(new []
{ {
"The.Office.S03E115.DVDRip.XviD-OSiTV", @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(),
"The.Office.S03E115.DVDRip.XviD-OSiTV", @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(),
"The.Office.S03E115.DVDRip.XviD-OSiTV" @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic()
}; });
GivenVideoFiles(_audioFiles); Subject.GetImportDecisions(_audioFiles, _artist, false);
Subject.GetImportDecisions(_audioFiles, _artist);
Mocker.GetMock<IAugmentingService>() Mocker.GetMock<IAugmentingService>()
.Verify(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()), Times.Exactly(_audioFiles.Count)); .Verify(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()), Times.Exactly(_audioFiles.Count));
@ -287,22 +289,20 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
{ {
GivenSpecifications(_pass1); GivenSpecifications(_pass1);
_audioFiles = new List<string> GivenAudioFiles(new []
{ {
"The.Office.S03E115.DVDRip.XviD-OSiTV", @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(),
"The.Office.S03E115.DVDRip.XviD-OSiTV", @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(),
"The.Office.S03E115.DVDRip.XviD-OSiTV" @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic()
}; });
GivenVideoFiles(_audioFiles);
Mocker.GetMock<IIdentificationService>() Mocker.GetMock<IIdentificationService>()
.Setup(s => s.Identify(It.IsAny<List<LocalTrack>>(), It.IsAny<Artist>(), It.IsAny<Album>(), It.IsAny<AlbumRelease>(), It.IsAny<bool>(), It.IsAny<bool>())) .Setup(s => s.Identify(It.IsAny<List<LocalTrack>>(), It.IsAny<Artist>(), It.IsAny<Album>(), It.IsAny<AlbumRelease>(), It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<bool>()))
.Returns((List<LocalTrack> tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) => { .Returns((List<LocalTrack> tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) => {
return new List<LocalAlbumRelease> { new LocalAlbumRelease(tracks) }; return new List<LocalAlbumRelease> { new LocalAlbumRelease(tracks) };
}); });
var decisions = Subject.GetImportDecisions(_audioFiles, _artist); var decisions = Subject.GetImportDecisions(_audioFiles, _artist, false);
Mocker.GetMock<IAugmentingService>() Mocker.GetMock<IAugmentingService>()
.Verify(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()), Times.Exactly(_audioFiles.Count)); .Verify(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()), Times.Exactly(_audioFiles.Count));
@ -316,16 +316,14 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
{ {
GivenSpecifications(_pass1); GivenSpecifications(_pass1);
_audioFiles = new List<string> GivenAudioFiles(new []
{ {
"The.Office.S03E115.DVDRip.XviD-OSiTV", @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(),
"The.Office.S03E115.DVDRip.XviD-OSiTV", @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(),
"The.Office.S03E115.DVDRip.XviD-OSiTV" @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic()
}; });
GivenVideoFiles(_audioFiles); var decisions = Subject.GetImportDecisions(_audioFiles, _artist, false);
var decisions = Subject.GetImportDecisions(_audioFiles, _artist);
Mocker.GetMock<IAugmentingService>() Mocker.GetMock<IAugmentingService>()
.Verify(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()), Times.Exactly(_audioFiles.Count)); .Verify(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()), Times.Exactly(_audioFiles.Count));
@ -341,14 +339,12 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport
.Setup(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>())) .Setup(c => c.Augment(It.IsAny<LocalTrack>(), It.IsAny<bool>()))
.Throws<TestException>(); .Throws<TestException>();
_audioFiles = new List<string> GivenAudioFiles(new []
{ {
"The.Office.S03E115.DVDRip.XviD-OSiTV" @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic()
}; });
GivenVideoFiles(_audioFiles); Subject.GetImportDecisions(_audioFiles, _artist, false).Should().HaveCount(1);
Subject.GetImportDecisions(_audioFiles, _artist).Should().HaveCount(1);
ExceptionVerification.ExpectedErrors(1); ExceptionVerification.ExpectedErrors(1);
} }

View file

@ -117,7 +117,7 @@ namespace NzbDrone.Core.MediaFiles
CleanMediaFiles(artist, mediaFileList); CleanMediaFiles(artist, mediaFileList);
var decisionsStopwatch = Stopwatch.StartNew(); var decisionsStopwatch = Stopwatch.StartNew();
var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, artist); var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, artist, false);
decisionsStopwatch.Stop(); decisionsStopwatch.Stop();
_logger.Debug("Import decisions complete for: {0} [{1}]", artist, decisionsStopwatch.Elapsed); _logger.Debug("Import decisions complete for: {0} [{1}]", artist, decisionsStopwatch.Elapsed);

View file

@ -0,0 +1,22 @@
using System.Collections.Generic;
using NzbDrone.Core.Music;
namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
{
public class CandidateAlbumRelease
{
public CandidateAlbumRelease()
{
}
public CandidateAlbumRelease(AlbumRelease release)
{
AlbumRelease = release;
ExistingTracks = new List<TrackFile>();
}
public AlbumRelease AlbumRelease { get; set; }
public List<TrackFile> ExistingTracks { get; set; }
}
}

View file

@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using Newtonsoft.Json; using Newtonsoft.Json;
using NLog; using NLog;
using NzbDrone.Common;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
@ -16,7 +18,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
{ {
public interface IIdentificationService public interface IIdentificationService
{ {
List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease); List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting);
} }
public class IdentificationService : IIdentificationService public class IdentificationService : IIdentificationService
@ -27,7 +29,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
private readonly ITrackService _trackService; private readonly ITrackService _trackService;
private readonly ITrackGroupingService _trackGroupingService; private readonly ITrackGroupingService _trackGroupingService;
private readonly IFingerprintingService _fingerprintingService; private readonly IFingerprintingService _fingerprintingService;
private readonly IAudioTagService _audioTagService;
private readonly IAugmentingService _augmentingService; private readonly IAugmentingService _augmentingService;
private readonly IMediaFileService _mediaFileService;
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly Logger _logger; private readonly Logger _logger;
@ -37,7 +41,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
ITrackService trackService, ITrackService trackService,
ITrackGroupingService trackGroupingService, ITrackGroupingService trackGroupingService,
IFingerprintingService fingerprintingService, IFingerprintingService fingerprintingService,
IAudioTagService audioTagService,
IAugmentingService augmentingService, IAugmentingService augmentingService,
IMediaFileService mediaFileService,
IConfigService configService, IConfigService configService,
Logger logger) Logger logger)
{ {
@ -47,7 +53,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
_trackService = trackService; _trackService = trackService;
_trackGroupingService = trackGroupingService; _trackGroupingService = trackGroupingService;
_fingerprintingService = fingerprintingService; _fingerprintingService = fingerprintingService;
_audioTagService = audioTagService;
_augmentingService = augmentingService; _augmentingService = augmentingService;
_mediaFileService = mediaFileService;
_configService = configService; _configService = configService;
_logger = logger; _logger = logger;
} }
@ -92,10 +100,10 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
_logger.Debug($"*** IdentificationService TestCaseGenerator ***\n{output}"); _logger.Debug($"*** IdentificationService TestCaseGenerator ***\n{output}");
} }
public List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) public List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting)
{ {
// 1 group localTracks so that we think they represent a single release // 1 group localTracks so that we think they represent a single release
// 2 get candidates given specified artist, album and release // 2 get candidates given specified artist, album and release. Candidates can include extra files already on disk.
// 3 find best candidate // 3 find best candidate
// 4 If best candidate worse than threshold, try fingerprinting // 4 If best candidate worse than threshold, try fingerprinting
@ -126,7 +134,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
{ {
_logger.Warn($"Augmentation failed for {localRelease}"); _logger.Warn($"Augmentation failed for {localRelease}");
} }
IdentifyRelease(localRelease, artist, album, release, newDownload); IdentifyRelease(localRelease, artist, album, release, newDownload, includeExisting);
} }
watch.Stop(); watch.Stop();
@ -165,18 +173,33 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
return false; return false;
} }
private void IdentifyRelease(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool newDownload) private List<LocalTrack> ToLocalTrack(IEnumerable<TrackFile> trackfiles)
{
var localTracks = trackfiles.Select(x => new LocalTrack {
Path = x.Path,
FileTrackInfo = _audioTagService.ReadTags(x.Path),
ExistingFile = true,
AdditionalFile = true
})
.ToList();
localTracks.ForEach(x => _augmentingService.Augment(x, true));
return localTracks;
}
private void IdentifyRelease(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool newDownload, bool includeExisting)
{ {
var watch = System.Diagnostics.Stopwatch.StartNew(); var watch = System.Diagnostics.Stopwatch.StartNew();
bool fingerprinted = false; bool fingerprinted = false;
var candidateReleases = GetCandidatesFromTags(localAlbumRelease, artist, album, release); var candidateReleases = GetCandidatesFromTags(localAlbumRelease, artist, album, release, includeExisting);
if (candidateReleases.Count == 0 && FingerprintingAllowed(newDownload)) if (candidateReleases.Count == 0 && FingerprintingAllowed(newDownload))
{ {
_logger.Debug("No candidates found, fingerprinting"); _logger.Debug("No candidates found, fingerprinting");
_fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5); _fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5);
fingerprinted = true; fingerprinted = true;
candidateReleases = GetCandidatesFromFingerprint(localAlbumRelease); candidateReleases = GetCandidatesFromFingerprint(localAlbumRelease, artist, album, release, includeExisting);
} }
if (candidateReleases.Count == 0) if (candidateReleases.Count == 0)
@ -187,11 +210,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
_logger.Debug($"Got {candidateReleases.Count} candidates for {localAlbumRelease.LocalTracks.Count} tracks in {watch.ElapsedMilliseconds}ms"); _logger.Debug($"Got {candidateReleases.Count} candidates for {localAlbumRelease.LocalTracks.Count} tracks in {watch.ElapsedMilliseconds}ms");
var allTracks = _trackService.GetTracksByReleases(candidateReleases.Select(x => x.Id).ToList()); var allTracks = _trackService.GetTracksByReleases(candidateReleases.Select(x => x.AlbumRelease.Id).ToList());
// convert all the TrackFiles that represent extra files to List<LocalTrack>
var allLocalTracks = ToLocalTrack(candidateReleases
.SelectMany(x => x.ExistingTracks)
.DistinctBy(x => x.Path));
_logger.Debug($"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms"); _logger.Debug($"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms");
GetBestRelease(localAlbumRelease, candidateReleases, allTracks); GetBestRelease(localAlbumRelease, candidateReleases, allTracks, allLocalTracks);
// If result isn't great and we haven't fingerprinted, try that // If result isn't great and we haven't fingerprinted, try that
// Note that this can improve the match even if we try the same candidates // Note that this can improve the match even if we try the same candidates
@ -204,12 +232,20 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
// Will generally be specified as part of manual import // Will generally be specified as part of manual import
if (album == null && release == null) if (album == null && release == null)
{ {
var extraCandidates = GetCandidatesFromFingerprint(localAlbumRelease).DistinctBy(x => x.Id); var extraCandidates = GetCandidatesFromFingerprint(localAlbumRelease, artist, album, release, includeExisting);
candidateReleases.AddRange(extraCandidates); var newCandidates = extraCandidates.ExceptBy(x => x.AlbumRelease.Id, candidateReleases, y => y.AlbumRelease.Id, EqualityComparer<int>.Default);
allTracks.AddRange(_trackService.GetTracksByReleases(extraCandidates.Select(x => x.Id).ToList())); candidateReleases.AddRange(newCandidates);
allTracks.AddRange(_trackService.GetTracksByReleases(newCandidates.Select(x => x.AlbumRelease.Id).ToList()));
allLocalTracks.AddRange(ToLocalTrack(newCandidates
.SelectMany(x => x.ExistingTracks)
.DistinctBy(x => x.Path)
.ExceptBy(x => x.Path, allLocalTracks, x => x.Path, PathEqualityComparer.Instance)));
} }
GetBestRelease(localAlbumRelease, candidateReleases, allTracks); // fingerprint all the local files in candidates we might be matching against
_fingerprintingService.Lookup(allLocalTracks, 0.5);
GetBestRelease(localAlbumRelease, candidateReleases, allTracks, allLocalTracks);
} }
_logger.Debug($"Best release found in {watch.ElapsedMilliseconds}ms"); _logger.Debug($"Best release found in {watch.ElapsedMilliseconds}ms");
@ -219,43 +255,70 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
_logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms"); _logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms");
} }
public List<AlbumRelease> GetCandidatesFromTags(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release) public List<CandidateAlbumRelease> GetCandidatesFromTags(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool includeExisting)
{ {
var watch = System.Diagnostics.Stopwatch.StartNew(); var watch = System.Diagnostics.Stopwatch.StartNew();
// Generally artist, album and release are null. But if they're not then limit candidates appropriately. // Generally artist, album and release are null. But if they're not then limit candidates appropriately.
// We've tried to make sure that tracks are all for a single release. // We've tried to make sure that tracks are all for a single release.
List<AlbumRelease> candidateReleases; List<CandidateAlbumRelease> candidateReleases;
// if we have a release ID, use that
AlbumRelease tagMbidRelease = null;
List<CandidateAlbumRelease> tagCandidate = null;
// if we have a release ID that makes sense, use that
var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList(); var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList();
if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace()) if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace())
{ {
var tagRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0]); _logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]);
if (tagRelease != null) tagMbidRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0]);
if (tagMbidRelease != null)
{ {
_logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); tagCandidate = GetCandidatesByRelease(new List<AlbumRelease> { tagMbidRelease }, includeExisting);
return new List<AlbumRelease> { tagRelease };
} }
} }
if (release != null) if (release != null)
{ {
// this case overrides the release picked up from the file tags
_logger.Debug("Release {0} [{1} tracks] was forced", release, release.TrackCount); _logger.Debug("Release {0} [{1} tracks] was forced", release, release.TrackCount);
candidateReleases = new List<AlbumRelease> { release }; candidateReleases = GetCandidatesByRelease(new List<AlbumRelease> { release }, includeExisting);
} }
else if (album != null) else if (album != null)
{ {
candidateReleases = GetCandidatesByAlbum(localAlbumRelease, album); // use the release from file tags if it exists and agrees with the specified album
if (tagMbidRelease?.AlbumId == album.Id)
{
candidateReleases = tagCandidate;
}
else
{
candidateReleases = GetCandidatesByAlbum(localAlbumRelease, album, includeExisting);
}
} }
else if (artist != null) else if (artist != null)
{ {
candidateReleases = GetCandidatesByArtist(localAlbumRelease, artist); // use the release from file tags if it exists and agrees with the specified album
if (tagMbidRelease?.Album.Value.ArtistMetadataId == artist.ArtistMetadataId)
{
candidateReleases = tagCandidate;
}
else
{
candidateReleases = GetCandidatesByArtist(localAlbumRelease, artist, includeExisting);
}
} }
else else
{ {
candidateReleases = GetCandidates(localAlbumRelease); if (tagMbidRelease != null)
{
candidateReleases = tagCandidate;
}
else
{
candidateReleases = GetCandidates(localAlbumRelease, includeExisting);
}
} }
watch.Stop(); watch.Stop();
@ -265,19 +328,41 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
return candidateReleases; return candidateReleases;
} }
private List<AlbumRelease> GetCandidatesByAlbum(LocalAlbumRelease localAlbumRelease, Album album) private List<CandidateAlbumRelease> GetCandidatesByRelease(List<AlbumRelease> releases, bool includeExisting)
{
// get the local tracks on disk for each album
var albumTracks = releases.Select(x => x.AlbumId)
.Distinct()
.ToDictionary(id => id, id => includeExisting ? _mediaFileService.GetFilesByAlbum(id) : new List<TrackFile>());
// populate the path. Artist will have been returned by mediaFileService
foreach (var trackfiles in albumTracks.Values)
{
foreach (var trackfile in trackfiles)
{
trackfile.Path = Path.Combine(trackfile.Artist.Value.Path, trackfile.RelativePath);
}
}
return releases.Select(x => new CandidateAlbumRelease {
AlbumRelease = x,
ExistingTracks = albumTracks[x.AlbumId]
}).ToList();
}
private List<CandidateAlbumRelease> GetCandidatesByAlbum(LocalAlbumRelease localAlbumRelease, Album album, bool includeExisting)
{ {
// sort candidate releases by closest track count so that we stand a chance of // sort candidate releases by closest track count so that we stand a chance of
// getting a perfect match early on // getting a perfect match early on
return _releaseService.GetReleasesByAlbum(album.Id) return GetCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id)
.OrderBy(x => Math.Abs(localAlbumRelease.TrackCount - x.TrackCount)) .OrderBy(x => Math.Abs(localAlbumRelease.TrackCount - x.TrackCount))
.ToList(); .ToList(), includeExisting);
} }
private List<AlbumRelease> GetCandidatesByArtist(LocalAlbumRelease localAlbumRelease, Artist artist) private List<CandidateAlbumRelease> GetCandidatesByArtist(LocalAlbumRelease localAlbumRelease, Artist artist, bool includeExisting)
{ {
_logger.Trace("Getting candidates for {0}", artist); _logger.Trace("Getting candidates for {0}", artist);
var candidateReleases = new List<AlbumRelease>(); var candidateReleases = new List<CandidateAlbumRelease>();
var albumTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? ""; var albumTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? "";
if (albumTag.IsNotNullOrWhiteSpace()) if (albumTag.IsNotNullOrWhiteSpace())
@ -285,14 +370,14 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
var possibleAlbums = _albumService.GetCandidates(artist.ArtistMetadataId, albumTag); var possibleAlbums = _albumService.GetCandidates(artist.ArtistMetadataId, albumTag);
foreach (var album in possibleAlbums) foreach (var album in possibleAlbums)
{ {
candidateReleases.AddRange(GetCandidatesByAlbum(localAlbumRelease, album)); candidateReleases.AddRange(GetCandidatesByAlbum(localAlbumRelease, album, includeExisting));
} }
} }
return candidateReleases; return candidateReleases;
} }
private List<AlbumRelease> GetCandidates(LocalAlbumRelease localAlbumRelease) private List<CandidateAlbumRelease> GetCandidates(LocalAlbumRelease localAlbumRelease, bool includeExisting)
{ {
// most general version, nothing has been specified. // most general version, nothing has been specified.
// get all plausible artists, then all plausible albums, then get releases for each of these. // get all plausible artists, then all plausible albums, then get releases for each of these.
@ -303,7 +388,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
throw new NotImplementedException("Various artists not supported"); throw new NotImplementedException("Various artists not supported");
} }
var candidateReleases = new List<AlbumRelease>(); var candidateReleases = new List<CandidateAlbumRelease>();
var artistTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? ""; var artistTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? "";
if (artistTag.IsNotNullOrWhiteSpace()) if (artistTag.IsNotNullOrWhiteSpace())
@ -311,30 +396,44 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
var possibleArtists = _artistService.GetCandidates(artistTag); var possibleArtists = _artistService.GetCandidates(artistTag);
foreach (var artist in possibleArtists) foreach (var artist in possibleArtists)
{ {
candidateReleases.AddRange(GetCandidatesByArtist(localAlbumRelease, artist)); candidateReleases.AddRange(GetCandidatesByArtist(localAlbumRelease, artist, includeExisting));
} }
} }
return candidateReleases; return candidateReleases;
} }
public List<AlbumRelease> GetCandidatesFromFingerprint(LocalAlbumRelease localAlbumRelease) public List<CandidateAlbumRelease> GetCandidatesFromFingerprint(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool includeExisting)
{ {
var recordingIds = localAlbumRelease.LocalTracks.Where(x => x.AcoustIdResults != null).SelectMany(x => x.AcoustIdResults).ToList(); var recordingIds = localAlbumRelease.LocalTracks.Where(x => x.AcoustIdResults != null).SelectMany(x => x.AcoustIdResults).ToList();
var allReleases = _releaseService.GetReleasesByRecordingIds(recordingIds); var allReleases = _releaseService.GetReleasesByRecordingIds(recordingIds);
return allReleases.Select(x => new { // make sure releases are consistent with those selected by the user
Release = x, if (release != null)
TrackCount = x.TrackCount, {
CommonProportion = x.Tracks.Value.Select(y => y.ForeignRecordingId).Intersect(recordingIds).Count() / localAlbumRelease.TrackCount allReleases = allReleases.Where(x => x.Id == release.Id).ToList();
}) }
else if (album != null)
{
allReleases = allReleases.Where(x => x.AlbumId == album.Id).ToList();
}
else if (artist != null)
{
allReleases = allReleases.Where(x => x.Album.Value.ArtistMetadataId == artist.ArtistMetadataId).ToList();
}
return GetCandidatesByRelease(allReleases.Select(x => new {
Release = x,
TrackCount = x.TrackCount,
CommonProportion = x.Tracks.Value.Select(y => y.ForeignRecordingId).Intersect(recordingIds).Count() / localAlbumRelease.TrackCount
})
.Where(x => x.CommonProportion > 0.6) .Where(x => x.CommonProportion > 0.6)
.ToList() .ToList()
.OrderBy(x => Math.Abs(x.TrackCount - localAlbumRelease.TrackCount)) .OrderBy(x => Math.Abs(x.TrackCount - localAlbumRelease.TrackCount))
.ThenByDescending(x => x.CommonProportion) .ThenByDescending(x => x.CommonProportion)
.Select(x => x.Release) .Select(x => x.Release)
.Take(10) .Take(10)
.ToList(); .ToList(), includeExisting);
} }
private T MostCommon<T>(IEnumerable<T> items) private T MostCommon<T>(IEnumerable<T> items)
@ -342,7 +441,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
return items.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key; return items.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key;
} }
private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List<AlbumRelease> candidateReleases, List<Track> tracks) private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List<CandidateAlbumRelease> candidateReleases, List<Track> dbTracks, List<LocalTrack> extraTracksOnDisk)
{ {
var watch = System.Diagnostics.Stopwatch.StartNew(); var watch = System.Diagnostics.Stopwatch.StartNew();
@ -351,13 +450,18 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
double bestDistance = 1.0; double bestDistance = 1.0;
foreach (var release in candidateReleases) foreach (var candidateRelease in candidateReleases)
{ {
_logger.Debug("Trying Release {0} [{1}, {2} tracks]", release, release.Title, release.TrackCount); var release = candidateRelease.AlbumRelease;
_logger.Debug("Trying Release {0} [{1}, {2} tracks, {3} existing]", release, release.Title, release.TrackCount, candidateRelease.ExistingTracks.Count);
var rwatch = System.Diagnostics.Stopwatch.StartNew(); var rwatch = System.Diagnostics.Stopwatch.StartNew();
var mapping = MapReleaseTracks(localAlbumRelease.LocalTracks, tracks.Where(x => x.AlbumReleaseId == release.Id).ToList()); var extraTrackPaths = candidateRelease.ExistingTracks.Select(x => x.Path).ToList();
var distance = AlbumReleaseDistance(localAlbumRelease.LocalTracks, release, mapping); var extraTracks = extraTracksOnDisk.Where(x => extraTrackPaths.Contains(x.Path)).ToList();
var allLocalTracks = localAlbumRelease.LocalTracks.Concat(extraTracks).DistinctBy(x => x.Path).ToList();
var mapping = MapReleaseTracks(allLocalTracks, dbTracks.Where(x => x.AlbumReleaseId == release.Id).ToList());
var distance = AlbumReleaseDistance(allLocalTracks, release, mapping);
var currDistance = distance.NormalizedDistance(); var currDistance = distance.NormalizedDistance();
rwatch.Stop(); rwatch.Stop();
@ -368,6 +472,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
bestDistance = currDistance; bestDistance = currDistance;
localAlbumRelease.Distance = distance; localAlbumRelease.Distance = distance;
localAlbumRelease.AlbumRelease = release; localAlbumRelease.AlbumRelease = release;
localAlbumRelease.ExistingTracks = extraTracks;
localAlbumRelease.TrackMapping = mapping; localAlbumRelease.TrackMapping = mapping;
if (currDistance == 0.0) if (currDistance == 0.0)
{ {

View file

@ -19,7 +19,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
{ {
public interface IImportApprovedTracks public interface IImportApprovedTracks
{ {
List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto); List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool replaceExisting, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto);
} }
public class ImportApprovedTracks : IImportApprovedTracks public class ImportApprovedTracks : IImportApprovedTracks
@ -58,7 +58,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
_logger = logger; _logger = logger;
} }
public List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) public List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool replaceExisting, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto)
{ {
var qualifiedImports = decisions.Where(c => c.Approved) var qualifiedImports = decisions.Where(c => c.Approved)
.GroupBy(c => c.Item.Artist.Id, (i, s) => s .GroupBy(c => c.Item.Artist.Id, (i, s) => s
@ -68,53 +68,48 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
.SelectMany(c => c) .SelectMany(c => c)
.ToList(); .ToList();
_logger.Debug($"Importing {qualifiedImports.Count} files. replaceExisting: {replaceExisting}");
var importResults = new List<ImportResult>(); var importResults = new List<ImportResult>();
var allImportedTrackFiles = new List<TrackFile>(); var allImportedTrackFiles = new List<TrackFile>();
var allOldTrackFiles = new List<TrackFile>(); var allOldTrackFiles = new List<TrackFile>();
var albumDecisions = decisions.Where(e => e.Item.Album != null) var albumDecisions = decisions.Where(e => e.Item.Album != null && e.Approved)
.GroupBy(e => e.Item.Album.Id).ToList(); .GroupBy(e => e.Item.Album.Id).ToList();
foreach (var albumDecision in albumDecisions) foreach (var albumDecision in albumDecisions)
{ {
var album = albumDecision.First().Item.Album; var album = albumDecision.First().Item.Album;
var currentRelease = album.AlbumReleases.Value.Single(x => x.Monitored); var newRelease = albumDecision.First().Item.Release;
if (albumDecision.Any(x => x.Approved)) if (replaceExisting)
{ {
var newRelease = albumDecision.First(x => x.Approved).Item.Release; var artist = albumDecision.First().Item.Artist;
var rootFolder = _diskProvider.GetParentFolder(artist.Path);
var previousFiles = _mediaFileService.GetFilesByAlbum(album.Id);
if (currentRelease.Id != newRelease.Id) _logger.Debug($"Deleting {previousFiles.Count} existing files for {album}");
foreach (var previousFile in previousFiles)
{ {
// if we are importing a new release, delete all old files and don't attempt to upgrade var trackFilePath = Path.Combine(artist.Path, previousFile.RelativePath);
if (newDownload) var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(trackFilePath));
if (_diskProvider.FileExists(trackFilePath))
{ {
var artist = albumDecision.First().Item.Artist; _logger.Debug("Removing existing track file: {0}", previousFile);
var rootFolder = _diskProvider.GetParentFolder(artist.Path); _recycleBinProvider.DeleteFile(trackFilePath, subfolder);
var previousFiles = _mediaFileService.GetFilesByAlbum(album.Id);
foreach (var previousFile in previousFiles)
{
var trackFilePath = Path.Combine(artist.Path, previousFile.RelativePath);
var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(trackFilePath));
if (_diskProvider.FileExists(trackFilePath))
{
_logger.Debug("Removing existing track file: {0}", previousFile);
_recycleBinProvider.DeleteFile(trackFilePath, subfolder);
}
_mediaFileService.Delete(previousFile, DeleteMediaFileReason.Upgrade);
}
} }
_mediaFileService.Delete(previousFile, DeleteMediaFileReason.Upgrade);
// set the correct release to be monitored before importing the new files
_logger.Debug("Updating release to {0} [{1} tracks]", newRelease, newRelease.TrackCount);
_releaseService.SetMonitored(newRelease);
// Publish album edited event.
// Deliberatly don't put in the old album since we don't want to trigger an ArtistScan.
_eventAggregator.PublishEvent(new AlbumEditedEvent(album, album));
} }
} }
// set the correct release to be monitored before importing the new files
_logger.Debug("Updating release to {0} [{1} tracks]", newRelease, newRelease.TrackCount);
_releaseService.SetMonitored(newRelease);
// Publish album edited event.
// Deliberatly don't put in the old album since we don't want to trigger an ArtistScan.
_eventAggregator.PublishEvent(new AlbumEditedEvent(album, album));
} }
var filesToAdd = new List<TrackFile>(qualifiedImports.Count); var filesToAdd = new List<TrackFile>(qualifiedImports.Count);
@ -186,7 +181,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
break; break;
} }
if (newDownload) if (!localTrack.ExistingFile)
{ {
trackFile.SceneName = GetSceneReleaseName(downloadClientItem, localTrack); trackFile.SceneName = GetSceneReleaseName(downloadClientItem, localTrack);
@ -205,13 +200,13 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
_mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride); _mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride);
} }
_audioTagService.WriteTags(trackFile, newDownload); _audioTagService.WriteTags(trackFile, false);
} }
filesToAdd.Add(trackFile); filesToAdd.Add(trackFile);
importResults.Add(new ImportResult(importDecision)); importResults.Add(new ImportResult(importDecision));
if (newDownload) if (!localTrack.ExistingFile)
{ {
_extraService.ImportTrack(localTrack, trackFile, copyOnly); _extraService.ImportTrack(localTrack, trackFile, copyOnly);
} }
@ -219,12 +214,12 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
allImportedTrackFiles.Add(trackFile); allImportedTrackFiles.Add(trackFile);
allOldTrackFiles.AddRange(oldFiles); allOldTrackFiles.AddRange(oldFiles);
_eventAggregator.PublishEvent(new TrackImportedEvent(localTrack, trackFile, oldFiles, newDownload, downloadClientItem)); _eventAggregator.PublishEvent(new TrackImportedEvent(localTrack, trackFile, oldFiles, !localTrack.ExistingFile, downloadClientItem));
} }
catch (RootFolderNotFoundException e) catch (RootFolderNotFoundException e)
{ {
_logger.Warn(e, "Couldn't import track " + localTrack); _logger.Warn(e, "Couldn't import track " + localTrack);
_eventAggregator.PublishEvent(new TrackImportFailedEvent(e, localTrack, newDownload, downloadClientItem)); _eventAggregator.PublishEvent(new TrackImportFailedEvent(e, localTrack, !localTrack.ExistingFile, downloadClientItem));
importResults.Add(new ImportResult(importDecision, "Failed to import track, Root folder missing.")); importResults.Add(new ImportResult(importDecision, "Failed to import track, Root folder missing."));
} }
@ -269,7 +264,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
album, album,
release, release,
allImportedTrackFiles.Where(s => s.AlbumId == album.Id).ToList(), allImportedTrackFiles.Where(s => s.AlbumId == album.Id).ToList(),
allOldTrackFiles.Where(s => s.AlbumId == album.Id).ToList(), newDownload, allOldTrackFiles.Where(s => s.AlbumId == album.Id).ToList(), replaceExisting,
downloadClientItem)); downloadClientItem));
} }

View file

@ -16,9 +16,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
{ {
public interface IMakeImportDecision public interface IMakeImportDecision
{ {
List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist); List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, bool includeExisting);
List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo); List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo);
List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, Album album, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool newDownload, bool singleRelease); List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, Album album, AlbumRelease albumRelease, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool newDownload, bool singleRelease, bool includeExisting);
} }
public class ImportDecisionMaker : IMakeImportDecision public class ImportDecisionMaker : IMakeImportDecision
@ -60,22 +60,22 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
_logger = logger; _logger = logger;
} }
public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist) public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, bool includeExisting)
{ {
return GetImportDecisions(musicFiles, artist, null, null, null, false, false, false); return GetImportDecisions(musicFiles, artist, null, null, null, null, false, false, false, true);
} }
public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo) public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo)
{ {
return GetImportDecisions(musicFiles, artist, null, null, folderInfo, false, true, false); return GetImportDecisions(musicFiles, artist, null, null, null, folderInfo, false, true, false, false);
} }
public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, Album album, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool newDownload, bool singleRelease) public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, Album album, AlbumRelease albumRelease, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool newDownload, bool singleRelease, bool includeExisting)
{ {
var watch = new System.Diagnostics.Stopwatch(); var watch = new System.Diagnostics.Stopwatch();
watch.Start(); watch.Start();
var files = filterExistingFiles && (artist != null) ? _mediaFileService.FilterExistingFiles(musicFiles.ToList(), artist) : musicFiles.ToList(); var files = filterExistingFiles && (artist != null) ? _mediaFileService.FilterExistingFiles(musicFiles, artist) : musicFiles;
_logger.Debug("Analyzing {0}/{1} files.", files.Count, musicFiles.Count); _logger.Debug("Analyzing {0}/{1} files.", files.Count, musicFiles.Count);
@ -98,7 +98,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
DownloadClientAlbumInfo = downloadClientItemInfo, DownloadClientAlbumInfo = downloadClientItemInfo,
FolderTrackInfo = folderInfo, FolderTrackInfo = folderInfo,
Path = file, Path = file,
FileTrackInfo = _audioTagService.ReadTags(file) FileTrackInfo = _audioTagService.ReadTags(file),
ExistingFile = !newDownload,
AdditionalFile = false
}; };
try try
@ -121,7 +123,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
_logger.Debug($"Tags parsed for {files.Count} files in {watch.ElapsedMilliseconds}ms"); _logger.Debug($"Tags parsed for {files.Count} files in {watch.ElapsedMilliseconds}ms");
var releases = _identificationService.Identify(localTracks, artist, album, null, newDownload, singleRelease); var releases = _identificationService.Identify(localTracks, artist, album, albumRelease, newDownload, singleRelease, includeExisting);
foreach (var release in releases) foreach (var release in releases)
{ {
@ -133,7 +135,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
if (releaseDecision.Approved) if (releaseDecision.Approved)
{ {
decisions.AddIfNotNull(GetDecision(localTrack)); decisions.AddIfNotNull(GetDecision(localTrack));
} }
else else
{ {

View file

@ -11,5 +11,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
public override bool RequiresDiskAccess => true; public override bool RequiresDiskAccess => true;
public ImportMode ImportMode { get; set; } public ImportMode ImportMode { get; set; }
public bool ReplaceExistingFiles { get; set; }
} }
} }

View file

@ -17,6 +17,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
public QualityModel Quality { get; set; } public QualityModel Quality { get; set; }
public Language Language { get; set; } public Language Language { get; set; }
public string DownloadId { get; set; } public string DownloadId { get; set; }
public bool DisableReleaseSwitching { get; set; }
public bool Equals(ManualImportFile other) public bool Equals(ManualImportFile other)
{ {

View file

@ -24,5 +24,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
public string DownloadId { get; set; } public string DownloadId { get; set; }
public IEnumerable<Rejection> Rejections { get; set; } public IEnumerable<Rejection> Rejections { get; set; }
public ParsedTrackInfo Tags { get; set; } public ParsedTrackInfo Tags { get; set; }
public bool AdditionalFile { get; set; }
public bool ReplaceExistingFiles { get; set; }
public bool DisableReleaseSwitching { get; set; }
} }
} }

View file

@ -14,15 +14,14 @@ using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Music; using NzbDrone.Core.Music;
using NzbDrone.Common.Crypto; using NzbDrone.Common.Crypto;
using NzbDrone.Common.Cache; using NzbDrone.Common;
namespace NzbDrone.Core.MediaFiles.TrackImport.Manual namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
{ {
public interface IManualImportService public interface IManualImportService
{ {
List<ManualImportItem> GetMediaFiles(string path, string downloadId, bool filterExistingFiles); List<ManualImportItem> GetMediaFiles(string path, string downloadId, bool filterExistingFiles, bool replaceExistingFiles);
void UpdateItems(List<ManualImportItem> item); List<ManualImportItem> UpdateItems(List<ManualImportItem> item);
ManualImportItem Find(int id);
} }
public class ManualImportService : IExecute<ManualImportCommand>, IManualImportService public class ManualImportService : IExecute<ManualImportCommand>, IManualImportService
@ -35,10 +34,10 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
private readonly IAlbumService _albumService; private readonly IAlbumService _albumService;
private readonly IReleaseService _releaseService; private readonly IReleaseService _releaseService;
private readonly ITrackService _trackService; private readonly ITrackService _trackService;
private readonly IAudioTagService _audioTagService;
private readonly IImportApprovedTracks _importApprovedTracks; private readonly IImportApprovedTracks _importApprovedTracks;
private readonly ITrackedDownloadService _trackedDownloadService; private readonly ITrackedDownloadService _trackedDownloadService;
private readonly IDownloadedTracksImportService _downloadedTracksImportService; private readonly IDownloadedTracksImportService _downloadedTracksImportService;
private readonly ICached<ManualImportItem> _cache;
private readonly IEventAggregator _eventAggregator; private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger; private readonly Logger _logger;
@ -50,10 +49,10 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
IAlbumService albumService, IAlbumService albumService,
IReleaseService releaseService, IReleaseService releaseService,
ITrackService trackService, ITrackService trackService,
IAudioTagService audioTagService,
IImportApprovedTracks importApprovedTracks, IImportApprovedTracks importApprovedTracks,
ITrackedDownloadService trackedDownloadService, ITrackedDownloadService trackedDownloadService,
IDownloadedTracksImportService downloadedTracksImportService, IDownloadedTracksImportService downloadedTracksImportService,
ICacheManager cacheManager,
IEventAggregator eventAggregator, IEventAggregator eventAggregator,
Logger logger) Logger logger)
{ {
@ -65,23 +64,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
_albumService = albumService; _albumService = albumService;
_releaseService = releaseService; _releaseService = releaseService;
_trackService = trackService; _trackService = trackService;
_audioTagService = audioTagService;
_importApprovedTracks = importApprovedTracks; _importApprovedTracks = importApprovedTracks;
_trackedDownloadService = trackedDownloadService; _trackedDownloadService = trackedDownloadService;
_downloadedTracksImportService = downloadedTracksImportService; _downloadedTracksImportService = downloadedTracksImportService;
_cache = cacheManager.GetCache<ManualImportItem>(GetType());
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_logger = logger; _logger = logger;
} }
public ManualImportItem Find(int id) public List<ManualImportItem> GetMediaFiles(string path, string downloadId, bool filterExistingFiles, bool replaceExistingFiles)
{ {
return _cache.Find(id.ToString());
}
public List<ManualImportItem> GetMediaFiles(string path, string downloadId, bool filterExistingFiles)
{
_cache.Clear();
if (downloadId.IsNotNullOrWhiteSpace()) if (downloadId.IsNotNullOrWhiteSpace())
{ {
var trackedDownload = _trackedDownloadService.Find(downloadId); var trackedDownload = _trackedDownloadService.Find(downloadId);
@ -101,23 +93,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
return new List<ManualImportItem>(); return new List<ManualImportItem>();
} }
var decision = _importDecisionMaker.GetImportDecisions(new List<string> { path }, null, null, null, null, false, true, false); var decision = _importDecisionMaker.GetImportDecisions(new List<string> { path }, null, null, null, null, null, false, true, false, !replaceExistingFiles);
var result = MapItem(decision.First(), Path.GetDirectoryName(path), downloadId); var result = MapItem(decision.First(), Path.GetDirectoryName(path), downloadId, replaceExistingFiles, false);
_cache.Set(result.Id.ToString(), result);
return new List<ManualImportItem> { result }; return new List<ManualImportItem> { result };
} }
var items = ProcessFolder(path, downloadId, filterExistingFiles); return ProcessFolder(path, downloadId, filterExistingFiles, replaceExistingFiles);
foreach (var item in items)
{
_cache.Set(item.Id.ToString(), item);
}
return items;
} }
private List<ManualImportItem> ProcessFolder(string folder, string downloadId, bool filterExistingFiles) private List<ManualImportItem> ProcessFolder(string folder, string downloadId, bool filterExistingFiles, bool replaceExistingFiles)
{ {
var directoryInfo = new DirectoryInfo(folder); var directoryInfo = new DirectoryInfo(folder);
var artist = _parsingService.GetArtist(directoryInfo.Name); var artist = _parsingService.GetArtist(directoryInfo.Name);
@ -130,24 +115,48 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
var folderInfo = Parser.Parser.ParseMusicTitle(directoryInfo.Name); var folderInfo = Parser.Parser.ParseMusicTitle(directoryInfo.Name);
var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); var artistFiles = _diskScanService.GetAudioFiles(folder).ToList();
var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, null, null, folderInfo, filterExistingFiles, true, false); var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, null, null, null, folderInfo, filterExistingFiles, true, false, !replaceExistingFiles);
return decisions.Select(decision => MapItem(decision, folder, downloadId)).ToList(); // paths will be different for new and old files which is why we need to map separately
var newFiles = artistFiles.Join(decisions,
f => f,
d => d.Item.Path,
(f, d) => new { File = f, Decision = d },
PathEqualityComparer.Instance);
var newItems = newFiles.Select(x => MapItem(x.Decision, folder, downloadId, replaceExistingFiles, false));
var existingDecisions = decisions.Except(newFiles.Select(x => x.Decision));
var existingItems = existingDecisions.Select(x => MapItem(x, x.Item.Artist.Path, null, replaceExistingFiles, false));
return newItems.Concat(existingItems).ToList();
} }
public void UpdateItems(List<ManualImportItem> items) public List<ManualImportItem> UpdateItems(List<ManualImportItem> items)
{ {
var groupedItems = items.GroupBy(x => x.Album?.Id); var replaceExistingFiles = items.All(x => x.ReplaceExistingFiles);
_logger.Debug("UpdateItems, {0} groups", groupedItems.Count()); var groupedItems = items.Where(x => !x.AdditionalFile).GroupBy(x => x.Album?.Id);
_logger.Debug($"UpdateItems, {groupedItems.Count()} groups, replaceExisting {replaceExistingFiles}");
var result = new List<ManualImportItem>();
foreach(var group in groupedItems) foreach(var group in groupedItems)
{ {
// generate dummy decisions that don't match the release
_logger.Debug("UpdateItems, group key: {0}", group.Key); _logger.Debug("UpdateItems, group key: {0}", group.Key);
var decisions = _importDecisionMaker.GetImportDecisions(group.Select(x => x.Path).ToList(), group.First().Artist, group.First().Album, null, null, false, true, true);
foreach (var decision in decisions) var disableReleaseSwitching = group.First().DisableReleaseSwitching;
var decisions = _importDecisionMaker.GetImportDecisions(group.Select(x => x.Path).ToList(), group.First().Artist, group.First().Album, group.First().Release, null, null, false, true, true, !replaceExistingFiles);
var existingItems = group.Join(decisions,
i => i.Path,
d => d.Item.Path,
(i, d) => new { Item = i, Decision = d },
PathEqualityComparer.Instance);
foreach (var pair in existingItems)
{ {
var item = items.Where(x => x.Path == decision.Item.Path).Single(); var item = pair.Item;
var decision = pair.Decision;
if (decision.Item.Artist != null) if (decision.Item.Artist != null)
{ {
@ -167,12 +176,17 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
item.Rejections = decision.Rejections; item.Rejections = decision.Rejections;
_cache.Set(item.Id.ToString(), item); result.Add(item);
} }
var newDecisions = decisions.Except(existingItems.Select(x => x.Decision));
result.AddRange(newDecisions.Select(x => MapItem(x, x.Item.Artist.Path, null, replaceExistingFiles, disableReleaseSwitching)));
} }
return result;
} }
private ManualImportItem MapItem(ImportDecision<LocalTrack> decision, string folder, string downloadId) private ManualImportItem MapItem(ImportDecision<LocalTrack> decision, string folder, string downloadId, bool replaceExistingFiles, bool disableReleaseSwitching)
{ {
var item = new ManualImportItem(); var item = new ManualImportItem();
@ -203,6 +217,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
item.Size = _diskProvider.GetFileSize(decision.Item.Path); item.Size = _diskProvider.GetFileSize(decision.Item.Path);
item.Rejections = decision.Rejections; item.Rejections = decision.Rejections;
item.Tags = decision.Item.FileTrackInfo; item.Tags = decision.Item.FileTrackInfo;
item.AdditionalFile = decision.Item.AdditionalFile;
item.ReplaceExistingFiles = replaceExistingFiles;
item.DisableReleaseSwitching = disableReleaseSwitching;
return item; return item;
} }
@ -220,6 +237,14 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
{ {
var albumImportDecisions = new List<ImportDecision<LocalTrack>>(); var albumImportDecisions = new List<ImportDecision<LocalTrack>>();
// turn off anyReleaseOk if specified
if (importAlbumId.First().DisableReleaseSwitching)
{
var album = _albumService.GetAlbum(importAlbumId.First().AlbumId);
album.AnyReleaseOk = false;
_albumService.UpdateAlbum(album);
}
foreach (var file in importAlbumId) foreach (var file in importAlbumId)
{ {
_logger.ProgressTrace("Processing file {0} of {1}", fileCount + 1, message.Files.Count); _logger.ProgressTrace("Processing file {0} of {1}", fileCount + 1, message.Files.Count);
@ -228,37 +253,35 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual
var album = _albumService.GetAlbum(file.AlbumId); var album = _albumService.GetAlbum(file.AlbumId);
var release = _releaseService.GetRelease(file.AlbumReleaseId); var release = _releaseService.GetRelease(file.AlbumReleaseId);
var tracks = _trackService.GetTracks(file.TrackIds); var tracks = _trackService.GetTracks(file.TrackIds);
var fileTrackInfo = Parser.Parser.ParseMusicPath(file.Path) ?? new ParsedTrackInfo(); var fileTrackInfo = _audioTagService.ReadTags(file.Path) ?? new ParsedTrackInfo();
var localTrack = new LocalTrack var localTrack = new LocalTrack
{ {
ExistingFile = false, ExistingFile = artist.Path.IsParentPath(file.Path),
Tracks = tracks, Tracks = tracks,
MediaInfo = null,
FileTrackInfo = fileTrackInfo, FileTrackInfo = fileTrackInfo,
MediaInfo = fileTrackInfo.MediaInfo,
Path = file.Path, Path = file.Path,
Quality = file.Quality, Quality = file.Quality,
Language = file.Language, Language = file.Language,
Artist = artist, Artist = artist,
Album = album, Album = album,
Release = release, Release = release
Size = 0
}; };
albumImportDecisions.Add(new ImportDecision<LocalTrack>(localTrack)); albumImportDecisions.Add(new ImportDecision<LocalTrack>(localTrack));
fileCount += 1; fileCount += 1;
} }
var existingFile = albumImportDecisions.First().Item.Artist.Path.IsParentPath(importAlbumId.First().Path); var downloadId = importAlbumId.Select(x => x.DownloadId).FirstOrDefault(x => x.IsNotNullOrWhiteSpace());
if (downloadId.IsNullOrWhiteSpace())
if (importAlbumId.First().DownloadId.IsNullOrWhiteSpace())
{ {
imported.AddRange(_importApprovedTracks.Import(albumImportDecisions, !existingFile, null, message.ImportMode)); imported.AddRange(_importApprovedTracks.Import(albumImportDecisions, message.ReplaceExistingFiles, null, message.ImportMode));
} }
else else
{ {
var trackedDownload = _trackedDownloadService.Find(importAlbumId.First().DownloadId); var trackedDownload = _trackedDownloadService.Find(downloadId);
var importResults = _importApprovedTracks.Import(albumImportDecisions, true, trackedDownload.DownloadItem, message.ImportMode); var importResults = _importApprovedTracks.Import(albumImportDecisions, message.ReplaceExistingFiles, trackedDownload.DownloadItem, message.ImportMode);
imported.AddRange(importResults); imported.AddRange(importResults);

View file

@ -17,10 +17,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications
public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease)
{ {
var existingRelease = localAlbumRelease.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); var existingRelease = localAlbumRelease.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored);
var existingTrackCount = existingRelease.Tracks.Value.Count(x => x.HasFile);
if (localAlbumRelease.AlbumRelease.Id != existingRelease.Id && if (localAlbumRelease.AlbumRelease.Id != existingRelease.Id &&
localAlbumRelease.TrackCount < existingRelease.Tracks.Value.Count(x => x.HasFile)) localAlbumRelease.TrackCount < existingTrackCount)
{ {
_logger.Debug("This release has fewer tracks than the existing one. Skipping {0}", localAlbumRelease); _logger.Debug($"This release has fewer tracks ({localAlbumRelease.TrackCount}) than existing {existingRelease} ({existingTrackCount}). Skipping {localAlbumRelease}");
return Decision.Reject("Has fewer tracks than existing release"); return Decision.Reject("Has fewer tracks than existing release");
} }

View file

@ -789,6 +789,7 @@
<Compile Include="MediaFiles\TrackImport\Specifications\NoMissingOrUnmatchedTracksSpecification.cs" /> <Compile Include="MediaFiles\TrackImport\Specifications\NoMissingOrUnmatchedTracksSpecification.cs" />
<Compile Include="MediaFiles\TrackImport\Specifications\ReleaseWantedSpecification.cs" /> <Compile Include="MediaFiles\TrackImport\Specifications\ReleaseWantedSpecification.cs" />
<Compile Include="MediaFiles\TrackImport\Identification\TrackGroupingService.cs" /> <Compile Include="MediaFiles\TrackImport\Identification\TrackGroupingService.cs" />
<Compile Include="MediaFiles\TrackImport\Identification\CandidateAlbumRelease.cs" />
<Compile Include="MediaFiles\TrackImport\Identification\IdentificationService.cs" /> <Compile Include="MediaFiles\TrackImport\Identification\IdentificationService.cs" />
<Compile Include="MediaFiles\TrackImport\Identification\IdentificationTestCase.cs" /> <Compile Include="MediaFiles\TrackImport\Identification\IdentificationTestCase.cs" />
<Compile Include="MediaFiles\TrackImport\Identification\Distance.cs" /> <Compile Include="MediaFiles\TrackImport\Identification\Distance.cs" />

View file

@ -4,6 +4,8 @@ using System.Linq;
using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.MediaFiles.TrackImport.Identification;
using System.IO; using System.IO;
using System; using System;
using NzbDrone.Common.Extensions;
using NzbDrone.Common;
namespace NzbDrone.Core.Parser.Model namespace NzbDrone.Core.Parser.Model
{ {
@ -33,23 +35,25 @@ namespace NzbDrone.Core.Parser.Model
public TrackMapping TrackMapping { get; set; } public TrackMapping TrackMapping { get; set; }
public Distance Distance { get; set; } public Distance Distance { get; set; }
public AlbumRelease AlbumRelease { get; set; } public AlbumRelease AlbumRelease { get; set; }
public List<LocalTrack> ExistingTracks { get; set; }
public bool NewDownload { get; set; } public bool NewDownload { get; set; }
public void PopulateMatch() public void PopulateMatch()
{ {
if (AlbumRelease != null) if (AlbumRelease != null)
{ {
LocalTracks = LocalTracks.Concat(ExistingTracks).DistinctBy(x => x.Path).ToList();
foreach (var localTrack in LocalTracks) foreach (var localTrack in LocalTracks)
{ {
localTrack.Release = AlbumRelease; localTrack.Release = AlbumRelease;
localTrack.Album = AlbumRelease.Album.Value; localTrack.Album = AlbumRelease.Album.Value;
localTrack.Artist = localTrack.Album.Artist.Value;
if (TrackMapping.Mapping.ContainsKey(localTrack)) if (TrackMapping.Mapping.ContainsKey(localTrack))
{ {
var track = TrackMapping.Mapping[localTrack].Item1; var track = TrackMapping.Mapping[localTrack].Item1;
localTrack.Tracks = new List<Track> { track }; localTrack.Tracks = new List<Track> { track };
localTrack.Distance = TrackMapping.Mapping[localTrack].Item2; localTrack.Distance = TrackMapping.Mapping[localTrack].Item2;
localTrack.Artist = localTrack.Album.Artist.Value;
} }
} }
} }

View file

@ -28,6 +28,7 @@ namespace NzbDrone.Core.Parser.Model
public Language Language { get; set; } public Language Language { get; set; }
public MediaInfoModel MediaInfo { get; set; } public MediaInfoModel MediaInfo { get; set; }
public bool ExistingFile { get; set; } public bool ExistingFile { get; set; }
public bool AdditionalFile { get; set; }
public bool SceneSource { get; set; } public bool SceneSource { get; set; }
public string ReleaseGroup { get; set; } public string ReleaseGroup { get; set; }