From a5061258419913bfee905df2236c5fbc81874655 Mon Sep 17 00:00:00 2001 From: ta264 Date: Sun, 9 Feb 2020 19:15:43 +0000 Subject: [PATCH] New: Don't require artist mapping --- .editorconfig | 2 +- .../History/Details/HistoryDetails.js | 14 +- .../ArtistMetadataProfilePopoverContent.js | 2 +- .../ImportArtist/Import/ImportArtist.js | 166 ------ .../Import/ImportArtistConnector.js | 170 ------- .../Import/ImportArtistFooter.css | 33 -- .../ImportArtist/Import/ImportArtistFooter.js | 261 ---------- .../Import/ImportArtistFooterConnector.js | 61 --- .../Import/ImportArtistHeader.css | 38 -- .../ImportArtist/Import/ImportArtistHeader.js | 96 ---- .../ImportArtist/Import/ImportArtistRow.css | 45 -- .../ImportArtist/Import/ImportArtistRow.js | 106 ---- .../Import/ImportArtistRowConnector.js | 87 ---- .../Import/ImportArtistSelected.css | 3 - .../ImportArtist/Import/ImportArtistTable.js | 195 -------- .../Import/ImportArtistTableConnector.js | 43 -- .../Import/SelectArtist/ImportArtistName.css | 19 - .../Import/SelectArtist/ImportArtistName.js | 41 -- .../SelectArtist/ImportArtistSearchResult.css | 8 - .../SelectArtist/ImportArtistSearchResult.js | 52 -- .../ImportArtistSearchResultConnector.js | 17 - .../SelectArtist/ImportArtistSelectArtist.css | 77 --- .../SelectArtist/ImportArtistSelectArtist.js | 303 ----------- .../ImportArtistSelectArtistConnector.js | 76 --- .../AddArtist/ImportArtist/ImportArtist.js | 30 -- .../SelectFolder/ImportArtistSelectFolder.css | 32 -- .../SelectFolder/ImportArtistSelectFolder.js | 147 ------ .../ImportArtistSelectFolderConnector.js | 84 ---- frontend/src/Album/Details/TrackRow.js | 12 - .../src/Album/Details/TrackRowConnector.js | 3 +- frontend/src/App/AppRoutes.js | 6 - frontend/src/App/ConnectionLostModal.js | 2 +- .../src/Artist/Details/AlbumRowConnector.js | 3 +- .../Artist/Editor/ArtistEditorConnector.js | 2 +- .../src/Artist/Editor/ArtistEditorFooter.js | 1 + frontend/src/Artist/Index/ArtistIndex.js | 1 - frontend/src/Artist/NoArtist.js | 6 +- frontend/src/Commands/commandNames.js | 1 + .../Components/Form/RootFolderSelectInput.js | 28 +- .../Form/RootFolderSelectInputConnector.js | 32 +- .../Form/RootFolderSelectInputOption.js | 6 +- .../RootFolderSelectInputSelectedValue.js | 6 +- frontend/src/Components/Form/SelectInput.js | 2 +- .../Page/Sidebar/Messages/Message.js | 2 + .../Components/Page/Sidebar/PageSidebar.js | 4 - frontend/src/Components/SignalRConnector.js | 12 +- frontend/src/Helpers/Props/icons.js | 3 + .../InteractiveImportModalContent.js | 4 +- .../Interactive/InteractiveImportRow.css | 2 +- .../Interactive/InteractiveImportRow.js | 12 +- .../Organize/OrganizePreviewModalContent.js | 8 - .../src/Retag/RetagPreviewModalContent.js | 9 +- frontend/src/RootFolder/RootFolderRow.css | 27 - frontend/src/RootFolder/RootFolderRow.js | 81 --- .../src/RootFolder/RootFolderRowConnector.js | 13 - frontend/src/RootFolder/RootFolders.js | 81 --- .../src/RootFolder/RootFoldersConnector.js | 46 -- frontend/src/Search/AddNewItemConnector.js | 2 +- .../ImportLists/ImportListsConnector.js | 3 +- .../MediaManagement/MediaManagement.js | 9 +- .../RootFolder/AddRootFolder.css | 7 - .../RootFolder/AddRootFolder.js | 71 --- .../RootFolder/AddRootFolderConnector.js | 13 - .../RootFolder/EditRootFolderModal.js | 25 + .../EditRootFolderModalConnector.js | 58 +++ .../RootFolder/EditRootFolderModalContent.css | 15 + .../RootFolder/EditRootFolderModalContent.js | 217 ++++++++ .../EditRootFolderModalContentConnector.js | 84 ++++ .../MediaManagement/RootFolder/RootFolder.css | 19 + .../MediaManagement/RootFolder/RootFolder.js | 116 +++++ .../RootFolder/RootFolders.css | 20 + .../MediaManagement/RootFolder/RootFolders.js | 105 ++++ .../RootFolder/RootFoldersConnector.js | 62 +++ .../src/Store/Actions/Settings/rootFolders.js | 76 +++ .../src/Store/Actions/importArtistActions.js | 327 ------------ frontend/src/Store/Actions/index.js | 4 - .../Store/Actions/interactiveImportActions.js | 6 +- .../src/Store/Actions/rootFolderActions.js | 97 ---- frontend/src/Store/Actions/settingsActions.js | 5 + frontend/src/Store/Actions/trackActions.js | 5 - .../Editor/TrackFileEditorModalContent.js | 4 +- .../TrackFileEditorModalContentConnector.js | 2 +- .../TrackFile/Editor/TrackFileEditorRow.js | 6 +- .../src/UnmappedFiles/UnmappedFilesTable.js | 17 +- .../UnmappedFilesTableConnector.js | 13 + src/Lidarr.Api.V1/Albums/AlbumModule.cs | 4 +- src/Lidarr.Api.V1/Artist/ArtistModule.cs | 4 +- .../FileSystem/FileSystemModule.cs | 1 - .../ImportLists/ImportListModule.cs | 4 +- .../ManualImport/ManualImportModule.cs | 1 - .../ManualImport/ManualImportResource.cs | 2 - .../RootFolders/RootFolderModule.cs | 48 +- .../RootFolders/RootFolderResource.cs | 25 +- .../TrackFiles/TrackFileResource.cs | 2 - .../Tracks/RetagTrackResource.cs | 4 +- .../Extensions/IEnumerableExtensions.cs | 10 + .../DiskSpace/DiskSpaceServiceFixture.cs | 43 +- .../DiskScanServiceTests/ScanFixture.cs | 151 ++---- .../DownloadedTracksImportServiceFixture.cs | 12 +- .../MediaFiles/MediaFileRepositoryFixture.cs | 1 - .../MediaFileServiceTests/FilterFixture.cs | 67 ++- .../MediaFileTableCleanupServiceFixture.cs | 14 +- .../Identification/AlbumDistanceFixture.cs | 36 +- ...sFixture.cs => CandidateServiceFixture.cs} | 35 +- .../IdentificationServiceFixture.cs | 29 +- .../Identification/TrackDistanceFixture.cs | 12 +- .../TrackImport/ImportDecisionMakerFixture.cs | 71 ++- .../MusicTests/AddArtistFixture.cs | 86 ++++ .../MusicTests/RefreshArtistServiceFixture.cs | 9 +- .../Metadata/MetadataProfileServiceFixture.cs | 53 +- .../Profiles/ProfileServiceFixture.cs | 53 +- .../RootFolderServiceFixture.cs | 44 -- .../039_add_root_folder_add_defaults.cs | 81 +++ .../DiskSpace/DiskSpaceService.cs | 20 +- src/NzbDrone.Core/History/HistoryService.cs | 2 - .../Definitions/SearchCriteriaBase.cs | 6 + .../MediaFiles/AudioTagService.cs | 2 +- .../Commands/RescanArtistCommand.cs | 23 - .../Commands/RescanFoldersCommand.cs | 26 + .../MediaFiles/DiskScanService.cs | 170 +++---- .../DownloadedTracksImportService.cs | 39 +- .../MediaFiles/MediaFileRepository.cs | 15 +- .../MediaFiles/MediaFileService.cs | 53 +- .../MediaFileTableCleanupService.cs | 6 +- .../MediaFiles/RenameTrackFileService.cs | 4 +- .../MediaFiles/RetagTrackFilePreview.cs | 2 +- .../Identification/CandidateService.cs | 297 +++++++++++ .../Identification/DistanceCalcualtor.cs | 205 ++++++++ .../Identification/IdentificationService.cs | 471 +++--------------- .../Identification/TrackGroupingService.cs | 3 + .../TrackImport/ImportApprovedTracks.cs | 223 +++++++-- .../TrackImport/ImportArtistDefaults.cs | 15 + .../TrackImport/ImportDecisionMaker.cs | 110 ++-- .../TrackImport/Manual/ManualImportItem.cs | 1 - .../TrackImport/Manual/ManualImportService.cs | 76 ++- .../AlbumUpgradeSpecification.cs | 5 +- .../ArtistPathInRootFolderSpecification.cs | 40 ++ .../Specifications/UpgradeSpecification.cs | 6 + .../MetadataSource/ISearchForNewAlbum.cs | 1 + .../MetadataSource/SkyHook/SkyHookProxy.cs | 31 +- .../Commands/BulkRefreshArtistCommand.cs | 25 + .../Music/Handlers/ArtistScannedHandler.cs | 1 - .../Music/Services/AddArtistService.cs | 28 +- .../Music/Services/AlbumEditedService.cs | 3 +- .../Music/Services/AlbumService.cs | 4 - .../Music/Services/ArtistService.cs | 5 +- .../Music/Services/RefreshArtistService.cs | 79 +-- .../Music/Utilities/AddArtistValidator.cs | 4 +- src/NzbDrone.Core/Parser/Parser.cs | 15 +- .../Metadata/MetadataProfileService.cs | 7 +- .../Qualities/QualityProfileService.cs | 13 +- src/NzbDrone.Core/RootFolders/RootFolder.cs | 8 +- .../RootFolders/RootFolderRepository.cs | 7 + .../RootFolders/RootFolderService.cs | 108 ++-- src/NzbDrone.Core/Tags/TagDetails.cs | 3 +- src/NzbDrone.Core/Tags/TagService.cs | 14 +- ...or.cs => QualityProfileExistsValidator.cs} | 4 +- .../ApiTests/FileSystemFixture.cs | 1 - .../ApiTests/WantedFixture.cs | 16 + 159 files changed, 2918 insertions(+), 4192 deletions(-) delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js delete mode 100644 frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js delete mode 100644 frontend/src/AddArtist/ImportArtist/ImportArtist.js delete mode 100644 frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css delete mode 100644 frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js delete mode 100644 frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js delete mode 100644 frontend/src/RootFolder/RootFolderRow.css delete mode 100644 frontend/src/RootFolder/RootFolderRow.js delete mode 100644 frontend/src/RootFolder/RootFolderRowConnector.js delete mode 100644 frontend/src/RootFolder/RootFolders.js delete mode 100644 frontend/src/RootFolder/RootFoldersConnector.js delete mode 100644 frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css delete mode 100644 frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js delete mode 100644 frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModal.js create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalConnector.js create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContentConnector.js create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/RootFolders.js create mode 100644 frontend/src/Settings/MediaManagement/RootFolder/RootFoldersConnector.js create mode 100644 frontend/src/Store/Actions/Settings/rootFolders.js delete mode 100644 frontend/src/Store/Actions/importArtistActions.js delete mode 100644 frontend/src/Store/Actions/rootFolderActions.js rename src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/{GetCandidatesFixture.cs => CandidateServiceFixture.cs} (83%) create mode 100644 src/NzbDrone.Core/Datastore/Migration/039_add_root_folder_add_defaults.cs delete mode 100644 src/NzbDrone.Core/MediaFiles/Commands/RescanArtistCommand.cs create mode 100644 src/NzbDrone.Core/MediaFiles/Commands/RescanFoldersCommand.cs create mode 100644 src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs create mode 100644 src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalcualtor.cs create mode 100644 src/NzbDrone.Core/MediaFiles/TrackImport/ImportArtistDefaults.cs create mode 100644 src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ArtistPathInRootFolderSpecification.cs create mode 100644 src/NzbDrone.Core/Music/Commands/BulkRefreshArtistCommand.cs rename src/NzbDrone.Core/Validation/{ProfileExistsValidator.cs => QualityProfileExistsValidator.cs} (80%) diff --git a/.editorconfig b/.editorconfig index 624514a98..c383f7beb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ # editorconfig.org root = true -[*.{cs}] +[*.cs] charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index ca6e49d22..528b65907 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -244,9 +244,7 @@ function HistoryDetails(props) { if (eventType === 'trackFileRenamed') { const { sourcePath, - sourceRelativePath, - path, - relativePath + path } = data; return ( @@ -256,20 +254,10 @@ function HistoryDetails(props) { data={sourcePath} /> - - - - ); } diff --git a/frontend/src/AddArtist/ArtistMetadataProfilePopoverContent.js b/frontend/src/AddArtist/ArtistMetadataProfilePopoverContent.js index 134f1eb1f..d7c663674 100644 --- a/frontend/src/AddArtist/ArtistMetadataProfilePopoverContent.js +++ b/frontend/src/AddArtist/ArtistMetadataProfilePopoverContent.js @@ -3,7 +3,7 @@ import React from 'react'; function ArtistMetadataProfilePopoverContent() { return (
- Select 'None' to only include items manually added via search + Select 'None' to only include items manually added via search or that match files on disk
); } diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js deleted file mode 100644 index 6c49a8fce..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js +++ /dev/null @@ -1,166 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import ImportArtistTableConnector from './ImportArtistTableConnector'; -import ImportArtistFooterConnector from './ImportArtistFooterConnector'; - -class ImportArtist extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - scroller: null - }; - } - - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - } - - // - // Listeners - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState, { parseIds: false }); - } - - onSelectAllChange = ({ value }) => { - // Only select non-dupes - this.setState(selectAll(this.state.selectedState, value)); - } - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - } - - onRemoveSelectedStateItem = (id) => { - this.setState((state) => { - const selectedState = Object.assign({}, state.selectedState); - delete selectedState[id]; - - return { - ...state, - selectedState - }; - }); - } - - onInputChange = ({ name, value }) => { - this.props.onInputChange(this.getSelectedIds(), name, value); - } - - onImportPress = () => { - this.props.onImportPress(this.getSelectedIds()); - } - - // - // Render - - render() { - const { - rootFolderId, - path, - rootFoldersFetching, - rootFoldersPopulated, - rootFoldersError, - unmappedFolders, - showMetadataProfile - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - scroller - } = this.state; - - return ( - - - { - rootFoldersFetching && !rootFoldersPopulated && - - } - - { - !rootFoldersFetching && !!rootFoldersError && -
Unable to load root folders
- } - - { - !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length && -
- All artist in {path} have been imported -
- } - - { - !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && scroller && - - } -
- - { - !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && - - } -
- ); - } -} - -ImportArtist.propTypes = { - rootFolderId: PropTypes.number.isRequired, - path: PropTypes.string, - rootFoldersFetching: PropTypes.bool.isRequired, - rootFoldersPopulated: PropTypes.bool.isRequired, - rootFoldersError: PropTypes.object, - unmappedFolders: PropTypes.arrayOf(PropTypes.object), - items: PropTypes.arrayOf(PropTypes.object), - showMetadataProfile: PropTypes.bool.isRequired, - onInputChange: PropTypes.func.isRequired, - onImportPress: PropTypes.func.isRequired -}; - -ImportArtist.defaultProps = { - unmappedFolders: [] -}; - -export default ImportArtist; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js deleted file mode 100644 index 65bcc9645..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js +++ /dev/null @@ -1,170 +0,0 @@ -/* eslint max-params: 0 */ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setImportArtistValue, importArtist, clearImportArtist } from 'Store/Actions/importArtistActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import { setAddDefault } from 'Store/Actions/searchActions'; -import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; -import ImportArtist from './ImportArtist'; - -function createMapStateToProps() { - return createSelector( - (state, { match }) => match, - (state) => state.rootFolders, - (state) => state.addArtist, - (state) => state.importArtist, - (state) => state.settings.qualityProfiles, - (state) => state.settings.metadataProfiles, - ( - match, - rootFolders, - addArtist, - importArtistState, - qualityProfiles, - metadataProfiles - ) => { - const { - isFetching: rootFoldersFetching, - isPopulated: rootFoldersPopulated, - error: rootFoldersError, - items - } = rootFolders; - - const rootFolderId = parseInt(match.params.rootFolderId); - - const result = { - rootFolderId, - rootFoldersFetching, - rootFoldersPopulated, - rootFoldersError, - qualityProfiles: qualityProfiles.items, - metadataProfiles: metadataProfiles.items, - showMetadataProfile: metadataProfiles.items.length > 1, - defaultQualityProfileId: addArtist.defaults.qualityProfileId, - defaultMetadataProfileId: addArtist.defaults.metadataProfileId - }; - - if (items.length) { - const rootFolder = _.find(items, { id: rootFolderId }); - - return { - ...result, - ...rootFolder, - items: importArtistState.items - }; - } - - return result; - } - ); -} - -const mapDispatchToProps = { - dispatchSetImportArtistValue: setImportArtistValue, - dispatchImportArtist: importArtist, - dispatchClearImportArtist: clearImportArtist, - dispatchFetchRootFolders: fetchRootFolders, - dispatchSetAddDefault: setAddDefault -}; - -class ImportArtistConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - qualityProfiles, - metadataProfiles, - defaultQualityProfileId, - defaultMetadataProfileId, - dispatchFetchRootFolders, - dispatchSetAddDefault - } = this.props; - - if (!this.props.rootFoldersPopulated) { - dispatchFetchRootFolders(); - } - - let setDefaults = false; - const setDefaultPayload = {}; - - if ( - !defaultQualityProfileId || - !qualityProfiles.some((p) => p.id === defaultQualityProfileId) - ) { - setDefaults = true; - setDefaultPayload.qualityProfileId = qualityProfiles[0].id; - } - - if ( - !defaultMetadataProfileId || - !metadataProfiles.some((p) => p.id === defaultMetadataProfileId) - ) { - setDefaults = true; - setDefaultPayload.metadataProfileId = metadataProfiles[0].id; - } - - if (setDefaults) { - dispatchSetAddDefault(setDefaultPayload); - } - } - - componentWillUnmount() { - this.props.dispatchClearImportArtist(); - } - - // - // Listeners - - onInputChange = (ids, name, value) => { - this.props.dispatchSetAddDefault({ [name]: value }); - - ids.forEach((id) => { - this.props.dispatchSetImportArtistValue({ - id, - [name]: value - }); - }); - } - - onImportPress = (ids) => { - this.props.dispatchImportArtist({ ids }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -const routeMatchShape = createRouteMatchShape({ - rootFolderId: PropTypes.string.isRequired -}); - -ImportArtistConnector.propTypes = { - match: routeMatchShape.isRequired, - rootFoldersPopulated: PropTypes.bool.isRequired, - qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, - metadataProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, - defaultQualityProfileId: PropTypes.number.isRequired, - defaultMetadataProfileId: PropTypes.number.isRequired, - dispatchSetImportArtistValue: PropTypes.func.isRequired, - dispatchImportArtist: PropTypes.func.isRequired, - dispatchClearImportArtist: PropTypes.func.isRequired, - dispatchFetchRootFolders: PropTypes.func.isRequired, - dispatchSetAddDefault: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistConnector); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css deleted file mode 100644 index 616aeaf3c..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css +++ /dev/null @@ -1,33 +0,0 @@ -.inputContainer { - margin-right: 20px; - min-width: 150px; -} - -.label { - margin-bottom: 3px; - font-weight: bold; -} - -.importButtonContainer { - display: flex; - align-items: center; -} - -.importButton { - composes: button from '~Components/Link/SpinnerButton.css'; - - height: 35px; -} - -.loadingButton { - composes: importButton; - - margin-left: 10px; -} - -.loading { - composes: loading from '~Components/Loading/LoadingIndicator.css'; - - margin: 0 10px 0 12px; - text-align: left; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js deleted file mode 100644 index a0feaad89..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js +++ /dev/null @@ -1,261 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { inputTypes, kinds } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import CheckInput from 'Components/Form/CheckInput'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import PageContentFooter from 'Components/Page/PageContentFooter'; -import styles from './ImportArtistFooter.css'; - -const MIXED = 'mixed'; - -class ImportArtistFooter extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - defaultMonitor, - defaultQualityProfileId, - defaultMetadataProfileId, - defaultAlbumFolder - } = props; - - this.state = { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - metadataProfileId: defaultMetadataProfileId, - albumFolder: defaultAlbumFolder - }; - } - - componentDidUpdate(prevProps, prevState) { - const { - defaultMonitor, - defaultQualityProfileId, - defaultMetadataProfileId, - defaultAlbumFolder, - isMonitorMixed, - isQualityProfileIdMixed, - isMetadataProfileIdMixed, - isAlbumFolderMixed - } = this.props; - - const { - monitor, - qualityProfileId, - metadataProfileId, - albumFolder - } = this.state; - - const newState = {}; - - if (isMonitorMixed && monitor !== MIXED) { - newState.monitor = MIXED; - } else if (!isMonitorMixed && monitor !== defaultMonitor) { - newState.monitor = defaultMonitor; - } - - if (isQualityProfileIdMixed && qualityProfileId !== MIXED) { - newState.qualityProfileId = MIXED; - } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) { - newState.qualityProfileId = defaultQualityProfileId; - } - - if (isMetadataProfileIdMixed && metadataProfileId !== MIXED) { - newState.metadataProfileId = MIXED; - } else if (!isMetadataProfileIdMixed && metadataProfileId !== defaultMetadataProfileId) { - newState.metadataProfileId = defaultMetadataProfileId; - } - - if (isAlbumFolderMixed && albumFolder != null) { - newState.albumFolder = null; - } else if (!isAlbumFolderMixed && albumFolder !== defaultAlbumFolder) { - newState.albumFolder = defaultAlbumFolder; - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.setState({ [name]: value }); - this.props.onInputChange({ name, value }); - } - - // - // Render - - render() { - const { - selectedCount, - isImporting, - isLookingUpArtist, - isMonitorMixed, - isQualityProfileIdMixed, - isMetadataProfileIdMixed, - hasUnsearchedItems, - showMetadataProfile, - onImportPress, - onLookupPress, - onCancelLookupPress - } = this.props; - - const { - monitor, - qualityProfileId, - metadataProfileId, - albumFolder - } = this.state; - - return ( - -
-
- Monitor -
- - -
- -
-
- Quality Profile -
- - -
- - { - showMetadataProfile && -
-
- Metadata Profile -
- - -
- } - -
-
- Album Folder -
- - -
- -
-
-   -
- -
- - Import {selectedCount} Artist(s) - - - { - isLookingUpArtist && - - } - - { - hasUnsearchedItems && - - } - - { - isLookingUpArtist && - - } - - { - isLookingUpArtist && - 'Processing Folders' - } -
-
-
- ); - } -} - -ImportArtistFooter.propTypes = { - selectedCount: PropTypes.number.isRequired, - isImporting: PropTypes.bool.isRequired, - isLookingUpArtist: PropTypes.bool.isRequired, - defaultMonitor: PropTypes.string.isRequired, - defaultQualityProfileId: PropTypes.number, - defaultMetadataProfileId: PropTypes.number, - defaultAlbumFolder: PropTypes.bool.isRequired, - isMonitorMixed: PropTypes.bool.isRequired, - isQualityProfileIdMixed: PropTypes.bool.isRequired, - isMetadataProfileIdMixed: PropTypes.bool.isRequired, - isAlbumFolderMixed: PropTypes.bool.isRequired, - hasUnsearchedItems: PropTypes.bool.isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - onInputChange: PropTypes.func.isRequired, - onImportPress: PropTypes.func.isRequired, - onLookupPress: PropTypes.func.isRequired, - onCancelLookupPress: PropTypes.func.isRequired -}; - -export default ImportArtistFooter; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js deleted file mode 100644 index 873d13b28..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js +++ /dev/null @@ -1,61 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import ImportArtistFooter from './ImportArtistFooter'; -import { lookupUnsearchedArtist, cancelLookupArtist } from 'Store/Actions/importArtistActions'; - -function isMixed(items, selectedIds, defaultValue, key) { - return _.some(items, (artist) => { - return selectedIds.indexOf(artist.id) > -1 && artist[key] !== defaultValue; - }); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.addArtist, - (state) => state.importArtist, - (state, { selectedIds }) => selectedIds, - (addArtist, importArtist, selectedIds) => { - const { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - metadataProfileId: defaultMetadataProfileId, - albumFolder: defaultAlbumFolder - } = addArtist.defaults; - - const { - isLookingUpArtist, - isImporting, - items - } = importArtist; - - const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); - const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); - const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId'); - const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder'); - const hasUnsearchedItems = !isLookingUpArtist && items.some((item) => !item.isPopulated); - - return { - selectedCount: selectedIds.length, - isLookingUpArtist, - isImporting, - defaultMonitor, - defaultQualityProfileId, - defaultMetadataProfileId, - defaultAlbumFolder, - isMonitorMixed, - isQualityProfileIdMixed, - isMetadataProfileIdMixed, - isAlbumFolderMixed, - hasUnsearchedItems - }; - } - ); -} - -const mapDispatchToProps = { - onLookupPress: lookupUnsearchedArtist, - onCancelLookupPress: cancelLookupArtist -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistFooter); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css deleted file mode 100644 index 52b918403..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css +++ /dev/null @@ -1,38 +0,0 @@ -.folder { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 1 0 200px; -} - -.monitor { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 200px; - min-width: 185px; -} - -.qualityProfile, -.metadataProfile { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 250px; - min-width: 170px; -} - -.albumFolder { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 150px; - min-width: 120px; -} - -.artist { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 1 400px; - min-width: 300px; -} - -.detailsIcon { - margin-left: 8px; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js deleted file mode 100644 index fb0a01cb7..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js +++ /dev/null @@ -1,96 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons, tooltipPositions } from 'Helpers/Props'; -import Icon from 'Components/Icon'; -import Popover from 'Components/Tooltip/Popover'; -import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; -import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; -import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; -// import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent'; -import styles from './ImportArtistHeader.css'; - -function ImportArtistHeader(props) { - const { - showMetadataProfile, - allSelected, - allUnselected, - onSelectAllChange - } = props; - - return ( - - - - - Folder - - - - Monitor - - - } - title="Monitoring Options" - body={} - position={tooltipPositions.RIGHT} - /> - - - - Quality Profile - - - { - showMetadataProfile && - - Metadata Profile - - } - - - Album Folder - - - - Artist - - - ); -} - -ImportArtistHeader.propTypes = { - showMetadataProfile: PropTypes.bool.isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - onSelectAllChange: PropTypes.func.isRequired -}; - -export default ImportArtistHeader; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css deleted file mode 100644 index f5e6ed2e5..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css +++ /dev/null @@ -1,45 +0,0 @@ -.selectInput { - composes: input from '~Components/Form/CheckInput.css'; -} - -.folder { - composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 1 0 200px; - line-height: 36px; -} - -.monitor { - composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 200px; - min-width: 185px; -} - -.qualityProfile, -.metadataProfile { - composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 250px; - min-width: 170px; -} - -.albumFolder { - composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 150px; - min-width: 120px; -} - -.artist { - composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; - - flex: 0 1 400px; - min-width: 300px; -} - -.hideMetadataProfile { - composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; - - display: none; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js deleted file mode 100644 index 338cab5fe..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js +++ /dev/null @@ -1,106 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { inputTypes } from 'Helpers/Props'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import ImportArtistSelectArtistConnector from './SelectArtist/ImportArtistSelectArtistConnector'; -import styles from './ImportArtistRow.css'; - -function ImportArtistRow(props) { - const { - id, - monitor, - qualityProfileId, - metadataProfileId, - albumFolder, - selectedArtist, - isExistingArtist, - showMetadataProfile, - isSelected, - onSelectedChange, - onInputChange - } = props; - - return ( - <> - - - - {id} - - - - - - - - - - - - - - - - - - - - - - - ); -} - -ImportArtistRow.propTypes = { - id: PropTypes.string.isRequired, - monitor: PropTypes.string.isRequired, - qualityProfileId: PropTypes.number.isRequired, - metadataProfileId: PropTypes.number.isRequired, - albumFolder: PropTypes.bool.isRequired, - selectedArtist: PropTypes.object, - isExistingArtist: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired, - onInputChange: PropTypes.func.isRequired -}; - -ImportArtistRow.defaultsProps = { - items: [] -}; - -export default ImportArtistRow; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js deleted file mode 100644 index 2480bfdb6..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js +++ /dev/null @@ -1,87 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setImportArtistValue } from 'Store/Actions/importArtistActions'; -import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import ImportArtistRow from './ImportArtistRow'; - -function createImportArtistItemSelector() { - return createSelector( - (state, { id }) => id, - (state) => state.importArtist.items, - (id, items) => { - return _.find(items, { id }) || {}; - } - ); -} - -function createMapStateToProps() { - return createSelector( - createImportArtistItemSelector(), - createAllArtistSelector(), - (item, artist) => { - const selectedArtist = item && item.selectedArtist; - const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId }); - - return { - ...item, - isExistingArtist - }; - } - ); -} - -const mapDispatchToProps = { - setImportArtistValue -}; - -class ImportArtistRowConnector extends Component { - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setImportArtistValue({ - id: this.props.id, - [name]: value - }); - } - - // - // Render - - render() { - // Don't show the row until we have the information we require for it. - - const { - items, - monitor, - albumFolder - } = this.props; - - if (!items || !monitor || !albumFolder == null) { - return null; - } - - return ( - - ); - } -} - -ImportArtistRowConnector.propTypes = { - rootFolderId: PropTypes.number.isRequired, - id: PropTypes.string.isRequired, - monitor: PropTypes.string, - albumFolder: PropTypes.bool, - items: PropTypes.arrayOf(PropTypes.object), - setImportArtistValue: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRowConnector); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css deleted file mode 100644 index 51fe4ce39..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css +++ /dev/null @@ -1,3 +0,0 @@ -.input { - composes: input from '~Components/Form/CheckInput.css'; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js deleted file mode 100644 index 4b31b1e0e..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js +++ /dev/null @@ -1,195 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import VirtualTable from 'Components/Table/VirtualTable'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import ImportArtistHeader from './ImportArtistHeader'; -import ImportArtistRowConnector from './ImportArtistRowConnector'; - -class ImportArtistTable extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - unmappedFolders, - defaultMonitor, - defaultQualityProfileId, - defaultMetadataProfileId, - defaultAlbumFolder, - onArtistLookup, - onSetImportArtistValue - } = this.props; - - const values = { - monitor: defaultMonitor, - qualityProfileId: defaultQualityProfileId, - metadataProfileId: defaultMetadataProfileId, - albumFolder: defaultAlbumFolder - }; - - unmappedFolders.forEach((unmappedFolder) => { - const id = unmappedFolder.name; - - onArtistLookup(id, unmappedFolder.path); - - onSetImportArtistValue({ - id, - ...values - }); - }); - } - - // This isn't great, but it's the most reliable way to ensure the items - // are checked off even if they aren't actually visible since the cells - // are virtualized. - - componentDidUpdate(prevProps) { - const { - items, - selectedState, - onSelectedChange, - onRemoveSelectedStateItem - } = this.props; - - prevProps.items.forEach((prevItem) => { - const { - id - } = prevItem; - - const item = _.find(items, { id }); - - if (!item) { - onRemoveSelectedStateItem(id); - return; - } - - const selectedArtist = item.selectedArtist; - const isSelected = selectedState[id]; - - const isExistingArtist = !!selectedArtist && - _.some(prevProps.allArtists, { foreignArtistId: selectedArtist.foreignArtistId }); - - // Props doesn't have a selected artist or - // the selected artist is an existing artist. - if ((!selectedArtist && prevItem.selectedArtist) || (isExistingArtist && !prevItem.selectedArtist)) { - onSelectedChange({ id, value: false }); - - return; - } - - // State is selected, but a artist isn't selected or - // the selected artist is an existing artist. - if (isSelected && (!selectedArtist || isExistingArtist)) { - onSelectedChange({ id, value: false }); - - return; - } - - // A artist is being selected that wasn't previously selected. - if (selectedArtist && selectedArtist !== prevItem.selectedArtist) { - onSelectedChange({ id, value: true }); - - return; - } - }); - } - - // - // Control - - rowRenderer = ({ key, rowIndex, style }) => { - const { - rootFolderId, - items, - selectedState, - showMetadataProfile, - onSelectedChange - } = this.props; - - const item = items[rowIndex]; - - return ( - - - - ); - } - - // - // Render - - render() { - const { - items, - allSelected, - allUnselected, - isSmallScreen, - showMetadataProfile, - scroller, - selectedState, - onSelectAllChange - } = this.props; - - if (!items.length) { - return null; - } - - return ( - - } - selectedState={selectedState} - /> - ); - } -} - -ImportArtistTable.propTypes = { - rootFolderId: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object), - unmappedFolders: PropTypes.arrayOf(PropTypes.object), - defaultMonitor: PropTypes.string.isRequired, - defaultQualityProfileId: PropTypes.number, - defaultMetadataProfileId: PropTypes.number, - defaultAlbumFolder: PropTypes.bool.isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - selectedState: PropTypes.object.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - allArtists: PropTypes.arrayOf(PropTypes.object), - scroller: PropTypes.instanceOf(Element).isRequired, - showMetadataProfile: PropTypes.bool.isRequired, - scrollTop: PropTypes.number.isRequired, - onSelectAllChange: PropTypes.func.isRequired, - onSelectedChange: PropTypes.func.isRequired, - onRemoveSelectedStateItem: PropTypes.func.isRequired, - onArtistLookup: PropTypes.func.isRequired, - onSetImportArtistValue: PropTypes.func.isRequired -}; - -export default ImportArtistTable; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js deleted file mode 100644 index fd7bf4fe2..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js +++ /dev/null @@ -1,43 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; -import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import ImportArtistTable from './ImportArtistTable'; - -function createMapStateToProps() { - return createSelector( - (state) => state.addArtist, - (state) => state.importArtist, - (state) => state.app.dimensions, - createAllArtistSelector(), - (addArtist, importArtist, dimensions, allArtists) => { - return { - defaultMonitor: addArtist.defaults.monitor, - defaultQualityProfileId: addArtist.defaults.qualityProfileId, - defaultMetadataProfileId: addArtist.defaults.metadataProfileId, - defaultAlbumFolder: addArtist.defaults.albumFolder, - items: importArtist.items, - isSmallScreen: dimensions.isSmallScreen, - allArtists - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onArtistLookup(name, path) { - dispatch(queueLookupArtist({ - name, - path, - term: name - })); - }, - - onSetImportArtistValue(values) { - dispatch(setImportArtistValue(values)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ImportArtistTable); diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css deleted file mode 100644 index fc86c41d1..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css +++ /dev/null @@ -1,19 +0,0 @@ -.artistNameContainer { - display: flex; - align-items: center; - flex: 0 1 auto; - overflow: hidden; -} - -.artistName { - @add-mixin truncate; -} - -.disambiguation { - margin-right: 5px; - color: $disabledColor; -} - -.existing { - margin-left: 5px; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js deleted file mode 100644 index 1d9fb21b7..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js +++ /dev/null @@ -1,41 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { kinds } from 'Helpers/Props'; -import Label from 'Components/Label'; -import styles from './ImportArtistName.css'; - -function ImportArtistName(props) { - const { - artistName, - disambiguation, - isExistingArtist - } = props; - - return ( -
-
- {artistName} -
-
- {disambiguation} -
- - { - isExistingArtist && - - } -
- ); -} - -ImportArtistName.propTypes = { - artistName: PropTypes.string.isRequired, - disambiguation: PropTypes.string, - isExistingArtist: PropTypes.bool.isRequired -}; - -export default ImportArtistName; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css deleted file mode 100644 index f7bc065b5..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css +++ /dev/null @@ -1,8 +0,0 @@ -.artist { - padding: 10px 20px; - width: 100%; - - &:hover { - background-color: $menuItemHoverBackgroundColor; - } -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js deleted file mode 100644 index aa489f0fb..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Link from 'Components/Link/Link'; -import ImportArtistName from './ImportArtistName'; -import styles from './ImportArtistSearchResult.css'; - -class ImportArtistSearchResult extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.foreignArtistId); - } - - // - // Render - - render() { - const { - artistName, - disambiguation, - // year, - isExistingArtist - } = this.props; - - return ( - - - - ); - } -} - -ImportArtistSearchResult.propTypes = { - foreignArtistId: PropTypes.string.isRequired, - artistName: PropTypes.string.isRequired, - disambiguation: PropTypes.string, - // year: PropTypes.number.isRequired, - isExistingArtist: PropTypes.bool.isRequired, - onPress: PropTypes.func.isRequired -}; - -export default ImportArtistSearchResult; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js deleted file mode 100644 index cdbcc03b3..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector'; -import ImportArtistSearchResult from './ImportArtistSearchResult'; - -function createMapStateToProps() { - return createSelector( - createExistingArtistSelector(), - (isExistingArtist) => { - return { - isExistingArtist - }; - } - ); -} - -export default connect(createMapStateToProps)(ImportArtistSearchResult); diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css deleted file mode 100644 index 6bdfd093e..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css +++ /dev/null @@ -1,77 +0,0 @@ -.button { - composes: link from '~Components/Link/Link.css'; - - display: flex; - align-items: center; - padding: 6px 16px; - width: 100%; - height: 35px; - border: 1px solid $inputBorderColor; - border-radius: 4px; - background-color: $white; - box-shadow: inset 0 1px 1px $inputBoxShadowColor; -} - -.loading { - display: inline-block; -} - -.warningIcon { - margin-right: 8px; -} - -.existing { - margin-left: 5px; -} - -.dropdownArrowContainer { - flex: 1 0 auto; - margin-left: 5px; - text-align: right; -} - -.contentContainer { - z-index: $popperZIndex; - margin-top: 4px; - /* 400px container witdh with 8px padding on each side */ - width: 384px; -} - -.content { - padding: 4px; - border: 1px solid $inputBorderColor; - border-radius: 4px; - background-color: $white; -} - -.searchContainer { - display: flex; -} - -.searchIconContainer { - width: 58px; - border: 1px solid $inputBorderColor; - border-right: none; - border-radius: 4px; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - background-color: #edf1f2; - text-align: center; - line-height: 33px; -} - -.searchInput { - composes: input from '~Components/Form/TextInput.css'; - - border-radius: 0; -} - -.results { - @add-mixin scrollbar; - @add-mixin scrollbarTrack; - @add-mixin scrollbarThumb; - - overflow-x: hidden; - overflow-y: scroll; - max-height: 165px; -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js deleted file mode 100644 index 68c448d1c..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js +++ /dev/null @@ -1,303 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Manager, Popper, Reference } from 'react-popper'; -import getUniqueElememtId from 'Utilities/getUniqueElementId'; -import { icons, kinds } from 'Helpers/Props'; -import Icon from 'Components/Icon'; -import Portal from 'Components/Portal'; -import FormInputButton from 'Components/Form/FormInputButton'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import TextInput from 'Components/Form/TextInput'; -import ImportArtistSearchResultConnector from './ImportArtistSearchResultConnector'; -import ImportArtistName from './ImportArtistName'; -import styles from './ImportArtistSelectArtist.css'; - -class ImportArtistSelectArtist extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._artistLookupTimeout = null; - this._scheduleUpdate = null; - this._buttonId = getUniqueElememtId(); - this._contentId = getUniqueElememtId(); - - this.state = { - term: props.id, - isOpen: false - }; - } - - componentDidUpdate() { - if (this._scheduleUpdate) { - this._scheduleUpdate(); - } - } - - // - // Control - - _addListener() { - window.addEventListener('click', this.onWindowClick); - } - - _removeListener() { - window.removeEventListener('click', this.onWindowClick); - } - - // - // Listeners - - onWindowClick = (event) => { - const button = document.getElementById(this._buttonId); - const content = document.getElementById(this._contentId); - - if (!button || !content) { - return; - } - - if ( - !button.contains(event.target) && - !content.contains(event.target) && - this.state.isOpen - ) { - this.setState({ isOpen: false }); - this._removeListener(); - } - } - - onPress = () => { - if (this.state.isOpen) { - this._removeListener(); - } else { - this._addListener(); - } - - this.setState({ isOpen: !this.state.isOpen }); - } - - onSearchInputChange = ({ value }) => { - if (this._artistLookupTimeout) { - clearTimeout(this._artistLookupTimeout); - } - - this.setState({ term: value }, () => { - this._artistLookupTimeout = setTimeout(() => { - this.props.onSearchInputChange(value); - }, 200); - }); - } - - onRefreshPress = () => { - this.props.onSearchInputChange(this.state.term); - } - - onArtistSelect = (foreignArtistId) => { - this.setState({ isOpen: false }); - - this.props.onArtistSelect(foreignArtistId); - } - - // - // Render - - render() { - const { - selectedArtist, - isExistingArtist, - isFetching, - isPopulated, - error, - items, - isQueued, - isLookingUpArtist - } = this.props; - - const errorMessage = error && - error.responseJSON && - error.responseJSON.message; - - return ( - - - {({ ref }) => ( -
- - { - isLookingUpArtist && isQueued && !isPopulated ? - : - null - } - - { - isPopulated && selectedArtist && isExistingArtist ? - : - null - } - - { - isPopulated && selectedArtist ? - : - null - } - - { - isPopulated && !selectedArtist ? -
- - - No match found! -
: - null - } - - { - !isFetching && !!error ? -
- - - Search failed, please try again later. -
: - null - } - -
- -
- -
- )} -
- - - - {({ ref, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( -
- { - this.state.isOpen ? -
-
-
- -
- - - - - - -
- -
- { - items.map((item) => { - return ( - - ); - }) - } -
-
: - null - } - -
- ); - }} -
-
-
- ); - } -} - -ImportArtistSelectArtist.propTypes = { - id: PropTypes.string.isRequired, - selectedArtist: PropTypes.object, - isExistingArtist: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isQueued: PropTypes.bool.isRequired, - isLookingUpArtist: PropTypes.bool.isRequired, - onSearchInputChange: PropTypes.func.isRequired, - onArtistSelect: PropTypes.func.isRequired -}; - -ImportArtistSelectArtist.defaultProps = { - isFetching: true, - isPopulated: false, - items: [], - isQueued: true -}; - -export default ImportArtistSelectArtist; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js deleted file mode 100644 index 21e2bcab2..000000000 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js +++ /dev/null @@ -1,76 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; -import createImportArtistItemSelector from 'Store/Selectors/createImportArtistItemSelector'; -import ImportArtistSelectArtist from './ImportArtistSelectArtist'; - -function createMapStateToProps() { - return createSelector( - (state) => state.importArtist.isLookingUpArtist, - createImportArtistItemSelector(), - (isLookingUpArtist, item) => { - return { - isLookingUpArtist, - ...item - }; - } - ); -} - -const mapDispatchToProps = { - queueLookupArtist, - setImportArtistValue -}; - -class ImportArtistSelectArtistConnector extends Component { - - // - // Listeners - - onSearchInputChange = (term) => { - this.props.queueLookupArtist({ - name: this.props.id, - term, - topOfQueue: true - }); - } - - onArtistSelect = (foreignArtistId) => { - const { - id, - items - } = this.props; - - this.props.setImportArtistValue({ - id, - selectedArtist: _.find(items, { foreignArtistId }) - }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -ImportArtistSelectArtistConnector.propTypes = { - id: PropTypes.string.isRequired, - items: PropTypes.arrayOf(PropTypes.object), - selectedArtist: PropTypes.object, - isSelected: PropTypes.bool, - queueLookupArtist: PropTypes.func.isRequired, - setImportArtistValue: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectArtistConnector); diff --git a/frontend/src/AddArtist/ImportArtist/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/ImportArtist.js deleted file mode 100644 index ce5ec27ee..000000000 --- a/frontend/src/AddArtist/ImportArtist/ImportArtist.js +++ /dev/null @@ -1,30 +0,0 @@ -import React, { Component } from 'react'; -import { Route } from 'react-router-dom'; -import Switch from 'Components/Router/Switch'; -import ImportArtistSelectFolderConnector from 'AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector'; -import ImportArtistConnector from 'AddArtist/ImportArtist/Import/ImportArtistConnector'; - -class ImportArtist extends Component { - - // - // Render - - render() { - return ( - - - - - - ); - } -} - -export default ImportArtist; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css deleted file mode 100644 index 030da96fb..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css +++ /dev/null @@ -1,32 +0,0 @@ -.header { - margin-bottom: 40px; - text-align: center; - font-weight: 300; - font-size: 36px; -} - -.tips { - font-size: 20px; -} - -.tip { - font-size: $defaultFontSize; -} - -.code { - font-size: 12px; - font-family: $monoSpaceFontFamily; -} - -.recentFolders { - margin-top: 40px; -} - -.startImport { - margin-top: 40px; - text-align: center; -} - -.importButtonIcon { - margin-right: 8px; -} diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js deleted file mode 100644 index 9a7253ec7..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js +++ /dev/null @@ -1,147 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { icons, kinds, sizes } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import FieldSet from 'Components/FieldSet'; -import Icon from 'Components/Icon'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; -import RootFolders from 'RootFolder/RootFolders'; -import styles from './ImportArtistSelectFolder.css'; - -class ImportArtistSelectFolder extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isAddNewRootFolderModalOpen: false - }; - } - - // - // Lifecycle - - onAddNewRootFolderPress = () => { - this.setState({ isAddNewRootFolderModalOpen: true }); - } - - onNewRootFolderSelect = ({ value }) => { - this.props.onNewRootFolderSelect(value); - } - - onAddRootFolderModalClose = () => { - this.setState({ isAddNewRootFolderModalOpen: false }); - } - - // - // Render - - render() { - const { - isWindows, - isFetching, - isPopulated, - error, - items - } = this.props; - - return ( - - - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
Unable to load root folders
- } - - { - !error && isPopulated && -
-
- Import artist(s) you already have -
- -
- Some tips to ensure the import goes smoothly: -
    -
  • - Point Lidarr to the folder containing all of your music not a specific one. eg. "{isWindows ? 'C:\\music' : '/music'}" and not "{isWindows ? 'C:\\music\\sublime' : '/music/sublime'}" -
  • -
-
- - { - items.length > 0 ? -
-
- -
- - -
: - -
- -
- } - - -
- } -
-
- ); - } -} - -ImportArtistSelectFolder.propTypes = { - isWindows: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onNewRootFolderSelect: PropTypes.func.isRequired -}; - -export default ImportArtistSelectFolder; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js deleted file mode 100644 index 8354ed4da..000000000 --- a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js +++ /dev/null @@ -1,84 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { push } from 'connected-react-router'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import { fetchRootFolders, addRootFolder } from 'Store/Actions/rootFolderActions'; -import ImportArtistSelectFolder from './ImportArtistSelectFolder'; - -function createMapStateToProps() { - return createSelector( - (state) => state.rootFolders, - createSystemStatusSelector(), - (rootFolders, systemStatus) => { - return { - ...rootFolders, - isWindows: systemStatus.isWindows - }; - } - ); -} - -const mapDispatchToProps = { - fetchRootFolders, - addRootFolder, - push -}; - -class ImportArtistSelectFolderConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchRootFolders(); - } - - componentDidUpdate(prevProps) { - const { - items, - isSaving, - saveError - } = this.props; - - if (prevProps.isSaving && !isSaving && !saveError) { - const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id); - - if (newRootFolders.length === 1) { - this.props.push(`${window.Lidarr.urlBase}/add/import/${newRootFolders[0].id}`); - } - } - } - - // - // Listeners - - onNewRootFolderSelect = (path) => { - this.props.addRootFolder({ path }); - } - - // - // Render - - render() { - return ( - - ); - } -} - -ImportArtistSelectFolderConnector.propTypes = { - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchRootFolders: PropTypes.func.isRequired, - addRootFolder: PropTypes.func.isRequired, - push: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectFolderConnector); diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index 217215f5c..f4d26ac6f 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -25,7 +25,6 @@ class TrackRow extends Component { title, duration, trackFilePath, - trackFileRelativePath, columns, deleteTrackFile } = this.props; @@ -86,16 +85,6 @@ class TrackRow extends Component { ); } - if (name === 'relativePath') { - return ( - - { - trackFileRelativePath - } - - ); - } - if (name === 'duration') { return ( { return { - trackFilePath: trackFile ? trackFile.path : null, - trackFileRelativePath: trackFile ? trackFile.relativePath : null + trackFilePath: trackFile ? trackFile.path : null }; } ); diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 19c4785b1..aacd16b06 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -6,7 +6,6 @@ import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector'; import AddNewItemConnector from 'Search/AddNewItemConnector'; -import ImportArtist from 'AddArtist/ImportArtist/ImportArtist'; import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector'; import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector'; import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector'; @@ -76,11 +75,6 @@ function AppRoutes(props) { component={AddNewItemConnector} /> - -
- Lidarr has lost it's connection to the backend and will need to be reloaded to restore functionality. + Lidarr has lost its connection to the backend and will need to be reloaded to restore functionality.
diff --git a/frontend/src/Artist/Details/AlbumRowConnector.js b/frontend/src/Artist/Details/AlbumRowConnector.js index 6e92fb1d4..f00bd3fce 100644 --- a/frontend/src/Artist/Details/AlbumRowConnector.js +++ b/frontend/src/Artist/Details/AlbumRowConnector.js @@ -13,8 +13,7 @@ function createMapStateToProps() { return { foreignArtistId: artist.foreignArtistId, artistMonitored: artist.monitored, - trackFilePath: trackFile ? trackFile.path : null, - trackFileRelativePath: trackFile ? trackFile.relativePath : null + trackFilePath: trackFile ? trackFile.path : null }; } ); diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js index c0188ee6d..61f276f41 100644 --- a/frontend/src/Artist/Editor/ArtistEditorConnector.js +++ b/frontend/src/Artist/Editor/ArtistEditorConnector.js @@ -5,7 +5,7 @@ import { createSelector } from 'reselect'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { setArtistEditorSort, setArtistEditorFilter, saveArtistEditor } from 'Store/Actions/artistEditorActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchRootFolders } from 'Store/Actions/settingsActions'; import { executeCommand } from 'Store/Actions/commandActions'; import * as commandNames from 'Commands/commandNames'; import ArtistEditor from './ArtistEditor'; diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.js b/frontend/src/Artist/Editor/ArtistEditorFooter.js index ccf044c53..59bdcd056 100644 --- a/frontend/src/Artist/Editor/ArtistEditorFooter.js +++ b/frontend/src/Artist/Editor/ArtistEditorFooter.js @@ -217,6 +217,7 @@ class ArtistEditorFooter extends Component { name="metadataProfileId" value={metadataProfileId} includeNoChange={true} + includeNone={true} isDisabled={!selectedCount} onChange={this.onInputChange} /> diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js index 1fa9388b0..e250d61f5 100644 --- a/frontend/src/Artist/Index/ArtistIndex.js +++ b/frontend/src/Artist/Index/ArtistIndex.js @@ -217,7 +217,6 @@ class ArtistIndex extends Component { iconName={icons.REFRESH} spinningName={icons.REFRESH} isSpinning={isRefreshingArtist} - isDisabled={hasNoArtist} onPress={onRefreshArtistPress} /> diff --git a/frontend/src/Artist/NoArtist.js b/frontend/src/Artist/NoArtist.js index 1db9f41db..76c2336bc 100644 --- a/frontend/src/Artist/NoArtist.js +++ b/frontend/src/Artist/NoArtist.js @@ -20,15 +20,15 @@ function NoArtist(props) { return (
- No artist found, to get started you'll want to add a new artist or album or import some existing ones. + No artists found, to get started you'll want to add a new artist or album or add an existing library location (Root Folder) and update.
diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index 110f94939..b3ea2f94e 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -14,6 +14,7 @@ export const MOVE_ARTIST = 'MoveArtist'; export const REFRESH_ARTIST = 'RefreshArtist'; export const RENAME_FILES = 'RenameFiles'; export const RENAME_ARTIST = 'RenameArtist'; +export const RESCAN_FOLDERS = 'RescanFolders'; export const RETAG_FILES = 'RetagFiles'; export const RETAG_ARTIST = 'RetagArtist'; export const RESET_API_KEY = 'ResetApiKey'; diff --git a/frontend/src/Components/Form/RootFolderSelectInput.js b/frontend/src/Components/Form/RootFolderSelectInput.js index 08d88e5f1..e585bd524 100644 --- a/frontend/src/Components/Form/RootFolderSelectInput.js +++ b/frontend/src/Components/Form/RootFolderSelectInput.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; +import EditRootFolderModalConnector from 'Settings/MediaManagement/RootFolder/EditRootFolderModalConnector'; import EnhancedSelectInput from './EnhancedSelectInput'; import RootFolderSelectInputOption from './RootFolderSelectInputOption'; import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue'; @@ -14,8 +14,7 @@ class RootFolderSelectInput extends Component { super(props, context); this.state = { - isAddNewRootFolderModalOpen: false, - newRootFolderPath: '' + isAddNewRootFolderModalOpen: false }; } @@ -52,9 +51,7 @@ class RootFolderSelectInput extends Component { } onNewRootFolderSelect = ({ value }) => { - this.setState({ newRootFolderPath: value }, () => { - this.props.onNewRootFolderSelect(value); - }); + this.setState({ newRootFolderPath: value }); } onAddRootFolderModalClose = () => { @@ -66,8 +63,7 @@ class RootFolderSelectInput extends Component { render() { const { - includeNoChange, - onNewRootFolderSelect, + value, ...otherProps } = this.props; @@ -75,17 +71,16 @@ class RootFolderSelectInput extends Component {
-
); @@ -94,16 +89,11 @@ class RootFolderSelectInput extends Component { RootFolderSelectInput.propTypes = { name: PropTypes.string.isRequired, + value: PropTypes.string, values: PropTypes.arrayOf(PropTypes.object).isRequired, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, - includeNoChange: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onNewRootFolderSelect: PropTypes.func.isRequired -}; - -RootFolderSelectInput.defaultProps = { - includeNoChange: false + onChange: PropTypes.func.isRequired }; export default RootFolderSelectInput; diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js index b76501dc1..928ecea28 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js @@ -2,20 +2,20 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { addRootFolder } from 'Store/Actions/rootFolderActions'; import RootFolderSelectInput from './RootFolderSelectInput'; const ADD_NEW_KEY = 'addNew'; function createMapStateToProps() { return createSelector( - (state) => state.rootFolders, + (state) => state.settings.rootFolders, (state, { includeNoChange }) => includeNoChange, (rootFolders, includeNoChange) => { const values = rootFolders.items.map((rootFolder) => { return { key: rootFolder.path, value: rootFolder.path, + name: rootFolder.name, freeSpace: rootFolder.freeSpace }; }); @@ -23,7 +23,8 @@ function createMapStateToProps() { if (includeNoChange) { values.unshift({ key: 'noChange', - value: 'No Change', + value: '', + name: 'No Change', isDisabled: true }); } @@ -32,6 +33,7 @@ function createMapStateToProps() { values.push({ key: '', value: '', + name: '', isDisabled: true, isHidden: true }); @@ -39,7 +41,8 @@ function createMapStateToProps() { values.push({ key: ADD_NEW_KEY, - value: 'Add a new path' + value: '', + name: 'Add a new path' }); return { @@ -51,14 +54,6 @@ function createMapStateToProps() { ); } -function createMapDispatchToProps(dispatch, props) { - return { - dispatchAddRootFolder(path) { - dispatch(addRootFolder({ path })); - } - }; -} - class RootFolderSelectInputConnector extends Component { // @@ -95,19 +90,11 @@ class RootFolderSelectInputConnector extends Component { } } - // - // Listeners - - onNewRootFolderSelect = (path) => { - this.props.dispatchAddRootFolder(path); - } - // // Render render() { const { - dispatchAddRootFolder, ...otherProps } = this.props; @@ -125,12 +112,11 @@ RootFolderSelectInputConnector.propTypes = { value: PropTypes.string, values: PropTypes.arrayOf(PropTypes.object).isRequired, includeNoChange: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - dispatchAddRootFolder: PropTypes.func.isRequired + onChange: PropTypes.func.isRequired }; RootFolderSelectInputConnector.defaultProps = { includeNoChange: false }; -export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector); +export default connect(createMapStateToProps)(RootFolderSelectInputConnector); diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js index a4db9cd82..0e4de1c1f 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputOption.js +++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js @@ -8,11 +8,14 @@ import styles from './RootFolderSelectInputOption.css'; function RootFolderSelectInputOption(props) { const { value, + name, freeSpace, isMobile, ...otherProps } = props; + const text = value === '' ? name : `${name} [${value}]`; + return ( -
{value}
+
{text}
{ freeSpace != null && @@ -37,6 +40,7 @@ function RootFolderSelectInputOption(props) { } RootFolderSelectInputOption.propTypes = { + name: PropTypes.string.isRequired, value: PropTypes.string.isRequired, freeSpace: PropTypes.number, isMobile: PropTypes.bool.isRequired diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js index ffd769254..0af2f61ae 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js +++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js @@ -6,19 +6,22 @@ import styles from './RootFolderSelectInputSelectedValue.css'; function RootFolderSelectInputSelectedValue(props) { const { + name, value, freeSpace, includeFreeSpace, ...otherProps } = props; + const text = value === '' ? name : `${name} [${value}]`; + return (
- {value} + {text}
{ @@ -32,6 +35,7 @@ function RootFolderSelectInputSelectedValue(props) { } RootFolderSelectInputSelectedValue.propTypes = { + name: PropTypes.string, value: PropTypes.string, freeSpace: PropTypes.number, includeFreeSpace: PropTypes.bool.isRequired diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js index 113d50a09..25899f630 100644 --- a/frontend/src/Components/Form/SelectInput.js +++ b/frontend/src/Components/Form/SelectInput.js @@ -75,7 +75,7 @@ SelectInput.propTypes = { className: PropTypes.string, disabledClassName: PropTypes.string, name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), values: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool, hasError: PropTypes.bool, diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.js b/frontend/src/Components/Page/Sidebar/Messages/Message.js index bb7a027fa..f9f95cfa5 100644 --- a/frontend/src/Components/Page/Sidebar/Messages/Message.js +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js @@ -17,6 +17,8 @@ function getIconName(name) { return icons.SEARCH; case 'Housekeeping': return icons.HOUSEKEEPING; + case 'RescanFolders': + return icons.RESCAN; case 'RefreshArtist': return icons.REFRESH; case 'RssSync': diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index a9e9491b0..cb27587df 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -28,10 +28,6 @@ const links = [ title: 'Add New', to: '/add/search' }, - { - title: 'Import', - to: '/add/import' - }, { title: 'Mass Editor', to: '/artisteditor' diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 2c0fc8f44..31779e9d1 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -11,7 +11,7 @@ import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchHealth } from 'Store/Actions/systemActions'; import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchRootFolders } from 'Store/Actions/settingsActions'; import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions'; function getHandlerName(name) { @@ -275,8 +275,14 @@ class SignalRConnector extends Component { // No-op for now, we may want this later } - handleRootfolder = () => { - this.props.dispatchFetchRootFolders(); + handleRootfolder = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'settings.rootFolders', + updateOnly: true, + ...body.resource + }); + } } handleTag = (body) => { diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 86ea9c58b..6643cbc73 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -82,6 +82,7 @@ import { faRocket as fasRocket, faSave as fasSave, faSearch as fasSearch, + faSearchPlus as fasSearchPlus, faSignOutAlt as fasSignOutAlt, faSitemap as fasSitemap, faSpinner as fasSpinner, @@ -110,6 +111,7 @@ import { export const ACTIONS = fasBolt; export const ACTIVITY = farClock; export const ADD = fasPlus; +export const ADD_MISSING_ARTISTS = fasSearchPlus; export const ALTERNATE_TITLES = farClone; export const ADVANCED_SETTINGS = fasCog; export const ARROW_LEFT = fasArrowCircleLeft; @@ -182,6 +184,7 @@ export const QUICK = fasRocket; export const REFRESH = fasSync; export const REMOVE = fasTimes; export const REORDER = fasBars; +export const RESCAN = fasFolderOpen; export const RESTART = fasRedoAlt; export const RESTORE = fasHistory; export const RETAG = fasEdit; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index 1edac2b7c..c84fd567d 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -30,8 +30,8 @@ import styles from './InteractiveImportModalContent.css'; const columns = [ { - name: 'relativePath', - label: 'Relative Path', + name: 'path', + label: 'Path', isSortable: true, isVisible: true }, diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css index 8510d0649..2a23e6d4d 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css @@ -1,4 +1,4 @@ -.relativePath { +.path { composes: cell from '~Components/Table/Cells/TableRowCell.css'; word-break: break-all; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index 06c2aed2a..0be12c0eb 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -149,7 +149,7 @@ class InteractiveImportRow extends Component { const { id, allowArtistChange, - relativePath, + path, artist, album, albumReleaseId, @@ -190,7 +190,7 @@ class InteractiveImportRow extends Component { const pathCellContents = (
- {relativePath} + {path}
); @@ -213,8 +213,8 @@ class InteractiveImportRow extends Component { /> {pathCell} @@ -328,7 +328,7 @@ class InteractiveImportRow extends Component { audioTags={audioTags} sortKey='mediumNumber' sortDirection={sortDirections.ASCENDING} - filename={relativePath} + filename={path} onModalClose={this.onSelectTrackModalClose} /> @@ -349,7 +349,7 @@ class InteractiveImportRow extends Component { InteractiveImportRow.propTypes = { id: PropTypes.number.isRequired, allowArtistChange: PropTypes.bool.isRequired, - relativePath: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, artist: PropTypes.object, album: PropTypes.object, albumReleaseId: PropTypes.number, diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js index 6f20a9d3c..c263208d4 100644 --- a/frontend/src/Organize/OrganizePreviewModalContent.js +++ b/frontend/src/Organize/OrganizePreviewModalContent.js @@ -75,7 +75,6 @@ class OrganizePreviewModalContent extends Component { error, items, trackFormat, - path, onModalClose } = this.props; @@ -113,13 +112,6 @@ class OrganizePreviewModalContent extends Component { !isFetching && isPopulated && !!items.length &&
-
- All paths are relative to: - - {path} - -
-
Naming pattern: diff --git a/frontend/src/Retag/RetagPreviewModalContent.js b/frontend/src/Retag/RetagPreviewModalContent.js index 5530d63fb..fb7a39c1b 100644 --- a/frontend/src/Retag/RetagPreviewModalContent.js +++ b/frontend/src/Retag/RetagPreviewModalContent.js @@ -74,7 +74,6 @@ class RetagPreviewModalContent extends Component { isPopulated, error, items, - path, onModalClose } = this.props; @@ -112,12 +111,6 @@ class RetagPreviewModalContent extends Component { !isFetching && isPopulated && !!items.length &&
-
- All paths are relative to: - - {path} - -
MusicBrainz identifiers will also be added to the files; these are not shown below.
@@ -130,7 +123,7 @@ class RetagPreviewModalContent extends Component { - - { - isUnavailable ? -
- {path} - - -
: - - - {path} - - } -
- - - {(isUnavailable || isNaN(freeSpace)) ? '-' : formatBytes(freeSpace)} - - - - {isUnavailable ? '-' : unmappedFolders.length} - - - - - - - ); -} - -RootFolderRow.propTypes = { - id: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - accessible: PropTypes.bool.isRequired, - freeSpace: PropTypes.number, - unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired, - onDeletePress: PropTypes.func.isRequired -}; - -RootFolderRow.defaultProps = { - unmappedFolders: [] -}; - -export default RootFolderRow; diff --git a/frontend/src/RootFolder/RootFolderRowConnector.js b/frontend/src/RootFolder/RootFolderRowConnector.js deleted file mode 100644 index ab0848e87..000000000 --- a/frontend/src/RootFolder/RootFolderRowConnector.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; -import RootFolderRow from './RootFolderRow'; - -function createMapDispatchToProps(dispatch, props) { - return { - onDeletePress() { - dispatch(deleteRootFolder({ id: props.id })); - } - }; -} - -export default connect(null, createMapDispatchToProps)(RootFolderRow); diff --git a/frontend/src/RootFolder/RootFolders.js b/frontend/src/RootFolder/RootFolders.js deleted file mode 100644 index a07209ecc..000000000 --- a/frontend/src/RootFolder/RootFolders.js +++ /dev/null @@ -1,81 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import RootFolderRowConnector from './RootFolderRowConnector'; - -const rootFolderColumns = [ - { - name: 'path', - label: 'Path', - isVisible: true - }, - { - name: 'freeSpace', - label: 'Free Space', - isVisible: true - }, - { - name: 'unmappedFolders', - label: 'Unmapped Folders', - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - -function RootFolders(props) { - const { - isFetching, - isPopulated, - error, - items - } = props; - - if (isFetching && !isPopulated) { - return ( - - ); - } - - if (!isFetching && !!error) { - return ( -
Unable to load root folders
- ); - } - - return ( - - - { - items.map((rootFolder) => { - return ( - - ); - }) - } - -
- ); -} - -RootFolders.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default RootFolders; diff --git a/frontend/src/RootFolder/RootFoldersConnector.js b/frontend/src/RootFolder/RootFoldersConnector.js deleted file mode 100644 index 39f140bcc..000000000 --- a/frontend/src/RootFolder/RootFoldersConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import RootFolders from './RootFolders'; - -function createMapStateToProps() { - return createSelector( - (state) => state.rootFolders, - (rootFolders) => { - return rootFolders; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchRootFolders: fetchRootFolders -}; - -class RootFoldersConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchRootFolders(); - } - - // - // Render - - render() { - return ( - - ); - } -} - -RootFoldersConnector.propTypes = { - dispatchFetchRootFolders: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector); diff --git a/frontend/src/Search/AddNewItemConnector.js b/frontend/src/Search/AddNewItemConnector.js index ea9961ad9..cd61dcdd0 100644 --- a/frontend/src/Search/AddNewItemConnector.js +++ b/frontend/src/Search/AddNewItemConnector.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import parseUrl from 'Utilities/String/parseUrl'; import { getSearchResults, clearSearchResults } from 'Store/Actions/searchActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchRootFolders } from 'Store/Actions/settingsActions'; import AddNewItem from './AddNewItem'; function createMapStateToProps() { diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js index 5aa8411b0..fa65813e1 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js @@ -4,8 +4,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import sortByName from 'Utilities/Array/sortByName'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import { fetchImportLists, deleteImportList } from 'Store/Actions/settingsActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchImportLists, deleteImportList, fetchRootFolders } from 'Store/Actions/settingsActions'; import ImportLists from './ImportLists'; function createMapStateToProps() { diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index ab36f00d1..a6fe21fcf 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -10,9 +10,8 @@ import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputGroup from 'Components/Form/FormInputGroup'; -import RootFoldersConnector from 'RootFolder/RootFoldersConnector'; +import RootFoldersConnector from './RootFolder/RootFoldersConnector'; import NamingConnector from './Naming/NamingConnector'; -import AddRootFolderConnector from './RootFolder/AddRootFolderConnector'; const rescanAfterRefreshOptions = [ { key: 'always', value: 'Always' }, @@ -64,6 +63,7 @@ class MediaManagement extends Component { /> + { @@ -427,11 +427,6 @@ class MediaManagement extends Component { } } - -
- - -
); diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css deleted file mode 100644 index 19b1880be..000000000 --- a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css +++ /dev/null @@ -1,7 +0,0 @@ -.addRootFolderButtonContainer { - margin-top: 20px; -} - -.importButtonIcon { - margin-right: 8px; -} diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js deleted file mode 100644 index 3da2a55b9..000000000 --- a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js +++ /dev/null @@ -1,71 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { icons, kinds, sizes } from 'Helpers/Props'; -import Button from 'Components/Link/Button'; -import Icon from 'Components/Icon'; -import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; -import styles from './AddRootFolder.css'; - -class AddRootFolder extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isAddNewRootFolderModalOpen: false - }; - } - - // - // Lifecycle - - onAddNewRootFolderPress = () => { - this.setState({ isAddNewRootFolderModalOpen: true }); - } - - onNewRootFolderSelect = ({ value }) => { - this.props.onNewRootFolderSelect(value); - } - - onAddRootFolderModalClose = () => { - this.setState({ isAddNewRootFolderModalOpen: false }); - } - - // - // Render - - render() { - return ( -
- - - -
- ); - } -} - -AddRootFolder.propTypes = { - onNewRootFolderSelect: PropTypes.func.isRequired -}; - -export default AddRootFolder; diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js deleted file mode 100644 index cd5f4c50d..000000000 --- a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import AddRootFolder from './AddRootFolder'; -import { addRootFolder } from 'Store/Actions/rootFolderActions'; - -function createMapDispatchToProps(dispatch) { - return { - onNewRootFolderSelect(path) { - dispatch(addRootFolder({ path })); - } - }; -} - -export default connect(null, createMapDispatchToProps)(AddRootFolder); diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModal.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModal.js new file mode 100644 index 000000000..6adc8046c --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditRootFolderModalContentConnector from './EditRootFolderModalContentConnector'; + +function EditRootFolderModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditRootFolderModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditRootFolderModal; diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalConnector.js new file mode 100644 index 000000000..d016df6ce --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelSaveRootFolder } from 'Store/Actions/settingsActions'; +import EditRootFolderModal from './EditRootFolderModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.rootFolders'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelSaveRootFolder() { + dispatch(cancelSaveRootFolder({ section })); + } + }; +} + +class EditRootFolderModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelSaveRootFolder(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelSaveRootFolder, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditRootFolderModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelSaveRootFolder: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditRootFolderModalConnector); diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css new file mode 100644 index 000000000..23e22b6dc --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.css @@ -0,0 +1,15 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.hideMetadataProfile { + composes: group from '~Components/Form/FormGroup.css'; + + display: none; +} + +.labelIcon { + margin-left: 8px; +} diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js new file mode 100644 index 000000000..6b015f8cc --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContent.js @@ -0,0 +1,217 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +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 Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Popover from 'Components/Tooltip/Popover'; +import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; +import ArtistMetadataProfilePopoverContent from 'AddArtist/ArtistMetadataProfilePopoverContent'; +import styles from './EditRootFolderModalContent.css'; + +function EditRootFolderModalContent(props) { + + const { + advancedSettings, + isFetching, + error, + isSaving, + saveError, + item, + onInputChange, + onModalClose, + onSavePress, + onDeleteRootFolderPress, + showMetadataProfile, + ...otherProps + } = props; + + const { + id, + name, + path, + defaultQualityProfileId, + defaultMetadataProfileId, + defaultMonitorOption, + defaultTags + } = item; + + return ( + + + {id ? 'Edit Root Folder' : 'Add Root Folder'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new root folder, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + Path + + + + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + + Quality Profile + + + + + + + Metadata Profile + + } + title="Metadata Profile" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Default Lidarr Tags + + + + +
+ } +
+ + { + id && + + } + + + + + Save + + +
+ ); +} + +EditRootFolderModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteRootFolderPress: PropTypes.func +}; + +export default EditRootFolderModalContent; diff --git a/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContentConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContentConnector.js new file mode 100644 index 000000000..1285d2f7a --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/EditRootFolderModalContentConnector.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setRootFolderValue, saveRootFolder } from 'Store/Actions/settingsActions'; +import EditRootFolderModalContent from './EditRootFolderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.advancedSettings, + (state) => state.settings.metadataProfiles, + (state) => state.settings.rootFolders, + createProviderSettingsSelector('rootFolders'), + (id, advancedSettings, metadataProfiles, rootFolders, rootFolderSettings) => { + return { + advancedSettings, + showMetadataProfile: metadataProfiles.items.length > 1, + ...rootFolderSettings, + isFetching: rootFolders.isFetching + }; + } + ); +} + +const mapDispatchToProps = { + setRootFolderValue, + saveRootFolder +}; + +class EditRootFolderModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setRootFolderValue({ name, value }); + } + + onSavePress = () => { + this.props.saveRootFolder({ id: this.props.id }); + + if (this.props.onRootFolderAdded) { + this.props.onRootFolderAdded(this.props.item.path); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRootFolderModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setRootFolderValue: PropTypes.func.isRequired, + saveRootFolder: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onRootFolderAdded: PropTypes.func +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditRootFolderModalContentConnector); diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css new file mode 100644 index 000000000..0506cc9c6 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.css @@ -0,0 +1,19 @@ +.rootFolder { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js new file mode 100644 index 000000000..01b0ac022 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js @@ -0,0 +1,116 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditRootFolderModalConnector from './EditRootFolderModalConnector'; +import styles from './RootFolder.css'; + +class RootFolder extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditRootFolderModalOpen: false, + isDeleteRootFolderModalOpen: false + }; + } + + // + // Listeners + + onEditRootFolderPress = () => { + this.setState({ isEditRootFolderModalOpen: true }); + } + + onEditRootFolderModalClose = () => { + this.setState({ isEditRootFolderModalOpen: false }); + } + + onDeleteRootFolderPress = () => { + this.setState({ + isEditRootFolderModalOpen: false, + isDeleteRootFolderModalOpen: true + }); + } + + onDeleteRootFolderModalClose= () => { + this.setState({ isDeleteRootFolderModalOpen: false }); + } + + onConfirmDeleteRootFolder = () => { + this.props.onConfirmDeleteRootFolder(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + path, + qualityProfile, + metadataProfile + } = this.props; + + return ( + +
+ {name} +
+ +
+ + + + + +
+ + + + +
+ ); + } +} + +RootFolder.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + qualityProfile: PropTypes.object.isRequired, + metadataProfile: PropTypes.object.isRequired, + onConfirmDeleteRootFolder: PropTypes.func.isRequired +}; + +export default RootFolder; diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css new file mode 100644 index 000000000..6ecc1572c --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.css @@ -0,0 +1,20 @@ +.rootFolders { + display: flex; + flex-wrap: wrap; +} + +.addRootFolder { + composes: rootFolder from '~./RootFolder.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.js new file mode 100644 index 000000000..366f45e85 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolders.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import RootFolder from './RootFolder'; +import EditRootFolderModalConnector from './EditRootFolderModalConnector'; +import styles from './RootFolders.css'; + +class RootFolders extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddRootFolderModalOpen: false + }; + } + + // + // Listeners + + onAddRootFolderPress = () => { + this.setState({ isAddRootFolderModalOpen: true }); + } + + onAddRootFolderModalClose = () => { + this.setState({ isAddRootFolderModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + qualityProfiles, + metadataProfiles, + onConfirmDeleteRootFolder, + ...otherProps + } = this.props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + const qualityProfile = qualityProfiles.find((profile) => profile.id === item.defaultQualityProfileId); + const metadataProfile = metadataProfiles.find((profile) => profile.id === item.defaultMetadataProfileId); + return ( + + ); + }) + } + + +
+ +
+
+
+ + +
+
+ ); + } +} + +RootFolders.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + metadataProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteRootFolder: PropTypes.func.isRequired +}; + +export default RootFolders; diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFoldersConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFoldersConnector.js new file mode 100644 index 000000000..af2cf091d --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFoldersConnector.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRootFolders, deleteRootFolder } from 'Store/Actions/settingsActions'; +import RootFolders from './RootFolders'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.rootFolders, + (state) => state.settings.qualityProfiles, + (state) => state.settings.metadataProfiles, + (rootFolders, quality, metadata) => { + return { + qualityProfiles: quality.items, + metadataProfiles: metadata.items, + ...rootFolders + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchRootFolders: fetchRootFolders, + dispatchDeleteRootFolder: deleteRootFolder +}; + +class RootFoldersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Listeners + + onConfirmDeleteRootFolder = (id) => { + this.props.dispatchDeleteRootFolder({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RootFoldersConnector.propTypes = { + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchDeleteRootFolder: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector); diff --git a/frontend/src/Store/Actions/Settings/rootFolders.js b/frontend/src/Store/Actions/Settings/rootFolders.js new file mode 100644 index 000000000..d7bcf34e4 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/rootFolders.js @@ -0,0 +1,76 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +export const section = 'settings.rootFolders'; + +// +// Actions Types + +export const FETCH_ROOT_FOLDERS = 'settings/rootFolders/fetchRootFolders'; +export const SET_ROOT_FOLDER_VALUE = 'settings/rootFolders/setRootFolderValue'; +export const SAVE_ROOT_FOLDER = 'settings/rootFolders/saveRootFolder'; +export const CANCEL_SAVE_ROOT_FOLDER = 'settings/rootFolders/cancelSaveRootFolder'; +export const DELETE_ROOT_FOLDER = 'settings/rootFolders/deleteRootFolder'; + +// +// Action Creators + +export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS); +export const saveRootFolder = createThunk(SAVE_ROOT_FOLDER); +export const cancelSaveRootFolder = createThunk(CANCEL_SAVE_ROOT_FOLDER); +export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER); + +export const setRootFolderValue = createAction(SET_ROOT_FOLDER_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + schema: { + defaultTags: [] + }, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + + [FETCH_ROOT_FOLDERS]: createFetchHandler(section, '/rootFolder'), + + [SAVE_ROOT_FOLDER]: createSaveProviderHandler(section, '/rootFolder'), + [CANCEL_SAVE_ROOT_FOLDER]: createCancelSaveProviderHandler(section), + [DELETE_ROOT_FOLDER]: createRemoveItemHandler(section, '/rootFolder') + + }, + + // + // Reducers + + reducers: { + [SET_ROOT_FOLDER_VALUE]: createSetSettingValueReducer(section) + } +}; diff --git a/frontend/src/Store/Actions/importArtistActions.js b/frontend/src/Store/Actions/importArtistActions.js deleted file mode 100644 index b4b265a6d..000000000 --- a/frontend/src/Store/Actions/importArtistActions.js +++ /dev/null @@ -1,327 +0,0 @@ -import _ from 'lodash'; -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import getSectionState from 'Utilities/State/getSectionState'; -import updateSectionState from 'Utilities/State/updateSectionState'; -import getNewArtist from 'Utilities/Artist/getNewArtist'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createHandleActions from './Creators/createHandleActions'; -import { set, removeItem, updateItem } from './baseActions'; -import { fetchRootFolders } from './rootFolderActions'; - -// -// Variables - -export const section = 'importArtist'; -let concurrentLookups = 0; -let abortCurrentLookup = null; -const queue = []; - -// -// State - -export const defaultState = { - isLookingUpArtist: false, - isImporting: false, - isImported: false, - importError: null, - items: [] -}; - -// -// Actions Types - -export const QUEUE_LOOKUP_ARTIST = 'importArtist/queueLookupArtist'; -export const START_LOOKUP_ARTIST = 'importArtist/startLookupArtist'; -export const CANCEL_LOOKUP_ARTIST = 'importArtist/cancelLookupArtist'; -export const LOOKUP_UNSEARCHED_ARTIST = 'importArtist/lookupUnsearchedArtist'; -export const CLEAR_IMPORT_ARTIST = 'importArtist/clearImportArtist'; -export const SET_IMPORT_ARTIST_VALUE = 'importArtist/setImportArtistValue'; -export const IMPORT_ARTIST = 'importArtist/importArtist'; - -// -// Action Creators - -export const queueLookupArtist = createThunk(QUEUE_LOOKUP_ARTIST); -export const startLookupArtist = createThunk(START_LOOKUP_ARTIST); -export const importArtist = createThunk(IMPORT_ARTIST); -export const lookupUnsearchedArtist = createThunk(LOOKUP_UNSEARCHED_ARTIST); -export const clearImportArtist = createAction(CLEAR_IMPORT_ARTIST); -export const cancelLookupArtist = createAction(CANCEL_LOOKUP_ARTIST); - -export const setImportArtistValue = createAction(SET_IMPORT_ARTIST_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - - [QUEUE_LOOKUP_ARTIST]: function(getState, payload, dispatch) { - const { - name, - path, - term, - topOfQueue = false - } = payload; - - const state = getState().importArtist; - const item = _.find(state.items, { id: name }) || { - id: name, - term, - path, - isFetching: false, - isPopulated: false, - error: null - }; - - dispatch(updateItem({ - section, - ...item, - term, - isQueued: true, - items: [] - })); - - const itemIndex = queue.indexOf(item.id); - - if (itemIndex >= 0) { - queue.splice(itemIndex, 1); - } - - if (topOfQueue) { - queue.unshift(item.id); - } else { - queue.push(item.id); - } - - if (term && term.length > 2) { - dispatch(startLookupArtist({ start: true })); - } - }, - - [START_LOOKUP_ARTIST]: function(getState, payload, dispatch) { - if (concurrentLookups >= 1) { - return; - } - - const state = getState().importArtist; - - const { - isLookingUpArtist, - items - } = state; - - const queueId = queue[0]; - - if (payload.start && !isLookingUpArtist) { - dispatch(set({ section, isLookingUpArtist: true })); - } else if (!isLookingUpArtist) { - return; - } else if (!queueId) { - dispatch(set({ section, isLookingUpArtist: false })); - return; - } - - concurrentLookups++; - queue.splice(0, 1); - - const queued = items.find((i) => i.id === queueId); - - dispatch(updateItem({ - section, - id: queued.id, - isFetching: true - })); - - const { request, abortRequest } = createAjaxRequest({ - url: '/artist/lookup', - data: { - term: queued.term - } - }); - - abortCurrentLookup = abortRequest; - - request.done((data) => { - dispatch(updateItem({ - section, - id: queued.id, - isFetching: false, - isPopulated: true, - error: null, - items: data, - isQueued: false, - selectedArtist: queued.selectedArtist || data[0], - updateOnly: true - })); - }); - - request.fail((xhr) => { - dispatch(updateItem({ - section, - id: queued.id, - isFetching: false, - isPopulated: false, - error: xhr, - isQueued: false, - updateOnly: true - })); - }); - - request.always(() => { - concurrentLookups--; - - dispatch(startLookupArtist()); - }); - }, - - [LOOKUP_UNSEARCHED_ARTIST]: function(getState, payload, dispatch) { - const state = getState().importArtist; - - if (state.isLookingUpArtist) { - return; - } - - state.items.forEach((item) => { - const id = item.id; - - if ( - !item.isPopulated && - !queue.includes(id) - ) { - queue.push(item.id); - } - }); - - if (queue.length) { - dispatch(startLookupArtist({ start: true })); - } - }, - - [IMPORT_ARTIST]: function(getState, payload, dispatch) { - dispatch(set({ section, isImporting: true })); - - const ids = payload.ids; - const items = getState().importArtist.items; - const addedIds = []; - - const allNewArtist = ids.reduce((acc, id) => { - const item = _.find(items, { id }); - const selectedArtist = item.selectedArtist; - - // Make sure we have a selected artist and - // the same artist hasn't been added yet. - if (selectedArtist && !_.some(acc, { foreignArtistId: selectedArtist.foreignArtistId })) { - const newArtist = getNewArtist(_.cloneDeep(selectedArtist), item); - newArtist.path = item.path; - - addedIds.push(id); - acc.push(newArtist); - } - - return acc; - }, []); - - const promise = createAjaxRequest({ - url: '/artist/import', - method: 'POST', - contentType: 'application/json', - data: JSON.stringify(allNewArtist) - }).request; - - promise.done((data) => { - dispatch(batchActions([ - set({ - section, - isImporting: false, - isImported: true - }), - - ...data.map((artist) => updateItem({ section: 'artist', ...artist })), - - ...addedIds.map((id) => removeItem({ section, id })) - ])); - - dispatch(fetchRootFolders()); - }); - - promise.fail((xhr) => { - dispatch(batchActions( - set({ - section, - isImporting: false, - isImported: true - }), - - addedIds.map((id) => updateItem({ - section, - id, - importError: xhr - })) - )); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [CANCEL_LOOKUP_ARTIST]: function(state) { - queue.splice(0, queue.length); - - const items = state.items.map((item) => { - if (item.isQueued) { - return { - ...item, - isQueued: false - }; - } - - return item; - }); - - return Object.assign({}, state, { - isLookingUpArtist: false, - items - }); - }, - - [CLEAR_IMPORT_ARTIST]: function(state) { - if (abortCurrentLookup) { - abortCurrentLookup(); - - abortCurrentLookup = null; - } - - queue.splice(0, queue.length); - - return Object.assign({}, state, defaultState); - }, - - [SET_IMPORT_ARTIST_VALUE]: function(state, { payload }) { - const newState = getSectionState(state, section); - const items = newState.items; - const index = _.findIndex(items, { id: payload.id }); - - newState.items = [...items]; - - if (index >= 0) { - const item = items[index]; - - newState.items.splice(index, 1, { ...item, ...payload }); - } else { - newState.items.push({ ...payload }); - } - - return updateSectionState(state, section, newState); - } - -}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index ccabf68f0..183bf9df3 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -8,7 +8,6 @@ import * as albums from './albumActions'; import * as trackFiles from './trackFileActions'; import * as albumHistory from './albumHistoryActions'; import * as history from './historyActions'; -import * as importArtist from './importArtistActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; import * as organizePreview from './organizePreviewActions'; @@ -17,7 +16,6 @@ import * as paths from './pathActions'; import * as providerOptions from './providerOptionActions'; import * as queue from './queueActions'; import * as releases from './releaseActions'; -import * as rootFolders from './rootFolderActions'; import * as albumStudio from './albumStudioActions'; import * as artist from './artistActions'; import * as artistEditor from './artistEditorActions'; @@ -41,7 +39,6 @@ export default [ trackFiles, albumHistory, history, - importArtist, interactiveImportActions, oAuth, organizePreview, @@ -50,7 +47,6 @@ export default [ providerOptions, queue, releases, - rootFolders, albumStudio, artist, artistEditor, diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index 7b0607885..7662a917a 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -34,10 +34,10 @@ export const defaultState = { recentFolders: [], importMode: 'move', sortPredicates: { - relativePath: function(item, direction) { - const relativePath = item.relativePath; + path: function(item, direction) { + const path = item.path; - return relativePath.toLowerCase(); + return path.toLowerCase(); }, artist: function(item, direction) { diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js deleted file mode 100644 index 3e3c7de8a..000000000 --- a/frontend/src/Store/Actions/rootFolderActions.js +++ /dev/null @@ -1,97 +0,0 @@ -import { batchActions } from 'redux-batched-actions'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createFetchHandler from './Creators/createFetchHandler'; -import createHandleActions from './Creators/createHandleActions'; -import createRemoveItemHandler from './Creators/createRemoveItemHandler'; -import { set, updateItem } from './baseActions'; - -// -// Variables - -export const section = 'rootFolders'; - -// -// State - -export const defaultState = { - isFetching: false, - isPopulated: false, - error: null, - isSaving: false, - saveError: null, - items: [] -}; - -// -// Actions Types - -export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders'; -export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder'; -export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder'; - -// -// Action Creators - -export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS); -export const addRootFolder = createThunk(ADD_ROOT_FOLDER); -export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - - [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'), - - [DELETE_ROOT_FOLDER]: createRemoveItemHandler( - 'rootFolders', - '/rootFolder', - (state) => state.rootFolders - ), - - [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) { - const path = payload.path; - - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/rootFolder', - method: 'POST', - data: JSON.stringify({ path }), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(batchActions([ - updateItem({ - section, - ...data - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - } - -}); - -// -// Reducers - -export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 2a7cfc8b9..c056fea31 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -20,6 +20,7 @@ import qualityDefinitions from './Settings/qualityDefinitions'; import qualityProfiles from './Settings/qualityProfiles'; import releaseProfiles from './Settings/releaseProfiles'; import remotePathMappings from './Settings/remotePathMappings'; +import rootFolders from './Settings/rootFolders'; import ui from './Settings/ui'; export * from './Settings/delayProfiles'; @@ -41,6 +42,7 @@ export * from './Settings/qualityDefinitions'; export * from './Settings/qualityProfiles'; export * from './Settings/releaseProfiles'; export * from './Settings/remotePathMappings'; +export * from './Settings/rootFolders'; export * from './Settings/ui'; // @@ -73,6 +75,7 @@ export const defaultState = { qualityProfiles: qualityProfiles.defaultState, releaseProfiles: releaseProfiles.defaultState, remotePathMappings: remotePathMappings.defaultState, + rootFolders: rootFolders.defaultState, ui: ui.defaultState }; @@ -113,6 +116,7 @@ export const actionHandlers = handleThunks({ ...qualityProfiles.actionHandlers, ...releaseProfiles.actionHandlers, ...remotePathMappings.actionHandlers, + ...rootFolders.actionHandlers, ...ui.actionHandlers }); @@ -144,6 +148,7 @@ export const reducers = createHandleActions({ ...qualityProfiles.reducers, ...releaseProfiles.reducers, ...remotePathMappings.reducers, + ...rootFolders.reducers, ...ui.reducers }, defaultState, section); diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js index 44292271a..bc65fab5e 100644 --- a/frontend/src/Store/Actions/trackActions.js +++ b/frontend/src/Store/Actions/trackActions.js @@ -45,11 +45,6 @@ export const defaultState = { label: 'Path', isVisible: false }, - { - name: 'relativePath', - label: 'Relative Path', - isVisible: false - }, { name: 'duration', label: 'Duration', diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js index f9d9cc282..ebc6ad892 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js @@ -28,8 +28,8 @@ const columns = [ isVisible: true }, { - name: 'relativePath', - label: 'Relative Path', + name: 'path', + label: 'Path', isVisible: true }, { diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js index bfd90a44b..406d1a04f 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js @@ -65,7 +65,7 @@ function createMapStateToProps() { const trackFile = _.find(trackFiles.items, { id: track.trackFileId }); return { - relativePath: trackFile.relativePath, + path: trackFile.path, quality: trackFile.quality, ...track }; diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js index e475c115b..5c00e6858 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js @@ -10,7 +10,7 @@ function TrackFileEditorRow(props) { const { id, trackNumber, - relativePath, + path, quality, isSelected, onSelectedChange @@ -29,7 +29,7 @@ function TrackFileEditorRow(props) { - {relativePath} + {path} @@ -44,7 +44,7 @@ function TrackFileEditorRow(props) { TrackFileEditorRow.propTypes = { id: PropTypes.number.isRequired, trackNumber: PropTypes.string.isRequired, - relativePath: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, quality: PropTypes.object.isRequired, isSelected: PropTypes.bool, onSelectedChange: PropTypes.func.isRequired diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js index 94822299f..7b2bad819 100644 --- a/frontend/src/UnmappedFiles/UnmappedFilesTable.js +++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js @@ -69,7 +69,8 @@ class UnmappedFilesTable extends Component { sortDirection, onTableOptionChange, onSortPress, - deleteUnmappedFile, + isScanningFolders, + onAddMissingArtistsPress, ...otherProps } = this.props; @@ -80,6 +81,16 @@ class UnmappedFilesTable extends Component { return ( + + + + { // trackFiles could pick up mapped entries via signalR so filter again here @@ -27,6 +32,7 @@ function createMapStateToProps() { return { items: unmappedFiles, ...otherProps, + isScanningFolders, isSmallScreen: dimensionsState.isSmallScreen }; } @@ -49,6 +55,13 @@ function createMapDispatchToProps(dispatch, props) { deleteUnmappedFile(id) { dispatch(deleteTrackFile({ id })); + }, + + onAddMissingArtistsPress() { + dispatch(executeCommand({ + name: commandNames.RESCAN_FOLDERS, + filter: 'matched' + })); } }; } diff --git a/src/Lidarr.Api.V1/Albums/AlbumModule.cs b/src/Lidarr.Api.V1/Albums/AlbumModule.cs index 9fb2d9d70..889d05144 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumModule.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumModule.cs @@ -39,7 +39,7 @@ namespace Lidarr.Api.V1.Albums IMapCoversToLocal coverMapper, IUpgradableSpecification upgradableSpecification, IBroadcastSignalRMessage signalRBroadcaster, - ProfileExistsValidator profileExistsValidator, + QualityProfileExistsValidator qualityProfileExistsValidator, MetadataProfileExistsValidator metadataProfileExistsValidator) : base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster) @@ -54,7 +54,7 @@ namespace Lidarr.Api.V1.Albums Put("/monitor", x => SetAlbumsMonitored()); PostValidator.RuleFor(s => s.ForeignAlbumId).NotEmpty(); - PostValidator.RuleFor(s => s.Artist.QualityProfileId).SetValidator(profileExistsValidator); + PostValidator.RuleFor(s => s.Artist.QualityProfileId).SetValidator(qualityProfileExistsValidator); PostValidator.RuleFor(s => s.Artist.MetadataProfileId).SetValidator(metadataProfileExistsValidator); PostValidator.RuleFor(s => s.Artist.RootFolderPath).IsValidPath().When(s => s.Artist.Path.IsNullOrWhiteSpace()); PostValidator.RuleFor(s => s.Artist.ForeignArtistId).NotEmpty(); diff --git a/src/Lidarr.Api.V1/Artist/ArtistModule.cs b/src/Lidarr.Api.V1/Artist/ArtistModule.cs index 830d66262..362f155d0 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistModule.cs @@ -53,7 +53,7 @@ namespace Lidarr.Api.V1.Artist ArtistExistsValidator artistExistsValidator, ArtistAncestorValidator artistAncestorValidator, SystemFolderValidator systemFolderValidator, - ProfileExistsValidator profileExistsValidator, + QualityProfileExistsValidator qualityProfileExistsValidator, MetadataProfileExistsValidator metadataProfileExistsValidator) : base(signalRBroadcaster) { @@ -85,7 +85,7 @@ namespace Lidarr.Api.V1.Artist .SetValidator(systemFolderValidator) .When(s => !s.Path.IsNullOrWhiteSpace()); - SharedValidator.RuleFor(s => s.QualityProfileId).SetValidator(profileExistsValidator); + SharedValidator.RuleFor(s => s.QualityProfileId).SetValidator(qualityProfileExistsValidator); SharedValidator.RuleFor(s => s.MetadataProfileId).SetValidator(metadataProfileExistsValidator); PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace()); diff --git a/src/Lidarr.Api.V1/FileSystem/FileSystemModule.cs b/src/Lidarr.Api.V1/FileSystem/FileSystemModule.cs index 67da221d6..58f899324 100644 --- a/src/Lidarr.Api.V1/FileSystem/FileSystemModule.cs +++ b/src/Lidarr.Api.V1/FileSystem/FileSystemModule.cs @@ -62,7 +62,6 @@ namespace Lidarr.Api.V1.FileSystem return _diskScanService.GetAudioFiles(path).Select(f => new { Path = f.FullName, - RelativePath = path.GetRelativePath(f.FullName), Name = f.Name }); } diff --git a/src/Lidarr.Api.V1/ImportLists/ImportListModule.cs b/src/Lidarr.Api.V1/ImportLists/ImportListModule.cs index 4cb75fbae..8f3b1edb1 100644 --- a/src/Lidarr.Api.V1/ImportLists/ImportListModule.cs +++ b/src/Lidarr.Api.V1/ImportLists/ImportListModule.cs @@ -9,7 +9,7 @@ namespace Lidarr.Api.V1.ImportLists public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); public ImportListModule(ImportListFactory importListFactory, - ProfileExistsValidator profileExistsValidator, + QualityProfileExistsValidator qualityProfileExistsValidator, MetadataProfileExistsValidator metadataProfileExistsValidator) : base(importListFactory, "importlist", ResourceMapper) { @@ -17,7 +17,7 @@ namespace Lidarr.Api.V1.ImportLists Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.MetadataProfileId)); SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath(); - SharedValidator.RuleFor(c => c.QualityProfileId).SetValidator(profileExistsValidator); + SharedValidator.RuleFor(c => c.QualityProfileId).SetValidator(qualityProfileExistsValidator); SharedValidator.RuleFor(c => c.MetadataProfileId).SetValidator(metadataProfileExistsValidator); } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs index c9c7017b4..ce8b00680 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs @@ -71,7 +71,6 @@ namespace Lidarr.Api.V1.ManualImport { Id = resource.Id, Path = resource.Path, - RelativePath = resource.RelativePath, Name = resource.Name, Size = resource.Size, Artist = resource.Artist == null ? null : _artistService.GetArtist(resource.Artist.Id), diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs index 4d9c2653a..8470ae13b 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs @@ -14,7 +14,6 @@ namespace Lidarr.Api.V1.ManualImport public class ManualImportResource : RestResource { public string Path { get; set; } - public string RelativePath { get; set; } public string Name { get; set; } public long Size { get; set; } public ArtistResource Artist { get; set; } @@ -44,7 +43,6 @@ namespace Lidarr.Api.V1.ManualImport { Id = model.Id, Path = model.Path, - RelativePath = model.RelativePath, Name = model.Name, Size = model.Size, Artist = model.Artist.ToResource(), diff --git a/src/Lidarr.Api.V1/RootFolders/RootFolderModule.cs b/src/Lidarr.Api.V1/RootFolders/RootFolderModule.cs index cba25a230..731acf561 100644 --- a/src/Lidarr.Api.V1/RootFolders/RootFolderModule.cs +++ b/src/Lidarr.Api.V1/RootFolders/RootFolderModule.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using FluentValidation; using Lidarr.Http; +using Lidarr.Http.REST; using NzbDrone.Core.RootFolders; +using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; using NzbDrone.SignalR; @@ -18,7 +20,9 @@ namespace Lidarr.Api.V1.RootFolders MappedNetworkDriveValidator mappedNetworkDriveValidator, StartupFolderValidator startupFolderValidator, SystemFolderValidator systemFolderValidator, - FolderWritableValidator folderWritableValidator) + FolderWritableValidator folderWritableValidator, + QualityProfileExistsValidator qualityProfileExistsValidator, + MetadataProfileExistsValidator metadataProfileExistsValidator) : base(signalRBroadcaster) { _rootFolderService = rootFolderService; @@ -26,17 +30,29 @@ namespace Lidarr.Api.V1.RootFolders GetResourceAll = GetRootFolders; GetResourceById = GetRootFolder; CreateResource = CreateRootFolder; + UpdateResource = UpdateRootFolder; DeleteResource = DeleteFolder; SharedValidator.RuleFor(c => c.Path) - .Cascade(CascadeMode.StopOnFirstFailure) - .IsValidPath() - .SetValidator(rootFolderValidator) - .SetValidator(mappedNetworkDriveValidator) - .SetValidator(startupFolderValidator) - .SetValidator(pathExistsValidator) - .SetValidator(systemFolderValidator) - .SetValidator(folderWritableValidator); + .Cascade(CascadeMode.StopOnFirstFailure) + .IsValidPath() + .SetValidator(mappedNetworkDriveValidator) + .SetValidator(startupFolderValidator) + .SetValidator(pathExistsValidator) + .SetValidator(systemFolderValidator) + .SetValidator(folderWritableValidator); + + PostValidator.RuleFor(c => c.Path) + .SetValidator(rootFolderValidator); + + SharedValidator.RuleFor(c => c.Name) + .NotEmpty(); + + SharedValidator.RuleFor(c => c.DefaultMetadataProfileId) + .SetValidator(metadataProfileExistsValidator); + + SharedValidator.RuleFor(c => c.DefaultQualityProfileId) + .SetValidator(qualityProfileExistsValidator); } private RootFolderResource GetRootFolder(int id) @@ -51,9 +67,21 @@ namespace Lidarr.Api.V1.RootFolders return _rootFolderService.Add(model).Id; } + private void UpdateRootFolder(RootFolderResource rootFolderResource) + { + var model = rootFolderResource.ToModel(); + + if (model.Path != rootFolderResource.Path) + { + throw new BadRequestException("Cannot edit root folder path"); + } + + _rootFolderService.Update(model); + } + private List GetRootFolders() { - return _rootFolderService.AllWithUnmappedFolders().ToResource(); + return _rootFolderService.AllWithSpaceStats().ToResource(); } private void DeleteFolder(int id) diff --git a/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs b/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs index 2d19ef40e..563eb4c08 100644 --- a/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs +++ b/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs @@ -1,18 +1,23 @@ using System.Collections.Generic; using System.Linq; using Lidarr.Http.REST; +using NzbDrone.Core.Music; using NzbDrone.Core.RootFolders; namespace Lidarr.Api.V1.RootFolders { public class RootFolderResource : RestResource { + public string Name { get; set; } public string Path { get; set; } + public int DefaultMetadataProfileId { get; set; } + public int DefaultQualityProfileId { get; set; } + public MonitorTypes DefaultMonitorOption { get; set; } + public HashSet DefaultTags { get; set; } + public bool Accessible { get; set; } public long? FreeSpace { get; set; } public long? TotalSpace { get; set; } - - public List UnmappedFolders { get; set; } } public static class RootFolderResourceMapper @@ -28,11 +33,16 @@ namespace Lidarr.Api.V1.RootFolders { Id = model.Id, + Name = model.Name, Path = model.Path, + DefaultMetadataProfileId = model.DefaultMetadataProfileId, + DefaultQualityProfileId = model.DefaultQualityProfileId, + DefaultMonitorOption = model.DefaultMonitorOption, + DefaultTags = model.DefaultTags, + Accessible = model.Accessible, FreeSpace = model.FreeSpace, TotalSpace = model.TotalSpace, - UnmappedFolders = model.UnmappedFolders }; } @@ -46,12 +56,13 @@ namespace Lidarr.Api.V1.RootFolders return new RootFolder { Id = resource.Id, - + Name = resource.Name, Path = resource.Path, - //Accessible - //FreeSpace - //UnmappedFolders + DefaultMetadataProfileId = resource.DefaultMetadataProfileId, + DefaultQualityProfileId = resource.DefaultQualityProfileId, + DefaultMonitorOption = resource.DefaultMonitorOption, + DefaultTags = resource.DefaultTags }; } diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index fb3f3523d..a445e3c84 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -13,7 +13,6 @@ namespace Lidarr.Api.V1.TrackFiles { public int ArtistId { get; set; } public int AlbumId { get; set; } - public string RelativePath { get; set; } public string Path { get; set; } public long Size { get; set; } public DateTime DateAdded { get; set; } @@ -74,7 +73,6 @@ namespace Lidarr.Api.V1.TrackFiles ArtistId = artist.Id, AlbumId = model.AlbumId, Path = model.Path, - RelativePath = artist.Path.GetRelativePath(model.Path), Size = model.Size, DateAdded = model.DateAdded, Quality = model.Quality, diff --git a/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs b/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs index 34766eb7c..353233e82 100644 --- a/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs +++ b/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs @@ -17,7 +17,7 @@ namespace Lidarr.Api.V1.Tracks public int AlbumId { get; set; } public List TrackNumbers { get; set; } public int TrackFileId { get; set; } - public string RelativePath { get; set; } + public string Path { get; set; } public List Changes { get; set; } } @@ -36,7 +36,7 @@ namespace Lidarr.Api.V1.Tracks AlbumId = model.AlbumId, TrackNumbers = model.TrackNumbers.ToList(), TrackFileId = model.TrackFileId, - RelativePath = model.RelativePath, + Path = model.Path, Changes = model.Changes.Select(x => new TagDifference { Field = x.Key, diff --git a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs index d2bd23467..946669ff2 100644 --- a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs @@ -153,5 +153,15 @@ namespace NzbDrone.Common.Extensions { return string.Join(separator, source.Select(predicate)); } + + public static TSource MostCommon(this IEnumerable items) + { + return items.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key; + } + + public static TResult MostCommon(this IEnumerable items, Func predicate) + { + return items.Select(predicate).GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key; + } } } diff --git a/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs index b673d0490..b17b4a4b2 100644 --- a/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs +++ b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Linq; using FluentAssertions; using Moq; @@ -6,6 +7,7 @@ using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Core.DiskSpace; using NzbDrone.Core.Music; +using NzbDrone.Core.RootFolders; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -14,14 +16,20 @@ namespace NzbDrone.Core.Test.DiskSpace [TestFixture] public class DiskSpaceServiceFixture : CoreTest { - private string _artistFolder; - private string _artostFolder2; + private RootFolder _rootDir; + private string _artistFolder1; + private string _artistFolder2; [SetUp] public void SetUp() { - _artistFolder = @"G:\fasdlfsdf\artist".AsOsAgnostic(); - _artostFolder2 = @"G:\fasdlfsdf\artist2".AsOsAgnostic(); + _rootDir = new RootFolder { Path = @"G:\fasdlfsdf".AsOsAgnostic() }; + _artistFolder1 = Path.Combine(_rootDir.Path, "artist1"); + _artistFolder2 = Path.Combine(_rootDir.Path, "artist2"); + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List() { _rootDir }); Mocker.GetMock() .Setup(v => v.GetMounts()) @@ -59,9 +67,9 @@ namespace NzbDrone.Core.Test.DiskSpace [Test] public void should_check_diskspace_for_artist_folders() { - GivenArtist(new Artist { Path = _artistFolder }); + GivenArtist(new Artist { Path = _artistFolder1 }); - GivenExistingFolder(_artistFolder); + GivenExistingFolder(_artistFolder1); var freeSpace = Subject.GetFreeSpace(); @@ -71,10 +79,10 @@ namespace NzbDrone.Core.Test.DiskSpace [Test] public void should_check_diskspace_for_same_root_folder_only_once() { - GivenArtist(new Artist { Path = _artistFolder }, new Artist { Path = _artostFolder2 }); + GivenArtist(new Artist { Path = _artistFolder1 }, new Artist { Path = _artistFolder2 }); - GivenExistingFolder(_artistFolder); - GivenExistingFolder(_artostFolder2); + GivenExistingFolder(_artistFolder1); + GivenExistingFolder(_artistFolder2); var freeSpace = Subject.GetFreeSpace(); @@ -84,19 +92,6 @@ namespace NzbDrone.Core.Test.DiskSpace .Verify(v => v.GetAvailableSpace(It.IsAny()), Times.Once()); } - [Test] - public void should_not_check_diskspace_for_missing_artist_folders() - { - GivenArtist(new Artist { Path = _artistFolder }); - - var freeSpace = Subject.GetFreeSpace(); - - freeSpace.Should().BeEmpty(); - - Mocker.GetMock() - .Verify(v => v.GetAvailableSpace(It.IsAny()), Times.Never()); - } - [TestCase("/boot")] [TestCase("/var/lib/rancher")] [TestCase("/var/lib/rancher/volumes")] @@ -114,6 +109,10 @@ namespace NzbDrone.Core.Test.DiskSpace .Setup(v => v.GetMounts()) .Returns(new List { mount.Object }); + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List()); + var freeSpace = Subject.GetFreeSpace(); freeSpace.Should().BeEmpty(); diff --git a/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs index 8ecf75845..70fecacbc 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs @@ -9,7 +9,6 @@ using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.TrackImport; @@ -41,11 +40,15 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests .Build(); Mocker.GetMock() - .Setup(s => s.GetBestRootFolderPath(It.IsAny())) - .Returns(_rootFolder); + .Setup(s => s.GetBestRootFolder(It.IsAny())) + .Returns(new RootFolder { Path = _rootFolder }); + + Mocker.GetMock() + .Setup(s => s.GetArtists(It.IsAny>())) + .Returns(new List()); Mocker.GetMock() - .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List>()); Mocker.GetMock() @@ -57,8 +60,8 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests .Returns(new List()); Mocker.GetMock() - .Setup(v => v.FilterUnchangedFiles(It.IsAny>(), It.IsAny(), It.IsAny())) - .Returns((List files, Artist artist, FilterFilesType filter) => files); + .Setup(v => v.FilterUnchangedFiles(It.IsAny>(), It.IsAny())) + .Returns((List files, FilterFilesType filter) => files); } private void GivenRootFolder(params string[] subfolders) @@ -112,7 +115,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests [Test] public void should_not_scan_if_root_folder_does_not_exist() { - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); ExceptionVerification.ExpectedWarns(1); @@ -120,15 +123,18 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests .Verify(v => v.FolderExists(_artist.Path), Times.Never()); Mocker.GetMock() - .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Never()); + .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Never()); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } [Test] - public void should_not_scan_if_artist_root_folder_is_empty() + public void should_not_scan_if_root_folder_is_empty() { GivenRootFolder(); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); ExceptionVerification.ExpectedWarns(1); @@ -136,72 +142,23 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests .Verify(v => v.FolderExists(_artist.Path), Times.Never()); Mocker.GetMock() - .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Never()); + .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Never()); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.IsAny>(), _artist, FilterFilesType.Known, true), Times.Never()); + .Verify(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); } [Test] - public void should_create_if_artist_folder_does_not_exist_but_create_folder_enabled() + public void should_clean_if_folder_does_not_exist() { GivenRootFolder(_otherArtistFolder); - Mocker.GetMock() - .Setup(s => s.CreateEmptyArtistFolders) - .Returns(true); - - Subject.Scan(_artist); - - DiskProvider.FolderExists(_artist.Path).Should().BeTrue(); - } - - [Test] - public void should_not_create_if_artist_folder_does_not_exist_and_create_folder_disabled() - { - GivenRootFolder(_otherArtistFolder); - - Mocker.GetMock() - .Setup(s => s.CreateEmptyArtistFolders) - .Returns(false); - - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); DiskProvider.FolderExists(_artist.Path).Should().BeFalse(); - } - - [Test] - public void should_clean_but_not_import_if_artist_folder_does_not_exist() - { - GivenRootFolder(_otherArtistFolder); - - Subject.Scan(_artist); - - DiskProvider.FolderExists(_artist.Path).Should().BeFalse(); - - Mocker.GetMock() - .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Once()); - - Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.IsAny>(), _artist, FilterFilesType.Known, true), Times.Never()); - } - - [Test] - public void should_clean_but_not_import_if_artist_folder_does_not_exist_and_create_folder_enabled() - { - GivenRootFolder(_otherArtistFolder); - - Mocker.GetMock() - .Setup(s => s.CreateEmptyArtistFolders) - .Returns(true); - - Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Once()); - - Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.IsAny>(), _artist, FilterFilesType.Known, true), Times.Never()); + .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Once()); } [Test] @@ -215,10 +172,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -235,10 +192,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -253,10 +210,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -276,10 +233,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 2", "s02e02.flac"), }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 4), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 4), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -292,10 +249,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Album 1", ".t01.mp3") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -311,10 +268,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -331,10 +288,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -348,10 +305,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -365,10 +322,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -384,10 +341,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } [Test] @@ -402,20 +359,20 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Path.Combine(_artist.Path, "24 The Status Quo Combustion.flac") }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); } private void GivenRejections() { Mocker.GetMock() - .Setup(x => x.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((List fileList, Artist artist, FilterFilesType filter, bool includeExisting) => + .Setup(x => x.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((List fileList, IdentificationOverrides idOverrides, ImportDecisionMakerInfo idInfo, ImportDecisionMakerConfig idConfig) => fileList.Select(x => new LocalTrack { - Artist = artist, + Artist = _artist, Path = x.FullName, Modified = x.LastWriteTimeUtc, FileTrackInfo = new ParsedTrackInfo() @@ -437,7 +394,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests GivenKnownFiles(new List()); GivenRejections(); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() .Verify(x => x.AddMany(It.Is>(l => l.Select(t => t.Path).SequenceEqual(files))), @@ -457,7 +414,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests GivenKnownFiles(files.GetRange(1, 1)); GivenRejections(); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() .Verify(x => x.AddMany(It.Is>(l => l.Select(t => t.Path).SequenceEqual(files.GetRange(0, 1)))), @@ -477,7 +434,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests GivenKnownFiles(files); GivenRejections(); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() .Verify(x => x.AddMany(It.Is>(l => l.Count == 0)), @@ -501,7 +458,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests GivenKnownFiles(files); GivenRejections(); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() .Verify(x => x.Update(It.Is>(l => l.Count == 0)), @@ -525,7 +482,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests GivenKnownFiles(files); GivenRejections(); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() .Verify(x => x.Update(It.Is>(l => l.Count == 2)), @@ -556,14 +513,14 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests .Build(); Mocker.GetMock() - .Setup(x => x.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List> { new ImportDecision(localTrack, new Rejection("Reject")) }); - Subject.Scan(_artist); + Subject.Scan(new List { _artist.Path }); Mocker.GetMock() .Verify(x => x.Update(It.Is>( - l => l.Count == 1 && + l => l.Count == 1 && l[0].Path == localTrack.Path && l[0].Modified == localTrack.Modified && l[0].Size == localTrack.Size && diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs index 3b9aaa3d4..887eef837 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs @@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null)) + .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(imported); Mocker.GetMock() @@ -130,8 +130,8 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); Mocker.GetMock() - .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never()); + .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); VerifyNoImport(); } @@ -181,7 +181,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null)) + .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(imported); Mocker.GetMock() @@ -238,7 +238,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null)) + .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(imported); Mocker.GetMock() @@ -278,7 +278,7 @@ namespace NzbDrone.Core.Test.MediaFiles imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), null)) + .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(imported); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs index 18a660a1d..bea7f05ba 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs @@ -123,7 +123,6 @@ namespace NzbDrone.Core.Test.MediaFiles { VerifyData(); var firstReleaseFiles = Subject.GetFilesWithBasePath(dir.AsOsAgnostic()); - VerifyEagerLoaded(firstReleaseFiles); firstReleaseFiles.Should().HaveCount(2); } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs index f3443623f..90cd316df 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs @@ -17,19 +17,8 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests [TestFixture] public class FilterFixture : FileSystemTest { - private Artist _artist; private DateTime _lastWrite = new DateTime(2019, 1, 1); - [SetUp] - public void Setup() - { - _artist = new Artist - { - Id = 10, - Path = @"C:\".AsOsAgnostic() - }; - } - private List GivenFiles(string[] files) { foreach (var file in files) @@ -52,10 +41,10 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests }); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List()); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().BeEquivalentTo(files); + Subject.FilterUnchangedFiles(files, filter).Should().BeEquivalentTo(files); } [TestCase(FilterFilesType.Known)] @@ -70,14 +59,14 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests }); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(files.Select(f => new TrackFile { Path = f.FullName, Modified = _lastWrite }).ToList()); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().BeEmpty(); + Subject.FilterUnchangedFiles(files, filter).Should().BeEmpty(); } [TestCase(FilterFilesType.Known)] @@ -92,7 +81,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests }); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List { new TrackFile @@ -102,8 +91,8 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests } }); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); - Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); } [TestCase(FilterFilesType.Known)] @@ -120,7 +109,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests }); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List { new TrackFile @@ -130,8 +119,8 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests } }); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); - Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); } [TestCase(FilterFilesType.Known)] @@ -148,7 +137,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests }); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List { new TrackFile @@ -158,7 +147,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests } }); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(3); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(3); } [TestCase(FilterFilesType.Known)] @@ -171,12 +160,12 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests }); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List()); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(1); - Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain(files.First().FullName.ToLower()); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().Contain(files.First()); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(1); + Subject.FilterUnchangedFiles(files, filter).Select(x => x.FullName).Should().NotContain(files.First().FullName.ToLower()); + Subject.FilterUnchangedFiles(files, filter).Should().Contain(files.First()); } [TestCase(FilterFilesType.Known)] @@ -190,7 +179,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List { new TrackFile @@ -201,8 +190,8 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests } }); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); - Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); } [TestCase(FilterFilesType.Matched)] @@ -215,7 +204,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List { new TrackFile @@ -227,8 +216,8 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests } }); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(3); - Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().Contain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(3); + Subject.FilterUnchangedFiles(files, filter).Select(x => x.FullName).Should().Contain("C:\\file2.avi".AsOsAgnostic()); } [TestCase(FilterFilesType.Matched)] @@ -241,7 +230,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List { new TrackFile @@ -253,8 +242,8 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests } }); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); - Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); } [TestCase(FilterFilesType.Known)] @@ -268,7 +257,7 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); Mocker.GetMock() - .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Setup(c => c.GetFileWithPath(It.IsAny>())) .Returns(new List { new TrackFile @@ -279,8 +268,8 @@ namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests } }); - Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(3); - Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().Contain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, filter).Should().HaveCount(3); + Subject.FilterUnchangedFiles(files, filter).Select(x => x.FullName).Should().Contain("C:\\file2.avi".AsOsAgnostic()); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs index f1c8ba681..646291f74 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Test.MediaFiles { public class MediaFileTableCleanupServiceFixture : CoreTest { - private readonly string _deletedPath = @"c:\ANY FILE STARTING WITH THIS PATH IS CONSIDERED DELETED!".AsOsAgnostic(); + private readonly string _DELETED_PATH = @"c:\ANY FILE STARTING WITH THIS PATH IS CONSIDERED DELETED!".AsOsAgnostic(); private List _tracks; private Artist _artist; @@ -62,7 +62,7 @@ namespace NzbDrone.Core.Test.MediaFiles GivenTrackFiles(trackFiles); - Subject.Clean(_artist, FilesOnDisk(trackFiles)); + Subject.Clean(_artist.Path, FilesOnDisk(trackFiles)); Mocker.GetMock() .Verify(c => c.DeleteMany(It.Is>(x => x.Count == 0), DeleteMediaFileReason.MissingFromDisk), Times.Once()); @@ -75,15 +75,15 @@ namespace NzbDrone.Core.Test.MediaFiles .All() .With(x => x.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName())) .Random(2) - .With(c => c.Path = Path.Combine(_deletedPath, Path.GetRandomFileName())) + .With(c => c.Path = Path.Combine(_DELETED_PATH, Path.GetRandomFileName())) .Build(); GivenTrackFiles(trackFiles); - Subject.Clean(_artist, FilesOnDisk(trackFiles.Where(e => !e.Path.StartsWith(_deletedPath)))); + Subject.Clean(_artist.Path, FilesOnDisk(trackFiles.Where(e => !e.Path.StartsWith(_DELETED_PATH)))); Mocker.GetMock() - .Verify(c => c.DeleteMany(It.Is>(e => e.Count == 2 && e.All(y => y.Path.StartsWith(_deletedPath))), DeleteMediaFileReason.MissingFromDisk), Times.Once()); + .Verify(c => c.DeleteMany(It.Is>(e => e.Count == 2 && e.All(y => y.Path.StartsWith(_DELETED_PATH))), DeleteMediaFileReason.MissingFromDisk), Times.Once()); } [Test] @@ -96,7 +96,7 @@ namespace NzbDrone.Core.Test.MediaFiles GivenTrackFiles(trackFiles); - Subject.Clean(_artist, new List()); + Subject.Clean(_artist.Path, new List()); Mocker.GetMock() .Verify(c => c.SetFileIds(It.Is>(e => e.Count == 10 && e.All(y => y.TrackFileId == 0))), Times.Once()); @@ -112,7 +112,7 @@ namespace NzbDrone.Core.Test.MediaFiles GivenTrackFiles(trackFiles); - Subject.Clean(_artist, FilesOnDisk(trackFiles)); + Subject.Clean(_artist.Path, FilesOnDisk(trackFiles)); Mocker.GetMock().Verify(c => c.SetFileIds(It.Is>(x => x.Count == 0)), Times.Once()); } diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs index a6fd25472..687b84923 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs @@ -13,7 +13,7 @@ using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification { [TestFixture] - public class AlbumDistanceFixture : CoreTest + public class AlbumDistanceFixture : CoreTest { private ArtistMetadata _artist; @@ -28,13 +28,13 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification private List GivenTracks(int count) { - return Builder - .CreateListOfSize(count) - .All() - .With(x => x.ArtistMetadata = _artist) - .With(x => x.MediumNumber = 1) - .Build() - .ToList(); + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = _artist) + .With(x => x.MediumNumber = 1) + .Build() + .ToList(); } private LocalTrack GivenLocalTrack(Track track, AlbumRelease release) @@ -102,7 +102,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification private TrackMapping GivenMapping(List local, List remote) { var mapping = new TrackMapping(); - var distances = local.Zip(remote, (l, r) => Tuple.Create(r, Subject.TrackDistance(l, r, Subject.GetTotalTrackNumber(r, remote)))); + var distances = local.Zip(remote, (l, r) => Tuple.Create(r, DistanceCalculator.TrackDistance(l, r, DistanceCalculator.GetTotalTrackNumber(r, remote)))); mapping.Mapping = local.Zip(distances, (l, r) => new { l, r }).ToDictionary(x => x.l, x => x.r); mapping.LocalExtra = local.Except(mapping.Mapping.Keys).ToList(); mapping.MBExtra = remote.Except(mapping.Mapping.Values.Select(x => x.Item1)).ToList(); @@ -118,7 +118,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var localTracks = GivenLocalTracks(tracks, release); var mapping = GivenMapping(localTracks, tracks); - Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); } [Test] @@ -130,7 +130,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification localTracks.RemoveAt(1); var mapping = GivenMapping(localTracks, tracks); - var dist = Subject.AlbumReleaseDistance(localTracks, release, mapping); + var dist = DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping); dist.NormalizedDistance().Should().NotBe(0.0); dist.NormalizedDistance().Should().BeLessThan(0.2); } @@ -148,7 +148,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification .With(x => x.Name = "different artist") .Build(); - Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().NotBe(0.0); + DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().NotBe(0.0); } [Test] @@ -165,7 +165,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification .With(x => x.ForeignArtistId = "89ad4ac3-39f7-470e-963a-56509c546377") .Build(); - Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); } // TODO: there are a couple more VA tests in beets but we don't support VA yet anyway @@ -178,7 +178,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification localTracks = new[] { 1, 3, 2 }.Select(x => localTracks[x - 1]).ToList(); var mapping = GivenMapping(localTracks, tracks); - var dist = Subject.AlbumReleaseDistance(localTracks, release, mapping); + var dist = DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping); dist.NormalizedDistance().Should().NotBe(0.0); dist.NormalizedDistance().Should().BeLessThan(0.2); } @@ -193,7 +193,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var localTracks = GivenLocalTracks(tracks, release); var mapping = GivenMapping(localTracks, tracks); - Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); } [Test] @@ -209,7 +209,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var mapping = GivenMapping(localTracks, tracks); - Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); } private static DateTime?[] dates = new DateTime?[] { null, new DateTime(2007, 1, 1), DateTime.Now }; @@ -225,7 +225,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification release.Album.Value.ReleaseDate = null; release.ReleaseDate = releaseDate; - var result = Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance(); + var result = DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance(); if (!releaseDate.HasValue || (localTracks[0].FileTrackInfo.Year == (releaseDate?.Year ?? 0))) { @@ -248,7 +248,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification release.Album.Value.ReleaseDate = albumDate; release.ReleaseDate = null; - var result = Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance(); + var result = DistanceCalculator.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance(); if (!albumDate.HasValue || (localTracks[0].FileTrackInfo.Year == (albumDate?.Year ?? 0))) { diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/CandidateServiceFixture.cs similarity index 83% rename from src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs rename to src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/CandidateServiceFixture.cs index bc9f73cb9..b016be75c 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/CandidateServiceFixture.cs @@ -4,6 +4,7 @@ using FizzWare.NBuilder; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.MediaFiles.TrackImport.Identification; using NzbDrone.Core.Music; using NzbDrone.Core.Parser; @@ -13,7 +14,7 @@ using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification { [TestFixture] - public class GetCandidatesFixture : CoreTest + public class GetCandidatesFixture : CoreTest { private ArtistMetadata _artist; @@ -28,12 +29,12 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification private List GivenTracks(int count) { - return Builder - .CreateListOfSize(count) - .All() - .With(x => x.ArtistMetadata = _artist) - .Build() - .ToList(); + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = _artist) + .Build() + .ToList(); } private ParsedTrackInfo GivenParsedTrackInfo(Track track, AlbumRelease release) @@ -108,12 +109,12 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification Mocker.GetMock() .Setup(x => x.Lookup(It.IsAny>(), It.IsAny())) .Callback((List x, double thres) => - { - foreach (var track in x) { - track.AcoustIdResults = null; - } - }); + foreach (var track in x) + { + track.AcoustIdResults = null; + } + }); Mocker.GetMock() .Setup(x => x.GetReleasesByRecordingIds(It.IsAny>())) @@ -121,7 +122,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var local = GivenLocalAlbumRelease(); - Subject.GetCandidatesFromFingerprint(local, null, null, null, false).Should().BeEquivalentTo(new List()); + Subject.GetDbCandidatesFromFingerprint(local, null, false).Should().BeEquivalentTo(new List()); } [Test] @@ -131,8 +132,12 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var release = GivenAlbumRelease("album", tracks); var localTracks = GivenLocalTracks(tracks, release); var localAlbumRelease = new LocalAlbumRelease(localTracks); + var idOverrides = new IdentificationOverrides + { + AlbumRelease = release + }; - Subject.GetCandidatesFromTags(localAlbumRelease, null, null, release, false).Should().BeEquivalentTo( + Subject.GetDbCandidatesFromTags(localAlbumRelease, idOverrides, false).Should().BeEquivalentTo( new List { new CandidateAlbumRelease(release) }); } @@ -149,7 +154,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification .Setup(x => x.GetReleaseByForeignReleaseId("xxx", true)) .Returns(release); - Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null, false).Should().BeEquivalentTo( + Subject.GetDbCandidatesFromTags(localAlbumRelease, null, false).Should().BeEquivalentTo( new List { new CandidateAlbumRelease(release) }); } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs index d2d6bc181..24481908d 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs @@ -9,6 +9,9 @@ using Newtonsoft.Json; using NUnit.Framework; using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; +using NzbDrone.Core.ImportLists.Exclusions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; using NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators; using NzbDrone.Core.MediaFiles.TrackImport.Identification; @@ -32,7 +35,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification private AddArtistService _addArtistService; private RefreshArtistService _refreshArtistService; - private IdentificationService _subject; + private IdentificationService _Subject; [SetUp] public void SetUp() @@ -45,6 +48,8 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); Mocker.GetMock().Setup(x => x.Exists(It.IsAny())).Returns(true); @@ -54,6 +59,8 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); @@ -69,6 +76,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification Mocker.GetMock().Setup(x => x.Validate(It.IsAny())).Returns(new ValidationResult()); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); // set up the augmenters List> aggregators = new List> @@ -78,7 +86,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification Mocker.SetConstant>>(aggregators); Mocker.SetConstant(Mocker.Resolve()); - _subject = Mocker.Resolve(); + _Subject = Mocker.Resolve(); } private void GivenMetadataProfile(MetadataProfile profile) @@ -131,9 +139,9 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification Mocker.GetMock() .Setup(x => x.Lookup(It.IsAny>(), It.IsAny())) .Callback((List track, double thres) => - { - track.ForEach(x => x.AcoustIdResults = fingerprints.SingleOrDefault(f => f.Path == x.Path).AcoustIdResults); - }); + { + track.ForEach(x => x.AcoustIdResults = fingerprints.SingleOrDefault(f => f.Path == x.Path).AcoustIdResults); + }); } public static class IdTestCaseFactory @@ -164,7 +172,6 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification // these are slow to run so only do so manually [Explicit] - [Test] [TestCaseSource(typeof(IdTestCaseFactory), "TestCases")] public void should_match_tracks(string file) { @@ -173,6 +180,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var artists = GivenArtists(testcase.LibraryArtists); var specifiedArtist = artists.SingleOrDefault(x => x.Metadata.Value.ForeignArtistId == testcase.Artist); + var idOverrides = new IdentificationOverrides { Artist = specifiedArtist }; var tracks = testcase.Tracks.Select(x => new LocalTrack { @@ -185,7 +193,14 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification GivenFingerprints(testcase.Fingerprints); } - var result = _subject.Identify(tracks, specifiedArtist, null, null, testcase.NewDownload, testcase.SingleRelease, false); + var config = new ImportDecisionMakerConfig + { + NewDownload = testcase.NewDownload, + SingleRelease = testcase.SingleRelease, + IncludeExisting = false + }; + + var result = _Subject.Identify(tracks, idOverrides, config); TestLogger.Debug($"Found releases:\n{result.Where(x => x.AlbumRelease != null).Select(x => x.AlbumRelease?.ForeignReleaseId).ToJson()}"); diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs index 961845f95..fa882f932 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs @@ -10,7 +10,7 @@ using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification { [TestFixture] - public class TrackDistanceFixture : CoreTest + public class TrackDistanceFixture : CoreTest { private Track GivenTrack(string title) { @@ -53,7 +53,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var track = GivenTrack("one"); var localTrack = GivenLocalTrack(track); - Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + DistanceCalculator.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); } [Test] @@ -63,7 +63,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var localTrack = GivenLocalTrack(track); localTrack.FileTrackInfo.Title = "one (feat. two)"; - Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + DistanceCalculator.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); } [Test] @@ -73,7 +73,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var localTrack = GivenLocalTrack(track); localTrack.FileTrackInfo.CleanTitle = "foo"; - Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); + DistanceCalculator.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); } [Test] @@ -83,7 +83,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var localTrack = GivenLocalTrack(track); localTrack.FileTrackInfo.ArtistTitle = "foo"; - Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); + DistanceCalculator.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); } [Test] @@ -93,7 +93,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var localTrack = GivenLocalTrack(track); localTrack.FileTrackInfo.ArtistTitle = "Various Artists"; - Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + DistanceCalculator.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs index d11534897..0b3698c79 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs @@ -27,9 +27,13 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport private List _fileInfos; private LocalTrack _localTrack; private Artist _artist; + private Album _album; private AlbumRelease _albumRelease; private QualityModel _quality; + private IdentificationOverrides _idOverrides; + private ImportDecisionMakerConfig _idConfig; + private Mock> _albumpass1; private Mock> _albumpass2; private Mock> _albumpass3; @@ -82,10 +86,16 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport _fail3.Setup(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny())).Returns(Decision.Reject("_fail3")); _artist = Builder.CreateNew() - .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) - .Build(); + .With(e => e.QualityProfileId = 1) + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + .Build(); + + _album = Builder.CreateNew() + .With(x => x.Artist = _artist) + .Build(); _albumRelease = Builder.CreateNew() + .With(x => x.Album = _album) .Build(); _quality = new QualityModel(Quality.MP3_256); @@ -98,11 +108,18 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() }; + _idOverrides = new IdentificationOverrides + { + Artist = _artist + }; + + _idConfig = new ImportDecisionMakerConfig(); + GivenAudioFiles(new List { @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() }); Mocker.GetMock() - .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((List tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) => + .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((List tracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) => { var ret = new LocalAlbumRelease(tracks); ret.AlbumRelease = _albumRelease; @@ -110,8 +127,8 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport }); Mocker.GetMock() - .Setup(c => c.FilterUnchangedFiles(It.IsAny>(), It.IsAny(), It.IsAny())) - .Returns((List files, Artist artist, FilterFilesType filter) => files); + .Setup(c => c.FilterUnchangedFiles(It.IsAny>(), It.IsAny())) + .Returns((List files, FilterFilesType filter) => files); GivenSpecifications(_albumpass1); } @@ -145,10 +162,12 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport public void should_call_all_album_specifications() { var downloadClientItem = Builder.CreateNew().Build(); + var itemInfo = new ImportDecisionMakerInfo { DownloadClientItem = downloadClientItem }; + GivenAugmentationSuccess(); GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); - Subject.GetImportDecisions(_fileInfos, new Artist(), null, null, downloadClientItem, null, FilterFilesType.None, false, false, false); + Subject.GetImportDecisions(_fileInfos, null, itemInfo, _idConfig); _albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); _albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); @@ -162,10 +181,12 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport public void should_call_all_track_specifications_if_album_accepted() { var downloadClientItem = Builder.CreateNew().Build(); + var itemInfo = new ImportDecisionMakerInfo { DownloadClientItem = downloadClientItem }; + GivenAugmentationSuccess(); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - Subject.GetImportDecisions(_fileInfos, new Artist(), null, null, downloadClientItem, null, FilterFilesType.None, false, false, false); + Subject.GetImportDecisions(_fileInfos, null, itemInfo, _idConfig); _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Once()); @@ -179,11 +200,13 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport public void should_call_no_track_specifications_if_album_rejected() { var downloadClientItem = Builder.CreateNew().Build(); + var itemInfo = new ImportDecisionMakerInfo { DownloadClientItem = downloadClientItem }; + GivenAugmentationSuccess(); GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - Subject.GetImportDecisions(_fileInfos, new Artist(), null, null, downloadClientItem, null, FilterFilesType.None, false, false, false); + Subject.GetImportDecisions(_fileInfos, null, itemInfo, _idConfig); _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Never()); _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny(), It.IsAny()), Times.Never()); @@ -199,7 +222,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport GivenSpecifications(_albumfail1); GivenSpecifications(_pass1); - var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + var result = Subject.GetImportDecisions(_fileInfos, null, null, _idConfig); result.Single().Approved.Should().BeFalse(); } @@ -210,7 +233,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport GivenSpecifications(_albumpass1); GivenSpecifications(_fail1); - var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + var result = Subject.GetImportDecisions(_fileInfos, null, null, _idConfig); result.Single().Approved.Should().BeFalse(); } @@ -221,7 +244,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport GivenSpecifications(_albumpass1, _albumfail1, _albumpass2, _albumpass3); GivenSpecifications(_pass1, _pass2, _pass3); - var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + var result = Subject.GetImportDecisions(_fileInfos, null, null, _idConfig); result.Single().Approved.Should().BeFalse(); } @@ -232,7 +255,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); GivenSpecifications(_pass1, _fail1, _pass2, _pass3); - var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + var result = Subject.GetImportDecisions(_fileInfos, null, null, _idConfig); result.Single().Approved.Should().BeFalse(); } @@ -244,7 +267,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); GivenSpecifications(_pass1, _pass2, _pass3); - var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + var result = Subject.GetImportDecisions(_fileInfos, null, null, _idConfig); result.Single().Approved.Should().BeTrue(); } @@ -255,7 +278,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport GivenAugmentationSuccess(); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + var result = Subject.GetImportDecisions(_fileInfos, null, null, _idConfig); result.Single().Rejections.Should().HaveCount(3); } @@ -275,7 +298,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic() }); - Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false); + var decisions = Subject.GetImportDecisions(_fileInfos, _idOverrides, null, _idConfig); Mocker.GetMock() .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_fileInfos.Count)); @@ -296,13 +319,13 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport }); Mocker.GetMock() - .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((List tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) => - { - return new List { new LocalAlbumRelease(tracks) }; - }); + .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((List tracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) => + { + return new List { new LocalAlbumRelease(tracks) }; + }); - var decisions = Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false); + var decisions = Subject.GetImportDecisions(_fileInfos, _idOverrides, null, _idConfig); Mocker.GetMock() .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_fileInfos.Count)); @@ -323,7 +346,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic() }); - var decisions = Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false); + var decisions = Subject.GetImportDecisions(_fileInfos, _idOverrides, null, _idConfig); Mocker.GetMock() .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_fileInfos.Count)); @@ -344,7 +367,7 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic() }); - Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false).Should().HaveCount(1); + Subject.GetImportDecisions(_fileInfos, _idOverrides, null, _idConfig).Should().HaveCount(1); ExceptionVerification.ExpectedErrors(1); } diff --git a/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs index fcb2cf28d..8f153af74 100644 --- a/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs @@ -127,5 +127,91 @@ namespace NzbDrone.Core.Test.MusicTests ExceptionVerification.ExpectedErrors(1); } + + [Test] + public void should_disambiguate_if_artist_folder_exists() + { + var newArtist = new Artist + { + ForeignArtistId = "ce09ea31-3d4a-4487-a797-e315175457a0", + Path = @"C:\Test\Music\Name1", + }; + + _fakeArtist.Metadata = Builder.CreateNew().With(x => x.Disambiguation = "Disambiguation").Build(); + + GivenValidArtist(newArtist.ForeignArtistId); + GivenValidPath(); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path)) + .Returns(true); + + var artist = Subject.AddArtist(newArtist); + artist.Path.Should().Be(newArtist.Path + " (Disambiguation)"); + } + + [Test] + public void should_disambiguate_with_numbers_if_artist_folder_still_exists() + { + var newArtist = new Artist + { + ForeignArtistId = "ce09ea31-3d4a-4487-a797-e315175457a0", + Path = @"C:\Test\Music\Name1", + }; + + _fakeArtist.Metadata = Builder.CreateNew().With(x => x.Disambiguation = "Disambiguation").Build(); + + GivenValidArtist(newArtist.ForeignArtistId); + GivenValidPath(); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path)) + .Returns(true); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path + " (Disambiguation)")) + .Returns(true); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path + " (Disambiguation) (1)")) + .Returns(true); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path + " (Disambiguation) (2)")) + .Returns(true); + + var artist = Subject.AddArtist(newArtist); + artist.Path.Should().Be(newArtist.Path + " (Disambiguation) (3)"); + } + + [Test] + public void should_disambiguate_with_numbers_if_artist_folder_exists_and_no_disambiguation() + { + var newArtist = new Artist + { + ForeignArtistId = "ce09ea31-3d4a-4487-a797-e315175457a0", + Path = @"C:\Test\Music\Name1", + }; + + _fakeArtist.Metadata = Builder.CreateNew().With(x => x.Disambiguation = string.Empty).Build(); + + GivenValidArtist(newArtist.ForeignArtistId); + GivenValidPath(); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path)) + .Returns(true); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path + " (1)")) + .Returns(true); + + Mocker.GetMock() + .Setup(x => x.ArtistPathExists(newArtist.Path + " (2)")) + .Returns(true); + + var artist = Subject.AddArtist(newArtist); + artist.Path.Should().Be(newArtist.Path + " (3)"); + } } } diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs index 3f1f093e3..c3f384ae6 100644 --- a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs @@ -11,6 +11,7 @@ using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Music; using NzbDrone.Core.Music.Commands; using NzbDrone.Core.Music.Events; +using NzbDrone.Core.RootFolders; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -48,8 +49,8 @@ namespace NzbDrone.Core.Test.MusicTests .Build(); Mocker.GetMock(MockBehavior.Strict) - .Setup(s => s.GetArtist(_artist.Id)) - .Returns(_artist); + .Setup(s => s.GetArtists(new List { _artist.Id })) + .Returns(new List { _artist }); Mocker.GetMock(MockBehavior.Strict) .Setup(s => s.InsertMany(It.IsAny>())); @@ -69,6 +70,10 @@ namespace NzbDrone.Core.Test.MusicTests Mocker.GetMock() .Setup(x => x.FindByForeignId(It.IsAny>())) .Returns(new List()); + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List()); } private void GivenNewArtistInfo(Artist artist) diff --git a/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs index 080b84630..00c40579f 100644 --- a/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs @@ -8,6 +8,7 @@ using NzbDrone.Core.ImportLists; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Music; using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Core.RootFolders; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.Profiles.Metadata @@ -122,8 +123,14 @@ namespace NzbDrone.Core.Test.Profiles.Metadata .With(c => c.MetadataProfileId = 1) .Build().ToList(); + var rootFolders = Builder.CreateListOfSize(2) + .All() + .With(f => f.DefaultMetadataProfileId = 1) + .BuildList(); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); Assert.Throws(() => Subject.Delete(profile.Id)); @@ -148,8 +155,14 @@ namespace NzbDrone.Core.Test.Profiles.Metadata .With(c => c.MetadataProfileId = profile.Id) .Build().ToList(); + var rootFolders = Builder.CreateListOfSize(2) + .All() + .With(f => f.DefaultMetadataProfileId = 1) + .BuildList(); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); Assert.Throws(() => Subject.Delete(profile.Id)); @@ -158,7 +171,39 @@ namespace NzbDrone.Core.Test.Profiles.Metadata } [Test] - public void should_delete_profile_if_not_assigned_to_artist_or_import_list() + public void should_not_be_able_to_delete_profile_if_assigned_to_root_folder() + { + var profile = Builder.CreateNew() + .With(p => p.Id = 2) + .Build(); + + var artistList = Builder.CreateListOfSize(3) + .All() + .With(c => c.MetadataProfileId = 1) + .Build().ToList(); + + var importLists = Builder.CreateListOfSize(2) + .All() + .With(c => c.MetadataProfileId = 1) + .Build().ToList(); + + var rootFolders = Builder.CreateListOfSize(2) + .Random(1) + .With(f => f.DefaultMetadataProfileId = profile.Id) + .BuildList(); + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); + Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); + + Assert.Throws(() => Subject.Delete(profile.Id)); + + Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_profile_if_not_assigned_to_artist_import_list_or_root_folder() { var profile = Builder.CreateNew() .With(p => p.Id = 1) @@ -174,8 +219,14 @@ namespace NzbDrone.Core.Test.Profiles.Metadata .With(c => c.MetadataProfileId = 2) .Build().ToList(); + var rootFolders = Builder.CreateListOfSize(2) + .All() + .With(f => f.DefaultMetadataProfileId = 2) + .BuildList(); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); Subject.Delete(1); diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs index 437e3d19d..4044f658f 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.ImportLists; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Music; using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.RootFolders; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.Profiles @@ -56,8 +57,14 @@ namespace NzbDrone.Core.Test.Profiles .With(c => c.ProfileId = 1) .Build().ToList(); + var rootFolders = Builder.CreateListOfSize(2) + .All() + .With(f => f.DefaultQualityProfileId = 1) + .BuildList(); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); Assert.Throws(() => Subject.Delete(profile.Id)); @@ -82,8 +89,14 @@ namespace NzbDrone.Core.Test.Profiles .With(c => c.ProfileId = profile.Id) .Build().ToList(); + var rootFolders = Builder.CreateListOfSize(2) + .All() + .With(f => f.DefaultQualityProfileId = 1) + .BuildList(); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); Assert.Throws(() => Subject.Delete(profile.Id)); @@ -92,7 +105,39 @@ namespace NzbDrone.Core.Test.Profiles } [Test] - public void should_delete_profile_if_not_assigned_to_artist_or_import_list() + public void should_not_be_able_to_delete_profile_if_assigned_to_root_folder() + { + var profile = Builder.CreateNew() + .With(p => p.Id = 2) + .Build(); + + var artistList = Builder.CreateListOfSize(3) + .All() + .With(c => c.QualityProfileId = 1) + .Build().ToList(); + + var importLists = Builder.CreateListOfSize(2) + .All() + .With(c => c.ProfileId = 1) + .Build().ToList(); + + var rootFolders = Builder.CreateListOfSize(2) + .Random(1) + .With(f => f.DefaultQualityProfileId = profile.Id) + .BuildList(); + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); + Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); + + Assert.Throws(() => Subject.Delete(profile.Id)); + + Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_profile_if_not_assigned_to_artist_import_list_or_root_folder() { var artistList = Builder.CreateListOfSize(3) .All() @@ -104,8 +149,14 @@ namespace NzbDrone.Core.Test.Profiles .With(c => c.ProfileId = 2) .Build().ToList(); + var rootFolders = Builder.CreateListOfSize(2) + .All() + .With(f => f.DefaultQualityProfileId = 2) + .BuildList(); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.All()).Returns(rootFolders); Subject.Delete(1); diff --git a/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs b/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs index 7f5a33608..375bd4a6c 100644 --- a/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs @@ -93,49 +93,5 @@ namespace NzbDrone.Core.Test.RootFolderTests Assert.Throws(() => Subject.Add(new RootFolder { Path = @"C:\Music".AsOsAgnostic() })); } - - [TestCase("$recycle.bin")] - [TestCase("system volume information")] - [TestCase("recycler")] - [TestCase("lost+found")] - [TestCase(".appledb")] - [TestCase(".appledesktop")] - [TestCase(".appledouble")] - [TestCase("@eadir")] - [TestCase(".grab")] - public void should_get_root_folder_with_subfolders_excluding_special_sub_folders(string subFolder) - { - var rootFolderPath = @"C:\Test\Music".AsOsAgnostic(); - var rootFolder = Builder.CreateNew() - .With(r => r.Path = rootFolderPath) - .Build(); - - var subFolders = new[] - { - "Artist1", - "Artist2", - "Artist3", - subFolder - }; - - var folders = subFolders.Select(f => Path.Combine(rootFolderPath, f)).ToArray(); - - Mocker.GetMock() - .Setup(s => s.Get(It.IsAny())) - .Returns(rootFolder); - - Mocker.GetMock() - .Setup(s => s.GetAllArtists()) - .Returns(new List()); - - Mocker.GetMock() - .Setup(s => s.GetDirectories(rootFolder.Path)) - .Returns(folders); - - var unmappedFolders = Subject.Get(rootFolder.Id).UnmappedFolders; - - unmappedFolders.Count.Should().BeGreaterThan(0); - unmappedFolders.Should().NotContain(u => u.Name == subFolder); - } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/039_add_root_folder_add_defaults.cs b/src/NzbDrone.Core/Datastore/Migration/039_add_root_folder_add_defaults.cs new file mode 100644 index 000000000..9815e1ab2 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/039_add_root_folder_add_defaults.cs @@ -0,0 +1,81 @@ +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(39)] + public class add_root_folder_add_defaults : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("RootFolders").AddColumn("Name").AsString().Nullable(); + Alter.Table("RootFolders").AddColumn("DefaultMetadataProfileId").AsInt32().WithDefaultValue(0); + Alter.Table("RootFolders").AddColumn("DefaultQualityProfileId").AsInt32().WithDefaultValue(0); + Alter.Table("RootFolders").AddColumn("DefaultMonitorOption").AsInt32().WithDefaultValue(0); + Alter.Table("RootFolders").AddColumn("DefaultTags").AsString().Nullable(); + + Execute.WithConnection(SetDefaultOptions); + } + + private void SetDefaultOptions(IDbConnection conn, IDbTransaction tran) + { + int metadataId = GetMinProfileId(conn, tran, "MetadataProfiles"); + int qualityId = GetMinProfileId(conn, tran, "QualityProfiles"); + + if (metadataId == 0 || qualityId == 0) + { + return; + } + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = $"SELECT Id, Path FROM RootFolders"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var rootFolderId = reader.GetInt32(0); + var path = reader.GetString(1); + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "UPDATE RootFolders SET Name = ?, DefaultMetadataProfileId = ?, DefaultQualityProfileId = ?, DefaultTags = ? WHERE Id = ?"; + updateCmd.AddParameter(path); + updateCmd.AddParameter(metadataId); + updateCmd.AddParameter(qualityId); + updateCmd.AddParameter("[]"); + updateCmd.AddParameter(rootFolderId); + + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + + private int GetMinProfileId(IDbConnection conn, IDbTransaction tran, string table) + { + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + + // A plain min(id) will return an empty row if table is empty which is a pain to deal with + cmd.CommandText = $"SELECT COALESCE(MIN(Id), 0) FROM {table}"; + + using (var reader = cmd.ExecuteReader()) + { + if (reader.Read()) + { + return reader.GetInt32(0); + } + + return 0; + } + } + } + } +} diff --git a/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs index d7106a8d7..5721076c9 100644 --- a/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs +++ b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Core.Music; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.DiskSpace { @@ -16,22 +16,24 @@ namespace NzbDrone.Core.DiskSpace public class DiskSpaceService : IDiskSpaceService { - private readonly IArtistService _artistService; private readonly IDiskProvider _diskProvider; + private readonly IRootFolderService _rootFolderService; private readonly Logger _logger; private static readonly Regex _regexSpecialDrive = new Regex("^/var/lib/(docker|rancher|kubelet)(/|$)|^/(boot|etc)(/|$)|/docker(/var)?/aufs(/|$)", RegexOptions.Compiled); - public DiskSpaceService(IArtistService artistService, IDiskProvider diskProvider, Logger logger) + public DiskSpaceService(IDiskProvider diskProvider, + IRootFolderService rootFolderService, + Logger logger) { - _artistService = artistService; _diskProvider = diskProvider; + _rootFolderService = rootFolderService; _logger = logger; } public List GetFreeSpace() { - var importantRootFolders = GetArtistRootPaths().Distinct().ToList(); + var importantRootFolders = _rootFolderService.All().Select(x => x.Path).ToList(); var optionalRootFolders = GetFixedDisksRootPaths().Except(importantRootFolders).Distinct().ToList(); @@ -40,14 +42,6 @@ namespace NzbDrone.Core.DiskSpace return diskSpace; } - private IEnumerable GetArtistRootPaths() - { - return _artistService.GetAllArtists() - .Where(s => _diskProvider.FolderExists(s.Path)) - .Select(s => _diskProvider.GetPathRoot(s.Path)) - .Distinct(); - } - private IEnumerable GetFixedDisksRootPaths() { return _diskProvider.GetMounts() diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 4ca7ff955..e4313e635 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -313,7 +313,6 @@ namespace NzbDrone.Core.History public void Handle(TrackFileRenamedEvent message) { var sourcePath = message.OriginalPath; - var sourceRelativePath = message.Artist.Path.GetRelativePath(message.OriginalPath); var path = message.TrackFile.Path; foreach (var track in message.TrackFile.Tracks.Value) @@ -330,7 +329,6 @@ namespace NzbDrone.Core.History }; history.Data.Add("SourcePath", sourcePath); - history.Data.Add("SourceRelativePath", sourceRelativePath); history.Data.Add("Path", path); _historyRepository.Insert(history); diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index 6373d3314..8dbbe7b6c 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -26,6 +26,12 @@ namespace NzbDrone.Core.IndexerSearch.Definitions { Ensure.That(title, () => title).IsNotNullOrWhiteSpace(); + // Most VA albums are listed as VA, not Various Artists + if (title == "Various Artists") + { + title = "VA"; + } + var cleanTitle = BeginningThe.Replace(title, string.Empty); cleanTitle = cleanTitle.Replace(" & ", " "); diff --git a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs index 9ae93b386..8f0dc7d83 100644 --- a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs +++ b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs @@ -355,7 +355,7 @@ namespace NzbDrone.Core.MediaFiles AlbumId = file.Album.Value.Id, TrackNumbers = file.Tracks.Value.Select(e => e.AbsoluteTrackNumber).ToList(), TrackFileId = file.Id, - RelativePath = file.Artist.Value.Path.GetRelativePath(file.Path), + Path = file.Path, Changes = diff }; } diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RescanArtistCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RescanArtistCommand.cs deleted file mode 100644 index b3d1dc05a..000000000 --- a/src/NzbDrone.Core/MediaFiles/Commands/RescanArtistCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.MediaFiles.Commands -{ - public class RescanArtistCommand : Command - { - public int? ArtistId { get; set; } - public FilterFilesType Filter { get; set; } - - public override bool SendUpdatesToClient => true; - - public RescanArtistCommand(FilterFilesType filter = FilterFilesType.Known) - { - Filter = filter; - } - - public RescanArtistCommand(int artistId, FilterFilesType filter = FilterFilesType.Known) - { - ArtistId = artistId; - Filter = filter; - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RescanFoldersCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RescanFoldersCommand.cs new file mode 100644 index 000000000..45519130d --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/RescanFoldersCommand.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class RescanFoldersCommand : Command + { + public RescanFoldersCommand() + { + } + + public RescanFoldersCommand(List folders, FilterFilesType filter, List artistIds) + { + Folders = folders; + Filter = filter; + ArtistIds = artistIds; + } + + public List Folders { get; set; } + public FilterFilesType Filter { get; set; } + public List ArtistIds { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + } +} diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index 49b44606d..d7fb36730 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -10,20 +10,20 @@ using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles { public interface IDiskScanService { - void Scan(Artist artist, FilterFilesType filter = FilterFilesType.Known); + void Scan(List folders = null, FilterFilesType filter = FilterFilesType.Known, List artistIds = null); IFileInfo[] GetAudioFiles(string path, bool allDirectories = true); string[] GetNonAudioFiles(string path, bool allDirectories = true); List FilterFiles(string basePath, IEnumerable files); @@ -32,13 +32,12 @@ namespace NzbDrone.Core.MediaFiles public class DiskScanService : IDiskScanService, - IExecute + IExecute { private readonly IDiskProvider _diskProvider; private readonly IMediaFileService _mediaFileService; private readonly IMakeImportDecision _importDecisionMaker; private readonly IImportApprovedTracks _importApprovedTracks; - private readonly IConfigService _configService; private readonly IArtistService _artistService; private readonly IMediaFileTableCleanupService _mediaFileTableCleanupService; private readonly IRootFolderService _rootFolderService; @@ -49,7 +48,6 @@ namespace NzbDrone.Core.MediaFiles IMediaFileService mediaFileService, IMakeImportDecision importDecisionMaker, IImportApprovedTracks importApprovedTracks, - IConfigService configService, IArtistService artistService, IRootFolderService rootFolderService, IMediaFileTableCleanupService mediaFileTableCleanupService, @@ -60,7 +58,6 @@ namespace NzbDrone.Core.MediaFiles _mediaFileService = mediaFileService; _importDecisionMaker = importDecisionMaker; _importApprovedTracks = importApprovedTracks; - _configService = configService; _artistService = artistService; _mediaFileTableCleanupService = mediaFileTableCleanupService; _rootFolderService = rootFolderService; @@ -71,56 +68,90 @@ namespace NzbDrone.Core.MediaFiles private static readonly Regex ExcludedSubFoldersRegex = new Regex(@"(?:\\|\/|^)(?:extras|@eadir|extrafanart|plex versions|\.[^\\/]+)(?:\\|\/)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex ExcludedFilesRegex = new Regex(@"^\._|^Thumbs\.db$|^\.DS_store$|\.partial~$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public void Scan(Artist artist, FilterFilesType filter = FilterFilesType.Known) + public void Scan(List folders = null, FilterFilesType filter = FilterFilesType.Known, List artistIds = null) { - var rootFolder = _rootFolderService.GetBestRootFolderPath(artist.Path); - - if (!_diskProvider.FolderExists(rootFolder)) + if (folders == null) { - _logger.Warn("Artist' root folder ({0}) doesn't exist.", rootFolder); - _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(artist, ArtistScanSkippedReason.RootFolderDoesNotExist)); - return; + folders = _rootFolderService.All().Select(x => x.Path).ToList(); } - if (_diskProvider.GetDirectories(rootFolder).Empty()) + if (artistIds == null) { - _logger.Warn("Artist' root folder ({0}) is empty.", rootFolder); - _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(artist, ArtistScanSkippedReason.RootFolderIsEmpty)); - return; + artistIds = new List(); } - _logger.ProgressInfo("Scanning {0}", artist.Name); + var mediaFileList = new List(); + + var musicFilesStopwatch = Stopwatch.StartNew(); - if (!_diskProvider.FolderExists(artist.Path)) + foreach (var folder in folders) { - if (_configService.CreateEmptyArtistFolders) + // We could be scanning a root folder or a subset of a root folder. If it's a subset, + // check if the root folder exists before cleaning. + var rootFolder = _rootFolderService.GetBestRootFolder(folder); + + if (rootFolder == null) { - _logger.Debug("Creating missing artist folder: {0}", artist.Path); - _diskProvider.CreateFolder(artist.Path); - SetPermissions(artist.Path); + _logger.Error("Not scanning {0}, it's not a subdirectory of a defined root folder", folder); + return; } - else + + if (!_diskProvider.FolderExists(rootFolder.Path)) { - _logger.Debug("Artist folder doesn't exist: {0}", artist.Path); + _logger.Warn("Root folder {0} doesn't exist.", rootFolder.Path); + + var skippedArtists = _artistService.GetArtists(artistIds); + skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderDoesNotExist))); + return; } - CleanMediaFiles(artist, new List()); - CompletedScanning(artist); + if (_diskProvider.GetDirectories(rootFolder.Path).Empty()) + { + _logger.Warn("Root folder {0} is empty.", rootFolder.Path); + + var skippedArtists = _artistService.GetArtists(artistIds); + skippedArtists.ForEach(x => _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(x, ArtistScanSkippedReason.RootFolderIsEmpty))); + return; + } + + if (!_diskProvider.FolderExists(folder)) + { + _logger.Debug("Specified scan folder ({0}) doesn't exist.", folder); + + CleanMediaFiles(folder, new List()); + continue; + } - return; + _logger.ProgressInfo("Scanning {0}", folder); + + var files = FilterFiles(folder, GetAudioFiles(folder)); + + if (!files.Any()) + { + _logger.Warn("Scan folder {0} is empty.", folder); + continue; + } + + CleanMediaFiles(folder, files.Select(x => x.FullName).ToList()); + mediaFileList.AddRange(files); } - var musicFilesStopwatch = Stopwatch.StartNew(); - var mediaFileList = FilterFiles(artist.Path, GetAudioFiles(artist.Path)).ToList(); musicFilesStopwatch.Stop(); - _logger.Trace("Finished getting track files for: {0} [{1}]", artist, musicFilesStopwatch.Elapsed); - - CleanMediaFiles(artist, mediaFileList.Select(x => x.FullName).ToList()); + _logger.Trace("Finished getting track files for:\n{0} [{1}]", folders.ConcatToString("\n"), musicFilesStopwatch.Elapsed); var decisionsStopwatch = Stopwatch.StartNew(); - var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, artist, filter, true); + + var config = new ImportDecisionMakerConfig + { + Filter = filter, + IncludeExisting = true, + AddNewArtists = true + }; + + var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, null, null, config); + decisionsStopwatch.Stop(); - _logger.Debug("Import decisions complete for: {0} [{1}]", artist, decisionsStopwatch.Elapsed); + _logger.Debug("Import decisions complete [{0}]", decisionsStopwatch.Elapsed); var importStopwatch = Stopwatch.StartNew(); _importApprovedTracks.Import(decisions, false); @@ -128,7 +159,8 @@ namespace NzbDrone.Core.MediaFiles // decisions may have been filtered to just new files. Anything new and approved will have been inserted. // Now we need to make sure anything new but not approved gets inserted // Note that knownFiles will include anything imported just now - var knownFiles = _mediaFileService.GetFilesWithBasePath(artist.Path); + var knownFiles = new List(); + folders.ForEach(x => knownFiles.AddRange(_mediaFileService.GetFilesWithBasePath(x))); var newFiles = decisions .ExceptBy(x => x.Item.Path, knownFiles, x => x.Path, PathEqualityComparer.Instance) @@ -173,17 +205,20 @@ namespace NzbDrone.Core.MediaFiles _logger.Debug($"Updated info for {updatedFiles.Count} known files"); - RemoveEmptyArtistFolder(artist.Path); + var artists = _artistService.GetArtists(artistIds); + foreach (var artist in artists) + { + CompletedScanning(artist); + } - CompletedScanning(artist); importStopwatch.Stop(); - _logger.Debug("Track import complete for: {0} [{1}]", artist, importStopwatch.Elapsed); + _logger.Debug("Track import complete for:\n{0} [{1}]", folders.ConcatToString("\n"), importStopwatch.Elapsed); } - private void CleanMediaFiles(Artist artist, List mediaFileList) + private void CleanMediaFiles(string folder, List mediaFileList) { - _logger.Debug("{0} Cleaning up media files in DB", artist); - _mediaFileTableCleanupService.Clean(artist, mediaFileList); + _logger.Debug($"Cleaning up media files in DB [{folder}]"); + _mediaFileTableCleanupService.Clean(folder, mediaFileList); } private void CompletedScanning(Artist artist) @@ -238,56 +273,9 @@ namespace NzbDrone.Core.MediaFiles .ToList(); } - private void SetPermissions(string path) - { - if (!_configService.SetPermissionsLinux) - { - return; - } - - try - { - var permissions = _configService.FolderChmod; - _diskProvider.SetPermissions(path, permissions, _configService.ChownUser, _configService.ChownGroup); - } - catch (Exception ex) - { - _logger.Warn(ex, "Unable to apply permissions to: " + path); - _logger.Debug(ex, ex.Message); - } - } - - private void RemoveEmptyArtistFolder(string path) + public void Execute(RescanFoldersCommand message) { - if (_configService.DeleteEmptyFolders) - { - if (_diskProvider.GetFiles(path, SearchOption.AllDirectories).Empty()) - { - _diskProvider.DeleteFolder(path, true); - } - else - { - _diskProvider.RemoveEmptySubfolders(path); - } - } - } - - public void Execute(RescanArtistCommand message) - { - if (message.ArtistId.HasValue) - { - var artist = _artistService.GetArtist(message.ArtistId.Value); - Scan(artist, message.Filter); - } - else - { - var allArtists = _artistService.GetAllArtists(); - - foreach (var artist in allArtists) - { - Scan(artist, message.Filter); - } - } + Scan(message.Folders, message.Filter, message.ArtistIds); } } } diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs index 14297a488..94172f301 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs @@ -200,7 +200,25 @@ namespace NzbDrone.Core.MediaFiles } } - var decisions = _importDecisionMaker.GetImportDecisions(audioFiles, artist, downloadClientItem, trackInfo); + var idOverrides = new IdentificationOverrides + { + Artist = artist + }; + var idInfo = new ImportDecisionMakerInfo + { + DownloadClientItem = downloadClientItem, + ParsedTrackInfo = trackInfo + }; + var idConfig = new ImportDecisionMakerConfig + { + Filter = FilterFilesType.None, + NewDownload = true, + SingleRelease = false, + IncludeExisting = false, + AddNewArtists = false + }; + + var decisions = _importDecisionMaker.GetImportDecisions(audioFiles, idOverrides, idInfo, idConfig); var importResults = _importApprovedTracks.Import(decisions, true, downloadClientItem, importMode); if (importMode == ImportMode.Auto) @@ -259,7 +277,24 @@ namespace NzbDrone.Core.MediaFiles } } - var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo }, artist, downloadClientItem, null); + var idOverrides = new IdentificationOverrides + { + Artist = artist + }; + var idInfo = new ImportDecisionMakerInfo + { + DownloadClientItem = downloadClientItem + }; + var idConfig = new ImportDecisionMakerConfig + { + Filter = FilterFilesType.None, + NewDownload = true, + SingleRelease = false, + IncludeExisting = false, + AddNewArtists = false + }; + + var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo }, idOverrides, idInfo, idConfig); return _importApprovedTracks.Import(decisions, true, downloadClientItem, importMode); } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs index 4720d183a..982e1918d 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using Marr.Data.QGen; +using NzbDrone.Common; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; @@ -15,6 +17,7 @@ namespace NzbDrone.Core.MediaFiles List GetFilesByRelease(int releaseId); List GetUnmappedFiles(); List GetFilesWithBasePath(string path); + List GetFileWithPath(List paths); TrackFile GetFileWithPath(string path); void DeleteFilesByAlbum(int albumId); void UnlinkFilesByAlbum(int albumId); @@ -88,7 +91,7 @@ namespace NzbDrone.Core.MediaFiles { // ensure path ends with a single trailing path separator to avoid matching partial paths var safePath = path.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; - return Query + return DataMapper.Query() .Where(x => x.Path.StartsWith(safePath)) .ToList(); } @@ -97,5 +100,15 @@ namespace NzbDrone.Core.MediaFiles { return Query.Where(x => x.Path == path).SingleOrDefault(); } + + public List GetFileWithPath(List paths) + { + // use more limited join for speed + var all = DataMapper.Query() + .Join(JoinType.Left, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) + .ToList(); + var joined = all.Join(paths, x => x.Path, x => x, (file, path) => file, PathEqualityComparer.Instance).ToList(); + return joined; + } } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index f794f6c7e..5294f5727 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -5,10 +5,11 @@ using System.Linq; using NLog; using NzbDrone.Common; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Music; using NzbDrone.Core.Music.Events; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles { @@ -24,15 +25,18 @@ namespace NzbDrone.Core.MediaFiles List GetFilesByAlbum(int albumId); List GetFilesByRelease(int releaseId); List GetUnmappedFiles(); - List FilterUnchangedFiles(List files, Artist artist, FilterFilesType filter); + List FilterUnchangedFiles(List files, FilterFilesType filter); TrackFile Get(int id); List Get(IEnumerable ids); List GetFilesWithBasePath(string path); + List GetFileWithPath(List path); TrackFile GetFileWithPath(string path); void UpdateMediaInfo(List trackFiles); } - public class MediaFileService : IMediaFileService, IHandleAsync + public class MediaFileService : IMediaFileService, + IHandleAsync, + IHandleAsync> { private readonly IEventAggregator _eventAggregator; private readonly IMediaFileRepository _mediaFileRepository; @@ -93,11 +97,16 @@ namespace NzbDrone.Core.MediaFiles } } - public List FilterUnchangedFiles(List files, Artist artist, FilterFilesType filter) + public List FilterUnchangedFiles(List files, FilterFilesType filter) { + if (filter == FilterFilesType.None) + { + return files; + } + _logger.Debug($"Filtering {files.Count} files for unchanged files"); - var knownFiles = GetFilesWithBasePath(artist.Path); + var knownFiles = GetFileWithPath(files.Select(x => x.FullName).ToList()); _logger.Trace($"Got {knownFiles.Count} existing files"); if (!knownFiles.Any()) @@ -156,21 +165,14 @@ namespace NzbDrone.Core.MediaFiles return _mediaFileRepository.GetFilesWithBasePath(path); } - public TrackFile GetFileWithPath(string path) + public List GetFileWithPath(List path) { return _mediaFileRepository.GetFileWithPath(path); } - public void HandleAsync(AlbumDeletedEvent message) + public TrackFile GetFileWithPath(string path) { - if (message.DeleteFiles) - { - _mediaFileRepository.DeleteFilesByAlbum(message.Album.Id); - } - else - { - _mediaFileRepository.UnlinkFilesByAlbum(message.Album.Id); - } + return _mediaFileRepository.GetFileWithPath(path); } public List GetFilesByArtist(int artistId) @@ -197,5 +199,26 @@ namespace NzbDrone.Core.MediaFiles { _mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo); } + + public void HandleAsync(AlbumDeletedEvent message) + { + if (message.DeleteFiles) + { + _mediaFileRepository.DeleteFilesByAlbum(message.Album.Id); + } + else + { + _mediaFileRepository.UnlinkFilesByAlbum(message.Album.Id); + } + } + + public void HandleAsync(ModelEvent message) + { + if (message.Action == ModelAction.Deleted) + { + var files = GetFilesWithBasePath(message.Model.Path); + DeleteMany(files, DeleteMediaFileReason.Manual); + } + } } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs index 5fb72354b..0e4e5fcf6 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs @@ -9,7 +9,7 @@ namespace NzbDrone.Core.MediaFiles { public interface IMediaFileTableCleanupService { - void Clean(Artist artist, List filesOnDisk); + void Clean(string folder, List filesOnDisk); } public class MediaFileTableCleanupService : IMediaFileTableCleanupService @@ -27,9 +27,9 @@ namespace NzbDrone.Core.MediaFiles _logger = logger; } - public void Clean(Artist artist, List filesOnDisk) + public void Clean(string folder, List filesOnDisk) { - var dbFiles = _mediaFileService.GetFilesWithBasePath(artist.Path); + var dbFiles = _mediaFileService.GetFilesWithBasePath(folder); // get files in database that are missing on disk and remove from database var missingFiles = dbFiles.ExceptBy(x => x.Path, filesOnDisk, x => x, PathEqualityComparer.Instance).ToList(); diff --git a/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs index bee38a49b..0944780e6 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs @@ -103,8 +103,8 @@ namespace NzbDrone.Core.MediaFiles AlbumId = album.Id, TrackNumbers = tracksInFile.Select(e => e.AbsoluteTrackNumber).ToList(), TrackFileId = file.Id, - ExistingPath = artist.Path.GetRelativePath(file.Path), - NewPath = artist.Path.GetRelativePath(newPath) + ExistingPath = file.Path, + NewPath = newPath }; } } diff --git a/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs b/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs index f9026ffd7..97e34aed6 100644 --- a/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs +++ b/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs @@ -9,7 +9,7 @@ namespace NzbDrone.Core.MediaFiles public int AlbumId { get; set; } public List TrackNumbers { get; set; } public int TrackFileId { get; set; } - public string RelativePath { get; set; } + public string Path { get; set; } public Dictionary> Changes { get; set; } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs new file mode 100644 index 000000000..6fb62bff0 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateService.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.MetadataSource.SkyHook; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public interface ICandidateService + { + List GetDbCandidatesFromTags(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, bool includeExisting); + List GetDbCandidatesFromFingerprint(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, bool includeExisting); + List GetRemoteCandidates(LocalAlbumRelease localAlbumRelease); + } + + public class CandidateService : ICandidateService + { + private readonly ISearchForNewAlbum _albumSearchService; + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IReleaseService _releaseService; + private readonly IMediaFileService _mediaFileService; + private readonly Logger _logger; + + public CandidateService(ISearchForNewAlbum albumSearchService, + IArtistService artistService, + IAlbumService albumService, + IReleaseService releaseService, + IMediaFileService mediaFileService, + Logger logger) + { + _albumSearchService = albumSearchService; + _artistService = artistService; + _albumService = albumService; + _releaseService = releaseService; + _mediaFileService = mediaFileService; + _logger = logger; + } + + public List GetDbCandidatesFromTags(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, bool includeExisting) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + + // 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. + List candidateReleases; + + // if we have a release ID, use that + AlbumRelease tagMbidRelease = null; + List tagCandidate = null; + + var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList(); + if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace()) + { + _logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); + tagMbidRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0], true); + + if (tagMbidRelease != null) + { + tagCandidate = GetDbCandidatesByRelease(new List { tagMbidRelease }, includeExisting); + } + } + + if (idOverrides?.AlbumRelease != null) + { + // this case overrides the release picked up from the file tags + var release = idOverrides.AlbumRelease; + _logger.Debug("Release {0} [{1} tracks] was forced", release, release.TrackCount); + candidateReleases = GetDbCandidatesByRelease(new List { release }, includeExisting); + } + else if (idOverrides?.Album != null) + { + // use the release from file tags if it exists and agrees with the specified album + if (tagMbidRelease?.AlbumId == idOverrides.Album.Id) + { + candidateReleases = tagCandidate; + } + else + { + candidateReleases = GetDbCandidatesByAlbum(localAlbumRelease, idOverrides.Album, includeExisting); + } + } + else if (idOverrides?.Artist != null) + { + // use the release from file tags if it exists and agrees with the specified album + if (tagMbidRelease?.Album.Value.ArtistMetadataId == idOverrides.Artist.ArtistMetadataId) + { + candidateReleases = tagCandidate; + } + else + { + candidateReleases = GetDbCandidatesByArtist(localAlbumRelease, idOverrides.Artist, includeExisting); + } + } + else + { + if (tagMbidRelease != null) + { + candidateReleases = tagCandidate; + } + else + { + candidateReleases = GetDbCandidates(localAlbumRelease, includeExisting); + } + } + + watch.Stop(); + _logger.Debug($"Getting candidates from tags for {localAlbumRelease.LocalTracks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + + // if we haven't got any candidates then try fingerprinting + return candidateReleases; + } + + private List GetDbCandidatesByRelease(List 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()); + + return releases.Select(x => new CandidateAlbumRelease + { + AlbumRelease = x, + ExistingTracks = albumTracks[x.AlbumId] + }).ToList(); + } + + private List GetDbCandidatesByAlbum(LocalAlbumRelease localAlbumRelease, Album album, bool includeExisting) + { + // sort candidate releases by closest track count so that we stand a chance of + // getting a perfect match early on + return GetDbCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id) + .OrderBy(x => Math.Abs(localAlbumRelease.TrackCount - x.TrackCount)) + .ToList(), includeExisting); + } + + private List GetDbCandidatesByArtist(LocalAlbumRelease localAlbumRelease, Artist artist, bool includeExisting) + { + _logger.Trace("Getting candidates for {0}", artist); + var candidateReleases = new List(); + + var albumTag = localAlbumRelease.LocalTracks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; + if (albumTag.IsNotNullOrWhiteSpace()) + { + var possibleAlbums = _albumService.GetCandidates(artist.ArtistMetadataId, albumTag); + foreach (var album in possibleAlbums) + { + candidateReleases.AddRange(GetDbCandidatesByAlbum(localAlbumRelease, album, includeExisting)); + } + } + + return candidateReleases; + } + + private List GetDbCandidates(LocalAlbumRelease localAlbumRelease, bool includeExisting) + { + // most general version, nothing has been specified. + // get all plausible artists, then all plausible albums, then get releases for each of these. + var candidateReleases = new List(); + + // check if it looks like VA. + if (TrackGroupingService.IsVariousArtists(localAlbumRelease.LocalTracks)) + { + var va = _artistService.FindById(DistanceCalculator.VariousArtistIds[0]); + if (va != null) + { + candidateReleases.AddRange(GetDbCandidatesByArtist(localAlbumRelease, va, includeExisting)); + } + } + + var artistTag = localAlbumRelease.LocalTracks.MostCommon(x => x.FileTrackInfo.ArtistTitle) ?? ""; + if (artistTag.IsNotNullOrWhiteSpace()) + { + var possibleArtists = _artistService.GetCandidates(artistTag); + foreach (var artist in possibleArtists) + { + candidateReleases.AddRange(GetDbCandidatesByArtist(localAlbumRelease, artist, includeExisting)); + } + } + + return candidateReleases; + } + + public List GetDbCandidatesFromFingerprint(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, bool includeExisting) + { + var recordingIds = localAlbumRelease.LocalTracks.Where(x => x.AcoustIdResults != null).SelectMany(x => x.AcoustIdResults).ToList(); + var allReleases = _releaseService.GetReleasesByRecordingIds(recordingIds); + + // make sure releases are consistent with those selected by the user + if (idOverrides?.AlbumRelease != null) + { + allReleases = allReleases.Where(x => x.Id == idOverrides.AlbumRelease.Id).ToList(); + } + else if (idOverrides?.Album != null) + { + allReleases = allReleases.Where(x => x.AlbumId == idOverrides.Album.Id).ToList(); + } + else if (idOverrides?.Artist != null) + { + allReleases = allReleases.Where(x => x.Album.Value.ArtistMetadataId == idOverrides.Artist.ArtistMetadataId).ToList(); + } + + return GetDbCandidatesByRelease(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) + .ToList() + .OrderBy(x => Math.Abs(x.TrackCount - localAlbumRelease.TrackCount)) + .ThenByDescending(x => x.CommonProportion) + .Select(x => x.Release) + .Take(10) + .ToList(), includeExisting); + } + + public List GetRemoteCandidates(LocalAlbumRelease localAlbumRelease) + { + // Gets candidate album releases from the metadata server. + // Will eventually need adding locally if we find a match + var watch = System.Diagnostics.Stopwatch.StartNew(); + + List remoteAlbums; + var candidates = new List(); + + var albumIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.AlbumMBId).Distinct().ToList(); + var recordingIds = localAlbumRelease.LocalTracks.Where(x => x.AcoustIdResults != null).SelectMany(x => x.AcoustIdResults).Distinct().ToList(); + + try + { + if (albumIds.Count == 1 && albumIds[0].IsNotNullOrWhiteSpace()) + { + // Use mbids in tags if set + remoteAlbums = _albumSearchService.SearchForNewAlbum($"mbid:{albumIds[0]}", null); + } + else if (recordingIds.Any()) + { + // If fingerprints present use those + remoteAlbums = _albumSearchService.SearchForNewAlbumByRecordingIds(recordingIds); + } + else + { + // fall back to artist / album name search + string artistTag; + + if (TrackGroupingService.IsVariousArtists(localAlbumRelease.LocalTracks)) + { + artistTag = "Various Artists"; + } + else + { + artistTag = localAlbumRelease.LocalTracks.MostCommon(x => x.FileTrackInfo.ArtistTitle) ?? ""; + } + + var albumTag = localAlbumRelease.LocalTracks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; + + if (artistTag.IsNullOrWhiteSpace() || albumTag.IsNullOrWhiteSpace()) + { + return candidates; + } + + remoteAlbums = _albumSearchService.SearchForNewAlbum(albumTag, artistTag); + } + } + catch (SkyHookException e) + { + _logger.Info(e, "Skipping album due to SkyHook error"); + remoteAlbums = new List(); + } + + foreach (var album in remoteAlbums) + { + // We have to make sure various bits and pieces are populated that are normally handled + // by a database lazy load + foreach (var release in album.AlbumReleases.Value) + { + release.Album = album; + candidates.Add(new CandidateAlbumRelease + { + AlbumRelease = release, + ExistingTracks = new List() + }); + } + } + + watch.Stop(); + _logger.Debug($"Getting {candidates.Count} remote candidates from tags for {localAlbumRelease.LocalTracks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + + return candidates; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalcualtor.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalcualtor.cs new file mode 100644 index 000000000..833ccb12f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/DistanceCalcualtor.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public static class DistanceCalculator + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DistanceCalculator)); + + public static readonly List VariousArtistIds = new List { "89ad4ac3-39f7-470e-963a-56509c546377" }; + private static readonly List VariousArtistNames = new List { "various artists", "various", "va", "unknown" }; + private static readonly List PreferredCountries = new List + { + "United States", + "United Kingdom", + "Europe", + "[Worldwide]" + }.Select(x => IsoCountries.Find(x)).ToList(); + + private static bool TrackIndexIncorrect(LocalTrack localTrack, Track mbTrack, int totalTrackNumber) + { + return localTrack.FileTrackInfo.TrackNumbers[0] != mbTrack.AbsoluteTrackNumber && + localTrack.FileTrackInfo.TrackNumbers[0] != totalTrackNumber; + } + + public static int GetTotalTrackNumber(Track track, List allTracks) + { + return track.AbsoluteTrackNumber + allTracks.Count(t => t.MediumNumber < track.MediumNumber); + } + + public static Distance TrackDistance(LocalTrack localTrack, Track mbTrack, int totalTrackNumber, bool includeArtist = false) + { + var dist = new Distance(); + + var localLength = localTrack.FileTrackInfo.Duration.TotalSeconds; + var mbLength = mbTrack.Duration / 1000; + var diff = Math.Abs(localLength - mbLength) - 10; + + if (mbLength > 0) + { + dist.AddRatio("track_length", diff, 30); + } + + // musicbrainz never has 'featuring' in the track title + // see https://musicbrainz.org/doc/Style/Artist_Credits + dist.AddString("track_title", localTrack.FileTrackInfo.CleanTitle ?? "", mbTrack.Title); + + if (includeArtist && localTrack.FileTrackInfo.ArtistTitle.IsNotNullOrWhiteSpace() + && !VariousArtistNames.Any(x => x.Equals(localTrack.FileTrackInfo.ArtistTitle, StringComparison.InvariantCultureIgnoreCase))) + { + dist.AddString("track_artist", localTrack.FileTrackInfo.ArtistTitle, mbTrack.ArtistMetadata.Value.Name); + } + + if (localTrack.FileTrackInfo.TrackNumbers.FirstOrDefault() > 0 && mbTrack.AbsoluteTrackNumber > 0) + { + dist.AddBool("track_index", TrackIndexIncorrect(localTrack, mbTrack, totalTrackNumber)); + } + + var recordingId = localTrack.FileTrackInfo.RecordingMBId; + if (recordingId.IsNotNullOrWhiteSpace()) + { + dist.AddBool("recording_id", localTrack.FileTrackInfo.RecordingMBId != mbTrack.ForeignRecordingId && + !mbTrack.OldForeignRecordingIds.Contains(localTrack.FileTrackInfo.RecordingMBId)); + } + + // for fingerprinted files + if (localTrack.AcoustIdResults != null) + { + dist.AddBool("recording_id", !localTrack.AcoustIdResults.Contains(mbTrack.ForeignRecordingId)); + } + + return dist; + } + + public static Distance AlbumReleaseDistance(List localTracks, AlbumRelease release, TrackMapping mapping) + { + var dist = new Distance(); + + if (!VariousArtistIds.Contains(release.Album.Value.ArtistMetadata.Value.ForeignArtistId)) + { + var artist = localTracks.MostCommon(x => x.FileTrackInfo.ArtistTitle) ?? ""; + dist.AddString("artist", artist, release.Album.Value.ArtistMetadata.Value.Name); + Logger.Trace("artist: {0} vs {1}; {2}", artist, release.Album.Value.ArtistMetadata.Value.Name, dist.NormalizedDistance()); + } + + var title = localTracks.MostCommon(x => x.FileTrackInfo.AlbumTitle) ?? ""; + + // Use the album title since the differences in release titles can cause confusion and + // aren't always correct in the tags + dist.AddString("album", title, release.Album.Value.Title); + Logger.Trace("album: {0} vs {1}; {2}", title, release.Title, dist.NormalizedDistance()); + + // Number of discs, either as tagged or the max disc number seen + var discCount = localTracks.MostCommon(x => x.FileTrackInfo.DiscCount); + discCount = discCount != 0 ? discCount : localTracks.Max(x => x.FileTrackInfo.DiscNumber); + if (discCount > 0) + { + dist.AddNumber("media_count", discCount, release.Media.Count); + Logger.Trace("media_count: {0} vs {1}; {2}", discCount, release.Media.Count, dist.NormalizedDistance()); + } + + // Media format + if (release.Media.Select(x => x.Format).Contains("Unknown")) + { + dist.Add("media_format", 1.0); + } + + // Year + var localYear = localTracks.MostCommon(x => x.FileTrackInfo.Year); + if (localYear > 0 && (release.Album.Value.ReleaseDate.HasValue || release.ReleaseDate.HasValue)) + { + var albumYear = release.Album.Value.ReleaseDate?.Year ?? 0; + var releaseYear = release.ReleaseDate?.Year ?? 0; + if (localYear == albumYear || localYear == releaseYear) + { + dist.Add("year", 0.0); + } + else + { + var remoteYear = albumYear > 0 ? albumYear : releaseYear; + var diff = Math.Abs(localYear - remoteYear); + var diff_max = Math.Abs(DateTime.Now.Year - remoteYear); + dist.AddRatio("year", diff, diff_max); + } + + Logger.Trace($"year: {localYear} vs {release.Album.Value.ReleaseDate?.Year} or {release.ReleaseDate?.Year}; {dist.NormalizedDistance()}"); + } + + // If we parsed a country from the files use that, otherwise use our preference + var country = localTracks.MostCommon(x => x.FileTrackInfo.Country); + if (release.Country.Count > 0) + { + if (country != null) + { + dist.AddEquality("country", country.Name, release.Country); + Logger.Trace("country: {0} vs {1}; {2}", country.Name, string.Join(", ", release.Country), dist.NormalizedDistance()); + } + else if (PreferredCountries.Count > 0) + { + dist.AddPriority("country", release.Country, PreferredCountries.Select(x => x.Name).ToList()); + Logger.Trace("country priority: {0} vs {1}; {2}", string.Join(", ", PreferredCountries.Select(x => x.Name)), string.Join(", ", release.Country), dist.NormalizedDistance()); + } + } + else + { + // full penalty if MusicBrainz release is missing a country + dist.Add("country", 1.0); + } + + var label = localTracks.MostCommon(x => x.FileTrackInfo.Label); + if (label.IsNotNullOrWhiteSpace()) + { + dist.AddEquality("label", label, release.Label); + Logger.Trace("label: {0} vs {1}; {2}", label, string.Join(", ", release.Label), dist.NormalizedDistance()); + } + + var disambig = localTracks.MostCommon(x => x.FileTrackInfo.Disambiguation); + if (disambig.IsNotNullOrWhiteSpace()) + { + dist.AddString("album_disambiguation", disambig, release.Disambiguation); + Logger.Trace("album_disambiguation: {0} vs {1}; {2}", disambig, release.Disambiguation, dist.NormalizedDistance()); + } + + var mbAlbumId = localTracks.MostCommon(x => x.FileTrackInfo.ReleaseMBId); + if (mbAlbumId.IsNotNullOrWhiteSpace()) + { + dist.AddBool("album_id", mbAlbumId != release.ForeignReleaseId && !release.OldForeignReleaseIds.Contains(mbAlbumId)); + Logger.Trace("album_id: {0} vs {1} or {2}; {3}", mbAlbumId, release.ForeignReleaseId, string.Join(", ", release.OldForeignReleaseIds), dist.NormalizedDistance()); + } + + // tracks + foreach (var pair in mapping.Mapping) + { + dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + } + + Logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); + + // missing tracks + foreach (var track in mapping.MBExtra.Take(localTracks.Count)) + { + dist.Add("missing_tracks", 1.0); + } + + Logger.Trace("after missing tracks: {0}", dist.NormalizedDistance()); + + // unmatched tracks + foreach (var track in mapping.LocalExtra.Take(localTracks.Count)) + { + dist.Add("unmatched_tracks", 1.0); + } + + Logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance()); + + return dist; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs index 12b848298..ab98b5cf8 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs @@ -4,8 +4,8 @@ using System.Linq; using Newtonsoft.Json; using NLog; using NzbDrone.Common; -using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; @@ -17,59 +17,39 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification { public interface IIdentificationService { - List Identify(List localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting); + List Identify(List localTracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config); } public class IdentificationService : IIdentificationService { - private readonly IArtistService _artistService; - private readonly IAlbumService _albumService; - private readonly IReleaseService _releaseService; private readonly ITrackService _trackService; private readonly ITrackGroupingService _trackGroupingService; private readonly IFingerprintingService _fingerprintingService; private readonly IAudioTagService _audioTagService; private readonly IAugmentingService _augmentingService; - private readonly IMediaFileService _mediaFileService; + private readonly ICandidateService _candidateService; private readonly IConfigService _configService; private readonly Logger _logger; - public IdentificationService(IArtistService artistService, - IAlbumService albumService, - IReleaseService releaseService, - ITrackService trackService, + public IdentificationService(ITrackService trackService, ITrackGroupingService trackGroupingService, IFingerprintingService fingerprintingService, IAudioTagService audioTagService, IAugmentingService augmentingService, - IMediaFileService mediaFileService, + ICandidateService candidateService, IConfigService configService, Logger logger) { - _artistService = artistService; - _albumService = albumService; - _releaseService = releaseService; _trackService = trackService; _trackGroupingService = trackGroupingService; _fingerprintingService = fingerprintingService; _audioTagService = audioTagService; _augmentingService = augmentingService; - _mediaFileService = mediaFileService; + _candidateService = candidateService; _configService = configService; _logger = logger; } - private readonly List _preferredCountries = new List - { - "United States", - "United Kingdom", - "Europe", - "[Worldwide]" - }.Select(x => IsoCountries.Find(x)).ToList(); - - private readonly List _variousArtistNames = new List { "various artists", "various", "va", "unknown" }; - private readonly List _variousArtistIds = new List { "89ad4ac3-39f7-470e-963a-56509c546377" }; - private void LogTestCaseOutput(List localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) { var trackData = localTracks.Select(x => new BasicLocalTrack @@ -104,17 +84,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification _logger.Debug($"*** IdentificationService TestCaseGenerator ***\n{output}"); } - public List Identify(List localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) + public List GetLocalAlbumReleases(List localTracks, bool singleRelease) { - // 1 group localTracks so that we think they represent a single release - // 2 get candidates given specified artist, album and release. Candidates can include extra files already on disk. - // 3 find best candidate - // 4 If best candidate worse than threshold, try fingerprinting var watch = System.Diagnostics.Stopwatch.StartNew(); - - _logger.Debug("Starting track identification"); - LogTestCaseOutput(localTracks, artist, album, release, newDownload, singleRelease); - List releases = null; if (singleRelease) { @@ -137,8 +109,29 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification { _logger.Warn($"Augmentation failed for {localRelease}"); } + } + + return releases; + } + + public List Identify(List localTracks, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) + { + // 1 group localTracks so that we think they represent a single release + // 2 get candidates given specified artist, album and release. Candidates can include extra files already on disk. + // 3 find best candidate + // 4 If best candidate worse than threshold, try fingerprinting + var watch = System.Diagnostics.Stopwatch.StartNew(); + + _logger.Debug("Starting track identification"); + + var releases = GetLocalAlbumReleases(localTracks, config.SingleRelease); - IdentifyRelease(localRelease, artist, album, release, newDownload, includeExisting); + int i = 0; + foreach (var localRelease in releases) + { + i++; + _logger.ProgressInfo($"Identifying album {i}/{releases.Count}"); + IdentifyRelease(localRelease, idOverrides, config); } watch.Stop(); @@ -191,25 +184,37 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification AdditionalFile = true, Quality = x.Quality })) - .ToList(); + .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) + private void IdentifyRelease(LocalAlbumRelease localAlbumRelease, IdentificationOverrides idOverrides, ImportDecisionMakerConfig config) { var watch = System.Diagnostics.Stopwatch.StartNew(); bool fingerprinted = false; - var candidateReleases = GetCandidatesFromTags(localAlbumRelease, artist, album, release, includeExisting); - if (candidateReleases.Count == 0 && FingerprintingAllowed(newDownload)) + var candidateReleases = _candidateService.GetDbCandidatesFromTags(localAlbumRelease, idOverrides, config.IncludeExisting); + + if (candidateReleases.Count == 0 && config.AddNewArtists) + { + candidateReleases = _candidateService.GetRemoteCandidates(localAlbumRelease); + } + + if (candidateReleases.Count == 0 && FingerprintingAllowed(config.NewDownload)) { _logger.Debug("No candidates found, fingerprinting"); _fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5); fingerprinted = true; - candidateReleases = GetCandidatesFromFingerprint(localAlbumRelease, artist, album, release, includeExisting); + candidateReleases = _candidateService.GetDbCandidatesFromFingerprint(localAlbumRelease, idOverrides, config.IncludeExisting); + + if (candidateReleases.Count == 0 && config.AddNewArtists) + { + // Now fingerprints are populated this will return a different answer + candidateReleases = _candidateService.GetRemoteCandidates(localAlbumRelease); + } } if (candidateReleases.Count == 0) @@ -220,32 +225,36 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification _logger.Debug($"Got {candidateReleases.Count} candidates for {localAlbumRelease.LocalTracks.Count} tracks in {watch.ElapsedMilliseconds}ms"); - var allTracks = _trackService.GetTracksByReleases(candidateReleases.Select(x => x.AlbumRelease.Id).ToList()); + PopulateTracks(candidateReleases); // convert all the TrackFiles that represent extra files to List var allLocalTracks = ToLocalTrack(candidateReleases .SelectMany(x => x.ExistingTracks) .DistinctBy(x => x.Path), localAlbumRelease); - _logger.Debug($"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms"); + _logger.Debug($"Retrieved {allLocalTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms"); - GetBestRelease(localAlbumRelease, candidateReleases, allTracks, allLocalTracks); + GetBestRelease(localAlbumRelease, candidateReleases, allLocalTracks); // 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 - if (!fingerprinted && FingerprintingAllowed(newDownload) && ShouldFingerprint(localAlbumRelease)) + if (!fingerprinted && FingerprintingAllowed(config.NewDownload) && ShouldFingerprint(localAlbumRelease)) { _logger.Debug($"Match not good enough, fingerprinting"); _fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5); // Only include extra possible candidates if neither album nor release are specified // Will generally be specified as part of manual import - if (album == null && release == null) + if (idOverrides?.Album == null && idOverrides?.AlbumRelease == null) { - var extraCandidates = GetCandidatesFromFingerprint(localAlbumRelease, artist, album, release, includeExisting); + var dbCandidates = _candidateService.GetDbCandidatesFromFingerprint(localAlbumRelease, idOverrides, config.IncludeExisting); + var remoteCandidates = config.AddNewArtists ? _candidateService.GetRemoteCandidates(localAlbumRelease) : new List(); + var extraCandidates = dbCandidates.Concat(remoteCandidates); var newCandidates = extraCandidates.ExceptBy(x => x.AlbumRelease.Id, candidateReleases, y => y.AlbumRelease.Id, EqualityComparer.Default); candidateReleases.AddRange(newCandidates); - allTracks.AddRange(_trackService.GetTracksByReleases(newCandidates.Select(x => x.AlbumRelease.Id).ToList())); + + PopulateTracks(candidateReleases); + allLocalTracks.AddRange(ToLocalTrack(newCandidates .SelectMany(x => x.ExistingTracks) .DistinctBy(x => x.Path) @@ -256,7 +265,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification // fingerprint all the local files in candidates we might be matching against _fingerprintingService.Lookup(allLocalTracks, 0.5); - GetBestRelease(localAlbumRelease, candidateReleases, allTracks, allLocalTracks); + GetBestRelease(localAlbumRelease, candidateReleases, allLocalTracks); } _logger.Debug($"Best release found in {watch.ElapsedMilliseconds}ms"); @@ -266,186 +275,22 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification _logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms"); } - public List GetCandidatesFromTags(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool includeExisting) + public void PopulateTracks(List candidateReleases) { var watch = System.Diagnostics.Stopwatch.StartNew(); - // 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. - List candidateReleases; - - // if we have a release ID, use that - AlbumRelease tagMbidRelease = null; - List tagCandidate = null; - - var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList(); - if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace()) - { - _logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); - tagMbidRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0], true); - - if (tagMbidRelease != null) - { - tagCandidate = GetCandidatesByRelease(new List { tagMbidRelease }, includeExisting); - } - } - - 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); - candidateReleases = GetCandidatesByRelease(new List { release }, includeExisting); - } - else if (album != null) - { - // 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) - { - // 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 - { - if (tagMbidRelease != null) - { - candidateReleases = tagCandidate; - } - else - { - candidateReleases = GetCandidates(localAlbumRelease, includeExisting); - } - } - - watch.Stop(); - _logger.Debug($"Getting candidates from tags for {localAlbumRelease.LocalTracks.Count} tracks took {watch.ElapsedMilliseconds}ms"); - - // if we haven't got any candidates then try fingerprinting - return candidateReleases; - } - - private List GetCandidatesByRelease(List 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()); - - return releases.Select(x => new CandidateAlbumRelease - { - AlbumRelease = x, - ExistingTracks = albumTracks[x.AlbumId] - }).ToList(); - } - - private List GetCandidatesByAlbum(LocalAlbumRelease localAlbumRelease, Album album, bool includeExisting) - { - // sort candidate releases by closest track count so that we stand a chance of - // getting a perfect match early on - return GetCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id) - .OrderBy(x => Math.Abs(localAlbumRelease.TrackCount - x.TrackCount)) - .ToList(), includeExisting); - } - - private List GetCandidatesByArtist(LocalAlbumRelease localAlbumRelease, Artist artist, bool includeExisting) - { - _logger.Trace("Getting candidates for {0}", artist); - var candidateReleases = new List(); - - var albumTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? ""; - if (albumTag.IsNotNullOrWhiteSpace()) - { - var possibleAlbums = _albumService.GetCandidates(artist.ArtistMetadataId, albumTag); - foreach (var album in possibleAlbums) - { - candidateReleases.AddRange(GetCandidatesByAlbum(localAlbumRelease, album, includeExisting)); - } - } + var releasesMissingTracks = candidateReleases.Where(x => !x.AlbumRelease.Tracks.IsLoaded); + var allTracks = _trackService.GetTracksByReleases(releasesMissingTracks.Select(x => x.AlbumRelease.Id).ToList()); - return candidateReleases; - } - - private List GetCandidates(LocalAlbumRelease localAlbumRelease, bool includeExisting) - { - // most general version, nothing has been specified. - // get all plausible artists, then all plausible albums, then get releases for each of these. - - // check if it looks like VA. - if (TrackGroupingService.IsVariousArtists(localAlbumRelease.LocalTracks)) - { - throw new NotImplementedException("Various artists not supported"); - } - - var candidateReleases = new List(); - - var artistTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? ""; - if (artistTag.IsNotNullOrWhiteSpace()) - { - var possibleArtists = _artistService.GetCandidates(artistTag); - foreach (var artist in possibleArtists) - { - candidateReleases.AddRange(GetCandidatesByArtist(localAlbumRelease, artist, includeExisting)); - } - } - - return candidateReleases; - } - - public List 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 allReleases = _releaseService.GetReleasesByRecordingIds(recordingIds); + _logger.Debug($"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms"); - // make sure releases are consistent with those selected by the user - if (release != null) - { - 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) + foreach (var release in releasesMissingTracks) { - allReleases = allReleases.Where(x => x.Album.Value.ArtistMetadataId == artist.ArtistMetadataId).ToList(); + release.AlbumRelease.Tracks = allTracks.Where(x => x.AlbumReleaseId == release.AlbumRelease.Id).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) - .ToList() - .OrderBy(x => Math.Abs(x.TrackCount - localAlbumRelease.TrackCount)) - .ThenByDescending(x => x.CommonProportion) - .Select(x => x.Release) - .Take(10) - .ToList(), includeExisting); - } - - private T MostCommon(IEnumerable items) - { - return items.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key; } - private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List candidateReleases, List dbTracks, List extraTracksOnDisk) + private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List candidateReleases, List extraTracksOnDisk) { var watch = System.Diagnostics.Stopwatch.StartNew(); @@ -464,8 +309,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification 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 mapping = MapReleaseTracks(allLocalTracks, release.Tracks.Value); + var distance = DistanceCalculator.AlbumReleaseDistance(allLocalTracks, release, mapping); var currDistance = distance.NormalizedDistance(); rwatch.Stop(); @@ -493,11 +338,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification _logger.Debug($"Best release: {localAlbumRelease.AlbumRelease} Distance {localAlbumRelease.Distance.NormalizedDistance()} found in {watch.ElapsedMilliseconds}ms"); } - public int GetTotalTrackNumber(Track track, List allTracks) - { - return track.AbsoluteTrackNumber + allTracks.Count(t => t.MediumNumber < track.MediumNumber); - } - public TrackMapping MapReleaseTracks(List localTracks, List mbTracks) { var distances = new Distance[localTracks.Count, mbTracks.Count]; @@ -505,10 +345,10 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification for (int col = 0; col < mbTracks.Count; col++) { - var totalTrackNumber = GetTotalTrackNumber(mbTracks[col], mbTracks); + var totalTrackNumber = DistanceCalculator.GetTotalTrackNumber(mbTracks[col], mbTracks); for (int row = 0; row < localTracks.Count; row++) { - distances[row, col] = TrackDistance(localTracks[row], mbTracks[col], totalTrackNumber, false); + distances[row, col] = DistanceCalculator.TrackDistance(localTracks[row], mbTracks[col], totalTrackNumber, false); costs[row, col] = distances[row, col].NormalizedDistance(); } } @@ -531,178 +371,5 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification return result; } - - private bool TrackIndexIncorrect(LocalTrack localTrack, Track mbTrack, int totalTrackNumber) - { - return localTrack.FileTrackInfo.TrackNumbers[0] != mbTrack.AbsoluteTrackNumber && - localTrack.FileTrackInfo.TrackNumbers[0] != totalTrackNumber; - } - - public Distance TrackDistance(LocalTrack localTrack, Track mbTrack, int totalTrackNumber, bool includeArtist = false) - { - var dist = new Distance(); - - var localLength = localTrack.FileTrackInfo.Duration.TotalSeconds; - var mbLength = mbTrack.Duration / 1000; - var diff = Math.Abs(localLength - mbLength) - 10; - - if (mbLength > 0) - { - dist.AddRatio("track_length", diff, 30); - } - - // musicbrainz never has 'featuring' in the track title - // see https://musicbrainz.org/doc/Style/Artist_Credits - dist.AddString("track_title", localTrack.FileTrackInfo.CleanTitle ?? "", mbTrack.Title); - - if (includeArtist && localTrack.FileTrackInfo.ArtistTitle.IsNotNullOrWhiteSpace() - && !_variousArtistNames.Any(x => x.Equals(localTrack.FileTrackInfo.ArtistTitle, StringComparison.InvariantCultureIgnoreCase))) - { - dist.AddString("track_artist", localTrack.FileTrackInfo.ArtistTitle, mbTrack.ArtistMetadata.Value.Name); - } - - if (localTrack.FileTrackInfo.TrackNumbers.FirstOrDefault() > 0 && mbTrack.AbsoluteTrackNumber > 0) - { - dist.AddBool("track_index", TrackIndexIncorrect(localTrack, mbTrack, totalTrackNumber)); - } - - var recordingId = localTrack.FileTrackInfo.RecordingMBId; - if (recordingId.IsNotNullOrWhiteSpace()) - { - dist.AddBool("recording_id", localTrack.FileTrackInfo.RecordingMBId != mbTrack.ForeignRecordingId && - !mbTrack.OldForeignRecordingIds.Contains(localTrack.FileTrackInfo.RecordingMBId)); - } - - // for fingerprinted files - if (localTrack.AcoustIdResults != null) - { - dist.AddBool("recording_id", !localTrack.AcoustIdResults.Contains(mbTrack.ForeignRecordingId)); - } - - return dist; - } - - public Distance AlbumReleaseDistance(List localTracks, AlbumRelease release, TrackMapping mapping) - { - var dist = new Distance(); - - if (!_variousArtistIds.Contains(release.Album.Value.ArtistMetadata.Value.ForeignArtistId)) - { - var artist = MostCommon(localTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? ""; - dist.AddString("artist", artist, release.Album.Value.ArtistMetadata.Value.Name); - _logger.Trace("artist: {0} vs {1}; {2}", artist, release.Album.Value.ArtistMetadata.Value.Name, dist.NormalizedDistance()); - } - - var title = MostCommon(localTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? ""; - - // Use the album title since the differences in release titles can cause confusion and - // aren't always correct in the tags - dist.AddString("album", title, release.Album.Value.Title); - _logger.Trace("album: {0} vs {1}; {2}", title, release.Title, dist.NormalizedDistance()); - - // Number of discs, either as tagged or the max disc number seen - var discCount = MostCommon(localTracks.Select(x => x.FileTrackInfo.DiscCount)); - discCount = discCount != 0 ? discCount : localTracks.Max(x => x.FileTrackInfo.DiscNumber); - if (discCount > 0) - { - dist.AddNumber("media_count", discCount, release.Media.Count); - _logger.Trace("media_count: {0} vs {1}; {2}", discCount, release.Media.Count, dist.NormalizedDistance()); - } - - // Media format - if (release.Media.Select(x => x.Format).Contains("Unknown")) - { - dist.Add("media_format", 1.0); - } - - // Year - var localYear = MostCommon(localTracks.Select(x => x.FileTrackInfo.Year)); - if (localYear > 0 && (release.Album.Value.ReleaseDate.HasValue || release.ReleaseDate.HasValue)) - { - var albumYear = release.Album.Value.ReleaseDate?.Year ?? 0; - var releaseYear = release.ReleaseDate?.Year ?? 0; - if (localYear == albumYear || localYear == releaseYear) - { - dist.Add("year", 0.0); - } - else - { - var remoteYear = albumYear > 0 ? albumYear : releaseYear; - var diff = Math.Abs(localYear - remoteYear); - var diff_max = Math.Abs(DateTime.Now.Year - remoteYear); - dist.AddRatio("year", diff, diff_max); - } - - _logger.Trace($"year: {localYear} vs {release.Album.Value.ReleaseDate?.Year} or {release.ReleaseDate?.Year}; {dist.NormalizedDistance()}"); - } - - // If we parsed a country from the files use that, otherwise use our preference - var country = MostCommon(localTracks.Select(x => x.FileTrackInfo.Country)); - if (release.Country.Count > 0) - { - if (country != null) - { - dist.AddEquality("country", country.Name, release.Country); - _logger.Trace("country: {0} vs {1}; {2}", country.Name, string.Join(", ", release.Country), dist.NormalizedDistance()); - } - else if (_preferredCountries.Count > 0) - { - dist.AddPriority("country", release.Country, _preferredCountries.Select(x => x.Name).ToList()); - _logger.Trace("country priority: {0} vs {1}; {2}", string.Join(", ", _preferredCountries.Select(x => x.Name)), string.Join(", ", release.Country), dist.NormalizedDistance()); - } - } - else - { - // full penalty if MusicBrainz release is missing a country - dist.Add("country", 1.0); - } - - var label = MostCommon(localTracks.Select(x => x.FileTrackInfo.Label)); - if (label.IsNotNullOrWhiteSpace()) - { - dist.AddEquality("label", label, release.Label); - _logger.Trace("label: {0} vs {1}; {2}", label, string.Join(", ", release.Label), dist.NormalizedDistance()); - } - - var disambig = MostCommon(localTracks.Select(x => x.FileTrackInfo.Disambiguation)); - if (disambig.IsNotNullOrWhiteSpace()) - { - dist.AddString("album_disambiguation", disambig, release.Disambiguation); - _logger.Trace("album_disambiguation: {0} vs {1}; {2}", disambig, release.Disambiguation, dist.NormalizedDistance()); - } - - var mbAlbumId = MostCommon(localTracks.Select(x => x.FileTrackInfo.ReleaseMBId)); - if (mbAlbumId.IsNotNullOrWhiteSpace()) - { - dist.AddBool("album_id", mbAlbumId != release.ForeignReleaseId && !release.OldForeignReleaseIds.Contains(mbAlbumId)); - _logger.Trace("album_id: {0} vs {1} or {2}; {3}", mbAlbumId, release.ForeignReleaseId, string.Join(", ", release.OldForeignReleaseIds), dist.NormalizedDistance()); - } - - // tracks - foreach (var pair in mapping.Mapping) - { - dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); - } - - _logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); - - // missing tracks - foreach (var track in mapping.MBExtra.Take(localTracks.Count)) - { - dist.Add("missing_tracks", 1.0); - } - - _logger.Trace("after missing tracks: {0}", dist.NormalizedDistance()); - - // unmatched tracks - foreach (var track in mapping.LocalExtra.Take(localTracks.Count)) - { - dist.Add("unmatched_tracks", 1.0); - } - - _logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance()); - - return dist; - } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs index 4caa48bc2..85b17098a 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs @@ -7,6 +7,7 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation; +using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport.Identification @@ -26,6 +27,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification public List GroupTracks(List localTracks) { + _logger.ProgressInfo($"Grouping {localTracks.Count} tracks"); + var releases = new List(); // first attempt, assume grouped by folder diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs index 5f64c1a2e..781f40a89 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs @@ -4,14 +4,19 @@ using System.Linq; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Extras; using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; using NzbDrone.Core.Music.Events; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles.TrackImport { @@ -26,81 +31,94 @@ namespace NzbDrone.Core.MediaFiles.TrackImport private readonly IMediaFileService _mediaFileService; private readonly IAudioTagService _audioTagService; private readonly ITrackService _trackService; + private readonly IArtistService _artistService; + private readonly IAddArtistService _addArtistService; + private readonly IAlbumService _albumService; + private readonly IRefreshAlbumService _refreshAlbumService; + private readonly IRootFolderService _rootFolderService; private readonly IRecycleBinProvider _recycleBinProvider; private readonly IExtraService _extraService; private readonly IDiskProvider _diskProvider; private readonly IReleaseService _releaseService; private readonly IEventAggregator _eventAggregator; + private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; public ImportApprovedTracks(IUpgradeMediaFiles trackFileUpgrader, IMediaFileService mediaFileService, IAudioTagService audioTagService, ITrackService trackService, + IArtistService artistService, + IAddArtistService addArtistService, + IAlbumService albumService, + IRefreshAlbumService refreshAlbumService, + IRootFolderService rootFolderService, IRecycleBinProvider recycleBinProvider, IExtraService extraService, IDiskProvider diskProvider, IReleaseService releaseService, IEventAggregator eventAggregator, + IManageCommandQueue commandQueueManager, Logger logger) { _trackFileUpgrader = trackFileUpgrader; _mediaFileService = mediaFileService; _audioTagService = audioTagService; _trackService = trackService; + _artistService = artistService; + _addArtistService = addArtistService; + _albumService = albumService; + _refreshAlbumService = refreshAlbumService; + _rootFolderService = rootFolderService; _recycleBinProvider = recycleBinProvider; _extraService = extraService; _diskProvider = diskProvider; _releaseService = releaseService; _eventAggregator = eventAggregator; + _commandQueueManager = commandQueueManager; _logger = logger; } public List Import(List> decisions, bool replaceExisting, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) { - var qualifiedImports = decisions.Where(c => c.Approved) - .GroupBy(c => c.Item.Artist.Id, (i, s) => s - .OrderByDescending(c => c.Item.Quality, new QualityModelComparer(s.First().Item.Artist.QualityProfile)) - .ThenByDescending(c => c.Item.Size)) - .SelectMany(c => c) - .ToList(); - - _logger.Debug($"Importing {qualifiedImports.Count} files. replaceExisting: {replaceExisting}"); - var importResults = new List(); var allImportedTrackFiles = new List(); var allOldTrackFiles = new List(); + var addedArtists = new List(); var albumDecisions = decisions.Where(e => e.Item.Album != null && e.Approved) - .GroupBy(e => e.Item.Album.Id).ToList(); + .GroupBy(e => e.Item.Album.ForeignAlbumId).ToList(); + int iDecision = 1; foreach (var albumDecision in albumDecisions) { - var album = albumDecision.First().Item.Album; - var newRelease = albumDecision.First().Item.Release; + _logger.ProgressInfo($"Importing album {iDecision++}/{albumDecisions.Count}"); - if (replaceExisting) + var decisionList = albumDecision.ToList(); + + var artist = EnsureArtistAdded(decisionList, addedArtists); + + if (artist == null) { - var artist = albumDecision.First().Item.Artist; - var rootFolder = _diskProvider.GetParentFolder(artist.Path); - var previousFiles = _mediaFileService.GetFilesByAlbum(album.Id); + // failed to add the artist, carry on with next album + continue; + } - _logger.Debug($"Deleting {previousFiles.Count} existing files for {album}"); + var album = EnsureAlbumAdded(decisionList); - foreach (var previousFile in previousFiles) - { - var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(previousFile.Path)); - if (_diskProvider.FileExists(previousFile.Path)) - { - _logger.Debug("Removing existing track file: {0}", previousFile); - _recycleBinProvider.DeleteFile(previousFile.Path, subfolder); - } + if (album == null) + { + // failed to add the album, carry on with next one + continue; + } - _mediaFileService.Delete(previousFile, DeleteMediaFileReason.Upgrade); - } + if (replaceExisting) + { + RemoveExistingTrackFiles(artist, album); } // set the correct release to be monitored before importing the new files + var newRelease = albumDecision.First().Item.Release; _logger.Debug("Updating release to {0} [{1} tracks]", newRelease, newRelease.TrackCount); album.AlbumReleases = _releaseService.SetMonitored(newRelease); @@ -109,6 +127,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _eventAggregator.PublishEvent(new AlbumEditedEvent(album, album)); } + var qualifiedImports = decisions.Where(c => c.Approved) + .GroupBy(c => c.Item.Artist.Id, (i, s) => s + .OrderByDescending(c => c.Item.Quality, new QualityModelComparer(s.First().Item.Artist.QualityProfile)) + .ThenByDescending(c => c.Item.Size)) + .SelectMany(c => c) + .ToList(); + + _logger.ProgressInfo($"Importing {qualifiedImports.Count} tracks"); + _logger.Debug($"Importing {qualifiedImports.Count} files. replaceExisting: {replaceExisting}"); + var filesToAdd = new List(qualifiedImports.Count); var albumReleasesDict = new Dictionary>(albumDecisions.Count); var trackImportedEvents = new List(qualifiedImports.Count); @@ -184,7 +212,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport if (!localTrack.ExistingFile) { - trackFile.SceneName = GetSceneReleaseName(downloadClientItem, localTrack); + trackFile.SceneName = GetSceneReleaseName(downloadClientItem); var moveResult = _trackFileUpgrader.UpgradeTrackFile(trackFile, localTrack, copyOnly); oldFiles = moveResult.OldFiles; @@ -283,10 +311,147 @@ namespace NzbDrone.Core.MediaFiles.TrackImport importResults.AddRange(decisions.Where(c => !c.Approved) .Select(d => new ImportResult(d, d.Rejections.Select(r => r.Reason).ToArray()))); + // Refresh any artists we added + if (addedArtists.Any()) + { + _commandQueueManager.Push(new BulkRefreshArtistCommand(addedArtists.Select(x => x.Id).ToList(), true)); + } + return importResults; } - private string GetSceneReleaseName(DownloadClientItem downloadClientItem, LocalTrack localTrack) + private Artist EnsureArtistAdded(List> decisions, List addedArtists) + { + var artist = decisions.First().Item.Artist; + + if (artist.Id == 0) + { + var dbArtist = _artistService.FindById(artist.ForeignArtistId); + + if (dbArtist == null) + { + _logger.Debug($"Adding remote artist {artist}"); + var rootFolder = _rootFolderService.GetBestRootFolder(decisions.First().Item.Path); + + artist.RootFolderPath = rootFolder.Path; + artist.MetadataProfileId = rootFolder.DefaultMetadataProfileId; + artist.QualityProfileId = rootFolder.DefaultQualityProfileId; + artist.AlbumFolder = true; + artist.Monitored = rootFolder.DefaultMonitorOption != MonitorTypes.None; + artist.Tags = rootFolder.DefaultTags; + artist.AddOptions = new AddArtistOptions + { + SearchForMissingAlbums = false, + Monitored = artist.Monitored, + Monitor = rootFolder.DefaultMonitorOption + }; + + try + { + dbArtist = _addArtistService.AddArtist(artist, false); + addedArtists.Add(dbArtist); + } + catch (Exception e) + { + _logger.Error(e, "Failed to add artist {0}", artist); + foreach (var decision in decisions) + { + decision.Reject(new Rejection("Failed to add missing artist", RejectionType.Temporary)); + } + + return null; + } + } + + // Put in the newly loaded artist + foreach (var decision in decisions) + { + decision.Item.Artist = dbArtist; + decision.Item.Album.Artist = dbArtist; + decision.Item.Album.ArtistMetadataId = dbArtist.ArtistMetadataId; + } + + artist = dbArtist; + } + + return artist; + } + + private Album EnsureAlbumAdded(List> decisions) + { + var album = decisions.First().Item.Album; + + if (album.Id == 0) + { + var dbAlbum = _albumService.FindById(album.ForeignAlbumId); + + if (dbAlbum == null) + { + _logger.Debug($"Adding remote album {album}"); + try + { + _albumService.InsertMany(new List { album }); + _refreshAlbumService.RefreshAlbumInfo(album, new List { album }, false); + dbAlbum = _albumService.FindById(album.ForeignAlbumId); + } + catch (Exception e) + { + _logger.Error(e, "Failed to add album {0}", album); + RejectAlbum(decisions); + + return null; + } + } + + var release = dbAlbum.AlbumReleases.Value.ExclusiveOrDefault(x => x.ForeignReleaseId == decisions.First().Item.Release.ForeignReleaseId); + if (release == null) + { + RejectAlbum(decisions); + return null; + } + + // Populate the new DB album + foreach (var decision in decisions) + { + decision.Item.Album = dbAlbum; + decision.Item.Release = release; + var trackIds = decision.Item.Tracks.Select(x => x.ForeignTrackId).ToList(); + decision.Item.Tracks = release.Tracks.Value.Where(x => trackIds.Contains(x.ForeignTrackId)).ToList(); + } + } + + return album; + } + + private void RejectAlbum(List> decisions) + { + foreach (var decision in decisions) + { + decision.Reject(new Rejection("Failed to add missing album", RejectionType.Temporary)); + } + } + + private void RemoveExistingTrackFiles(Artist artist, Album album) + { + var rootFolder = _diskProvider.GetParentFolder(artist.Path); + var previousFiles = _mediaFileService.GetFilesByAlbum(album.Id); + + _logger.Debug($"Deleting {previousFiles.Count} existing files for {album}"); + + foreach (var previousFile in previousFiles) + { + var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(previousFile.Path)); + if (_diskProvider.FileExists(previousFile.Path)) + { + _logger.Debug("Removing existing track file: {0}", previousFile); + _recycleBinProvider.DeleteFile(previousFile.Path, subfolder); + } + + _mediaFileService.Delete(previousFile, DeleteMediaFileReason.Upgrade); + } + } + + private string GetSceneReleaseName(DownloadClientItem downloadClientItem) { if (downloadClientItem != null) { diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportArtistDefaults.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportArtistDefaults.cs new file mode 100644 index 000000000..ab0b4e0c1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportArtistDefaults.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public class ImportArtistDefaults + { + public int MetadataProfileId { get; set; } + public int LanguageProfileId { get; set; } + public int QualityProfileId { get; set; } + public bool AlbumFolder { get; set; } + public MonitorTypes Monitored { get; set; } + public HashSet Tags { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs index e1ebc3df2..10dd4b27f 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs @@ -3,23 +3,44 @@ using System.Collections.Generic; using System.IO.Abstractions; using System.Linq; using NLog; -using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; using NzbDrone.Core.MediaFiles.TrackImport.Identification; -using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles.TrackImport { public interface IMakeImportDecision { - List> GetImportDecisions(List musicFiles, Artist artist, FilterFilesType filter, bool includeExisting); - List> GetImportDecisions(List musicFiles, Artist artist, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo); - List> GetImportDecisions(List musicFiles, Artist artist, Album album, AlbumRelease albumRelease, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, FilterFilesType filter, bool newDownload, bool singleRelease, bool includeExisting); + List> GetImportDecisions(List musicFiles, IdentificationOverrides idOverrides, ImportDecisionMakerInfo itemInfo, ImportDecisionMakerConfig config); + } + + public class IdentificationOverrides + { + public Artist Artist { get; set; } + public Album Album { get; set; } + public AlbumRelease AlbumRelease { get; set; } + } + + public class ImportDecisionMakerInfo + { + public DownloadClientItem DownloadClientItem { get; set; } + public ParsedTrackInfo ParsedTrackInfo { get; set; } + } + + public class ImportDecisionMakerConfig + { + public FilterFilesType Filter { get; set; } + public bool NewDownload { get; set; } + public bool SingleRelease { get; set; } + public bool IncludeExisting { get; set; } + public bool AddNewArtists { get; set; } } public class ImportDecisionMaker : IMakeImportDecision @@ -30,10 +51,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport private readonly IAudioTagService _audioTagService; private readonly IAugmentingService _augmentingService; private readonly IIdentificationService _identificationService; - private readonly IAlbumService _albumService; - private readonly IReleaseService _releaseService; - private readonly IEventAggregator _eventAggregator; - private readonly IDiskProvider _diskProvider; + private readonly IRootFolderService _rootFolderService; + private readonly IProfileService _qualityProfileService; private readonly Logger _logger; public ImportDecisionMaker(IEnumerable> trackSpecifications, @@ -42,10 +61,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport IAudioTagService audioTagService, IAugmentingService augmentingService, IIdentificationService identificationService, - IAlbumService albumService, - IReleaseService releaseService, - IEventAggregator eventAggregator, - IDiskProvider diskProvider, + IRootFolderService rootFolderService, + IProfileService qualityProfileService, Logger logger) { _trackSpecifications = trackSpecifications; @@ -54,29 +71,17 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _audioTagService = audioTagService; _augmentingService = augmentingService; _identificationService = identificationService; - _albumService = albumService; - _releaseService = releaseService; - _eventAggregator = eventAggregator; - _diskProvider = diskProvider; + _rootFolderService = rootFolderService; + _qualityProfileService = qualityProfileService; _logger = logger; } - public List> GetImportDecisions(List musicFiles, Artist artist, FilterFilesType filter, bool includeExisting) - { - return GetImportDecisions(musicFiles, artist, null, null, null, null, filter, false, false, true); - } - - public List> GetImportDecisions(List musicFiles, Artist artist, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo) - { - return GetImportDecisions(musicFiles, artist, null, null, downloadClientItem, folderInfo, FilterFilesType.None, true, false, false); - } - - public List> GetImportDecisions(List musicFiles, Artist artist, Album album, AlbumRelease albumRelease, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, FilterFilesType filter, bool newDownload, bool singleRelease, bool includeExisting) + public Tuple, List>> GetLocalTracks(List musicFiles, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, FilterFilesType filter) { var watch = new System.Diagnostics.Stopwatch(); watch.Start(); - var files = filter != FilterFilesType.None && (artist != null) ? _mediaFileService.FilterUnchangedFiles(musicFiles, artist, filter) : musicFiles; + var files = _mediaFileService.FilterUnchangedFiles(musicFiles, filter); var localTracks = new List(); var decisions = new List>(); @@ -85,7 +90,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport if (!files.Any()) { - return decisions; + return Tuple.Create(localTracks, decisions); } ParsedAlbumInfo downloadClientItemInfo = null; @@ -95,19 +100,19 @@ namespace NzbDrone.Core.MediaFiles.TrackImport downloadClientItemInfo = Parser.Parser.ParseAlbumTitle(downloadClientItem.Title); } + int i = 1; foreach (var file in files) { + _logger.ProgressInfo($"Reading file {i++}/{files.Count}"); + var localTrack = new LocalTrack { - Artist = artist, - Album = album, DownloadClientAlbumInfo = downloadClientItemInfo, FolderTrackInfo = folderInfo, Path = file.FullName, Size = file.Length, Modified = file.LastWriteTimeUtc, FileTrackInfo = _audioTagService.ReadTags(file.FullName), - ExistingFile = !newDownload, AdditionalFile = false }; @@ -131,18 +136,36 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _logger.Debug($"Tags parsed for {files.Count} files in {watch.ElapsedMilliseconds}ms"); - var releases = _identificationService.Identify(localTracks, artist, album, albumRelease, newDownload, singleRelease, includeExisting); + return Tuple.Create(localTracks, decisions); + } + + public List> GetImportDecisions(List musicFiles, IdentificationOverrides idOverrides, ImportDecisionMakerInfo itemInfo, ImportDecisionMakerConfig config) + { + idOverrides = idOverrides ?? new IdentificationOverrides(); + itemInfo = itemInfo ?? new ImportDecisionMakerInfo(); + + var trackData = GetLocalTracks(musicFiles, itemInfo.DownloadClientItem, itemInfo.ParsedTrackInfo, config.Filter); + var localTracks = trackData.Item1; + var decisions = trackData.Item2; + + localTracks.ForEach(x => x.ExistingFile = !config.NewDownload); + + var releases = _identificationService.Identify(localTracks, idOverrides, config); foreach (var release in releases) { - release.NewDownload = newDownload; - var releaseDecision = GetDecision(release, downloadClientItem); + // make sure the appropriate quality profile is set for the release artist + // in case it's a new artist + EnsureData(release); + release.NewDownload = config.NewDownload; + + var releaseDecision = GetDecision(release, itemInfo.DownloadClientItem); foreach (var localTrack in release.LocalTracks) { if (releaseDecision.Approved) { - decisions.AddIfNotNull(GetDecision(localTrack, downloadClientItem)); + decisions.AddIfNotNull(GetDecision(localTrack, itemInfo.DownloadClientItem)); } else { @@ -154,6 +177,19 @@ namespace NzbDrone.Core.MediaFiles.TrackImport return decisions; } + private void EnsureData(LocalAlbumRelease release) + { + if (release.AlbumRelease != null && release.AlbumRelease.Album.Value.Artist.Value.QualityProfileId == 0) + { + var rootFolder = _rootFolderService.GetBestRootFolder(release.LocalTracks.First().Path); + var qualityProfile = _qualityProfileService.Get(rootFolder.DefaultQualityProfileId); + + var artist = release.AlbumRelease.Album.Value.Artist.Value; + artist.QualityProfileId = qualityProfile.Id; + artist.QualityProfile = qualityProfile; + } + } + private ImportDecision GetDecision(LocalAlbumRelease localAlbumRelease, DownloadClientItem downloadClientItem) { ImportDecision decision = null; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs index ca83a6e17..f0878b774 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs @@ -15,7 +15,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual } public string Path { get; set; } - public string RelativePath { get; set; } public string Name { get; set; } public long Size { get; set; } public Artist Artist { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index 8fbad9c78..051c20939 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Crypto; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Messaging.Commands; @@ -16,6 +17,7 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles.TrackImport.Manual { @@ -29,6 +31,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual { private readonly IDiskProvider _diskProvider; private readonly IParsingService _parsingService; + private readonly IRootFolderService _rootFolderService; private readonly IDiskScanService _diskScanService; private readonly IMakeImportDecision _importDecisionMaker; private readonly IArtistService _artistService; @@ -44,6 +47,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual public ManualImportService(IDiskProvider diskProvider, IParsingService parsingService, + IRootFolderService rootFolderService, IDiskScanService diskScanService, IMakeImportDecision importDecisionMaker, IArtistService artistService, @@ -59,6 +63,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual { _diskProvider = diskProvider; _parsingService = parsingService; + _rootFolderService = rootFolderService; _diskScanService = diskScanService; _importDecisionMaker = importDecisionMaker; _artistService = artistService; @@ -94,8 +99,19 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual return new List(); } - var decision = _importDecisionMaker.GetImportDecisions(new List { _diskProvider.GetFileInfo(path) }, null, null, null, null, null, FilterFilesType.None, true, false, !replaceExistingFiles); - var result = MapItem(decision.First(), Path.GetDirectoryName(path), downloadId, replaceExistingFiles, false); + var files = new List { _diskProvider.GetFileInfo(path) }; + + var config = new ImportDecisionMakerConfig + { + Filter = FilterFilesType.None, + NewDownload = true, + SingleRelease = false, + IncludeExisting = !replaceExistingFiles, + AddNewArtists = false + }; + + var decision = _importDecisionMaker.GetImportDecisions(files, null, null, config); + var result = MapItem(decision.First(), downloadId, replaceExistingFiles, false); return new List { result }; } @@ -120,9 +136,26 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual } } - var folderInfo = Parser.Parser.ParseMusicTitle(directoryInfo.Name); var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); - var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, null, null, downloadClientItem, folderInfo, filter, true, false, !replaceExistingFiles); + var idOverrides = new IdentificationOverrides + { + Artist = artist + }; + var itemInfo = new ImportDecisionMakerInfo + { + DownloadClientItem = downloadClientItem, + ParsedTrackInfo = Parser.Parser.ParseMusicTitle(directoryInfo.Name) + }; + var config = new ImportDecisionMakerConfig + { + Filter = filter, + NewDownload = true, + SingleRelease = false, + IncludeExisting = !replaceExistingFiles, + AddNewArtists = false + }; + + var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, idOverrides, itemInfo, config); // paths will be different for new and old files which is why we need to map separately var newFiles = artistFiles.Join(decisions, @@ -131,9 +164,9 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual (f, d) => new { File = f, Decision = d }, PathEqualityComparer.Instance); - var newItems = newFiles.Select(x => MapItem(x.Decision, folder, downloadId, replaceExistingFiles, false)); + var newItems = newFiles.Select(x => MapItem(x.Decision, 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)); + var existingItems = existingDecisions.Select(x => MapItem(x, null, replaceExistingFiles, false)); return newItems.Concat(existingItems).ToList(); } @@ -152,7 +185,22 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual var disableReleaseSwitching = group.First().DisableReleaseSwitching; - var decisions = _importDecisionMaker.GetImportDecisions(group.Select(x => _diskProvider.GetFileInfo(x.Path)).ToList(), group.First().Artist, group.First().Album, group.First().Release, null, null, FilterFilesType.None, true, true, !replaceExistingFiles); + var files = group.Select(x => _diskProvider.GetFileInfo(x.Path)).ToList(); + var idOverride = new IdentificationOverrides + { + Artist = group.First().Artist, + Album = group.First().Album, + AlbumRelease = group.First().Release + }; + var config = new ImportDecisionMakerConfig + { + Filter = FilterFilesType.None, + NewDownload = true, + SingleRelease = true, + IncludeExisting = !replaceExistingFiles, + AddNewArtists = false + }; + var decisions = _importDecisionMaker.GetImportDecisions(files, idOverride, null, config); var existingItems = group.Join(decisions, i => i.Path, @@ -187,19 +235,18 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual } var newDecisions = decisions.Except(existingItems.Select(x => x.Decision)); - result.AddRange(newDecisions.Select(x => MapItem(x, x.Item.Artist.Path, null, replaceExistingFiles, disableReleaseSwitching))); + result.AddRange(newDecisions.Select(x => MapItem(x, null, replaceExistingFiles, disableReleaseSwitching))); } return result; } - private ManualImportItem MapItem(ImportDecision decision, string folder, string downloadId, bool replaceExistingFiles, bool disableReleaseSwitching) + private ManualImportItem MapItem(ImportDecision decision, string downloadId, bool replaceExistingFiles, bool disableReleaseSwitching) { var item = new ManualImportItem(); item.Id = HashConverter.GetHashInt31(decision.Item.Path); item.Path = decision.Item.Path; - item.RelativePath = folder.GetRelativePath(decision.Item.Path); item.Name = Path.GetFileNameWithoutExtension(decision.Item.Path); item.DownloadId = downloadId; @@ -276,7 +323,14 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual Release = release }; - albumImportDecisions.Add(new ImportDecision(localTrack)); + var importDecision = new ImportDecision(localTrack); + if (_rootFolderService.GetBestRootFolder(artist.Path) == null) + { + _logger.Warn($"Destination artist folder {artist.Path} not in a Root Folder, skipping import"); + importDecision.Reject(new Rejection($"Destination artist folder {artist.Path} is not in a Root Folder")); + } + + albumImportDecisions.Add(importDecision); fileCount += 1; } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs index ddaa0b7b9..f6deffbc9 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs @@ -18,9 +18,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) { - var artist = item.AlbumRelease.Album.Value.Artist.Value; - var qualityComparer = new QualityModelComparer(artist.QualityProfile); - // check if we are changing release var currentRelease = item.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); var newRelease = item.AlbumRelease; @@ -28,6 +25,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications // if we are, check we are upgrading if (newRelease.Id != currentRelease.Id) { + var qualityComparer = new QualityModelComparer(item.AlbumRelease.Album.Value.Artist.Value.QualityProfile); + // min quality of all new tracks var newMinQuality = item.LocalTracks.Select(x => x.Quality).OrderBy(x => x, qualityComparer).First(); _logger.Debug("Min quality of new files: {0}", newMinQuality); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ArtistPathInRootFolderSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ArtistPathInRootFolderSpecification.cs new file mode 100644 index 000000000..ed414dacc --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ArtistPathInRootFolderSpecification.cs @@ -0,0 +1,40 @@ +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RootFolders; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class ArtistPathInRootFolderSpecification : IImportDecisionEngineSpecification + { + private readonly IRootFolderService _rootFolderService; + private readonly Logger _logger; + + public ArtistPathInRootFolderSpecification(IRootFolderService rootFolderService, + Logger logger) + { + _rootFolderService = rootFolderService; + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease item, DownloadClientItem downloadClientItem) + { + // Prevent imports to artists that are no longer inside a root folder Lidarr manages + var artist = item.AlbumRelease.Album.Value.Artist.Value; + + // a new artist will have empty path, and will end up having path assinged based on file location + var pathToCheck = artist.Path.IsNotNullOrWhiteSpace() ? artist.Path : item.LocalTracks.First().Path.GetParentPath(); + + if (_rootFolderService.GetBestRootFolder(pathToCheck) == null) + { + _logger.Warn($"Destination folder {pathToCheck} not in a Root Folder, skipping import"); + return Decision.Reject($"Destination folder {pathToCheck} is not in a Root Folder"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs index cc1ad01f5..d75d08ee8 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs @@ -21,6 +21,12 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications public Decision IsSatisfiedBy(LocalTrack item, DownloadClientItem downloadClientItem) { + if (!item.Tracks.Any(e => e.TrackFileId > 0)) + { + // No existing tracks, skip. This guards against new artists not having a QualityProfile. + return Decision.Accept(); + } + var downloadPropersAndRepacks = _configService.DownloadPropersAndRepacks; var qualityComparer = new QualityModelComparer(item.Artist.QualityProfile); diff --git a/src/NzbDrone.Core/MetadataSource/ISearchForNewAlbum.cs b/src/NzbDrone.Core/MetadataSource/ISearchForNewAlbum.cs index d1561b46a..1326efb8c 100644 --- a/src/NzbDrone.Core/MetadataSource/ISearchForNewAlbum.cs +++ b/src/NzbDrone.Core/MetadataSource/ISearchForNewAlbum.cs @@ -6,5 +6,6 @@ namespace NzbDrone.Core.MetadataSource public interface ISearchForNewAlbum { List SearchForNewAlbum(string title, string artist); + List SearchForNewAlbumByRecordingIds(List recordingIds); } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index e554f702b..dbd773d20 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -5,7 +5,7 @@ using System.Net; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource.SkyHook.Resource; @@ -21,7 +21,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook private readonly IArtistService _artistService; private readonly IAlbumService _albumService; private readonly IMetadataRequestBuilder _requestBuilder; - private readonly IConfigService _configService; private readonly IMetadataProfileService _metadataProfileService; private static readonly List NonAudioMedia = new List { "DVD", "DVD-Video", "Blu-ray", "HD-DVD", "VCD", "SVCD", "UMD", "VHS" }; @@ -32,11 +31,9 @@ namespace NzbDrone.Core.MetadataSource.SkyHook IArtistService artistService, IAlbumService albumService, Logger logger, - IConfigService configService, IMetadataProfileService metadataProfileService) { _httpClient = httpClient; - _configService = configService; _metadataProfileService = metadataProfileService; _requestBuilder = requestBuilder; _artistService = artistService; @@ -259,6 +256,21 @@ namespace NzbDrone.Core.MetadataSource.SkyHook } } + public List SearchForNewAlbumByRecordingIds(List recordingIds) + { + var ids = recordingIds.Where(x => x.IsNotNullOrWhiteSpace()).Distinct(); + var httpRequest = _requestBuilder.GetRequestBuilder().Create() + .SetSegment("route", "search/fingerprint") + .Build(); + + httpRequest.SetContent(ids.ToJson()); + httpRequest.Headers.ContentType = "application/json"; + + var httpResponse = _httpClient.Post>(httpRequest); + + return httpResponse.Resource.SelectList(MapSearchResult); + } + public List SearchForNewEntity(string title) { try @@ -351,6 +363,17 @@ namespace NzbDrone.Core.MetadataSource.SkyHook if (resource.Releases != null) { album.AlbumReleases = resource.Releases.Select(x => MapRelease(x, artistDict)).Where(x => x.TrackCount > 0).ToList(); + + // Monitor the release with most tracks + var mostTracks = album.AlbumReleases.Value.OrderByDescending(x => x.TrackCount).FirstOrDefault(); + if (mostTracks != null) + { + mostTracks.Monitored = true; + } + } + else + { + album.AlbumReleases = new List(); } album.AnyReleaseOk = true; diff --git a/src/NzbDrone.Core/Music/Commands/BulkRefreshArtistCommand.cs b/src/NzbDrone.Core/Music/Commands/BulkRefreshArtistCommand.cs new file mode 100644 index 000000000..cba189a3b --- /dev/null +++ b/src/NzbDrone.Core/Music/Commands/BulkRefreshArtistCommand.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Music.Commands +{ + public class BulkRefreshArtistCommand : Command + { + public BulkRefreshArtistCommand() + { + } + + public BulkRefreshArtistCommand(List artistIds, bool areNewArtists = false) + { + ArtistIds = artistIds; + AreNewArtists = areNewArtists; + } + + public List ArtistIds { get; set; } + public bool AreNewArtists { get; set; } + + public override bool SendUpdatesToClient => true; + + public override bool UpdateScheduledTask => false; + } +} diff --git a/src/NzbDrone.Core/Music/Handlers/ArtistScannedHandler.cs b/src/NzbDrone.Core/Music/Handlers/ArtistScannedHandler.cs index 0be7f1089..7a415e72b 100644 --- a/src/NzbDrone.Core/Music/Handlers/ArtistScannedHandler.cs +++ b/src/NzbDrone.Core/Music/Handlers/ArtistScannedHandler.cs @@ -13,7 +13,6 @@ namespace NzbDrone.Core.Music private readonly IArtistService _artistService; private readonly IManageCommandQueue _commandQueueManager; private readonly IAlbumAddedService _albumAddedService; - private readonly Logger _logger; public ArtistScannedHandler(IAlbumMonitoredService albumMonitoredService, diff --git a/src/NzbDrone.Core/Music/Services/AddArtistService.cs b/src/NzbDrone.Core/Music/Services/AddArtistService.cs index 70604b9bc..7a7bf033d 100644 --- a/src/NzbDrone.Core/Music/Services/AddArtistService.cs +++ b/src/NzbDrone.Core/Music/Services/AddArtistService.cs @@ -6,6 +6,7 @@ using FluentValidation; using FluentValidation.Results; using NLog; using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Organizer; @@ -115,12 +116,35 @@ namespace NzbDrone.Core.Music private Artist SetPropertiesAndValidate(Artist newArtist) { - if (string.IsNullOrWhiteSpace(newArtist.Path)) + var path = newArtist.Path; + if (string.IsNullOrWhiteSpace(path)) { var folderName = _fileNameBuilder.GetArtistFolder(newArtist); - newArtist.Path = Path.Combine(newArtist.RootFolderPath, folderName); + path = Path.Combine(newArtist.RootFolderPath, folderName); } + // Disambiguate artist path if it exists already + if (_artistService.ArtistPathExists(path)) + { + if (newArtist.Metadata.Value.Disambiguation.IsNotNullOrWhiteSpace()) + { + path += $" ({newArtist.Metadata.Value.Disambiguation})"; + } + + if (_artistService.ArtistPathExists(path)) + { + var basepath = path; + int i = 0; + do + { + i++; + path = basepath + $" ({i})"; + } + while (_artistService.ArtistPathExists(path)); + } + } + + newArtist.Path = path; newArtist.CleanName = newArtist.Metadata.Value.Name.CleanArtistName(); newArtist.SortName = Parser.Parser.NormalizeTitle(newArtist.Metadata.Value.Name).ToLower(); newArtist.Added = DateTime.UtcNow; diff --git a/src/NzbDrone.Core/Music/Services/AlbumEditedService.cs b/src/NzbDrone.Core/Music/Services/AlbumEditedService.cs index a02b115d7..f62a5e5d6 100644 --- a/src/NzbDrone.Core/Music/Services/AlbumEditedService.cs +++ b/src/NzbDrone.Core/Music/Services/AlbumEditedService.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -34,7 +33,7 @@ namespace NzbDrone.Core.Music tracks.ForEach(x => x.TrackFileId = 0); _trackService.SetFileIds(tracks); - _commandQueueManager.Push(new RescanArtistCommand(message.Album.ArtistId, FilterFilesType.Matched)); + _commandQueueManager.Push(new RescanFoldersCommand()); } } } diff --git a/src/NzbDrone.Core/Music/Services/AlbumService.cs b/src/NzbDrone.Core/Music/Services/AlbumService.cs index 23a555310..8aa5591cb 100644 --- a/src/NzbDrone.Core/Music/Services/AlbumService.cs +++ b/src/NzbDrone.Core/Music/Services/AlbumService.cs @@ -144,10 +144,6 @@ namespace NzbDrone.Core.Music .OrderByDescending(s => s.MatchProb) .ToList(); - _logger.Trace("\nFuzzy album match on '{0}':\n{1}", - title, - string.Join("\n", sortedAlbums.Select(x => $"[{x.Album.Title}] {x.Album.CleanTitle}: {x.MatchProb}"))); - return sortedAlbums.TakeWhile((x, i) => i == 0 || sortedAlbums[i - 1].MatchProb - x.MatchProb < fuzzGap) .TakeWhile((x, i) => x.MatchProb > fuzzThreshold || (i > 0 && sortedAlbums[i - 1].MatchProb > fuzzThreshold)) .Select(x => x.Album) diff --git a/src/NzbDrone.Core/Music/Services/ArtistService.cs b/src/NzbDrone.Core/Music/Services/ArtistService.cs index d5cde9ec7..f69206f1b 100644 --- a/src/NzbDrone.Core/Music/Services/ArtistService.cs +++ b/src/NzbDrone.Core/Music/Services/ArtistService.cs @@ -99,6 +99,7 @@ namespace NzbDrone.Core.Music { tc((a, t) => a.CleanName.FuzzyMatch(t), cleanTitle), tc((a, t) => a.Name.FuzzyMatch(t), title), + tc((a, t) => a.Metadata.Value.Aliases.Concat(new List { a.Name }).Max(x => x.CleanArtistName().FuzzyMatch(t)), cleanTitle), }; if (title.StartsWith("The ", StringComparison.CurrentCultureIgnoreCase)) @@ -156,10 +157,6 @@ namespace NzbDrone.Core.Music .OrderByDescending(s => s.MatchProb) .ToList(); - _logger.Trace("\nFuzzy artist match on '{0}':\n{1}", - title, - string.Join("\n", sortedArtists.Select(x => $"[{x.Artist.Name}] {x.Artist.CleanName}: {x.MatchProb}"))); - return sortedArtists.TakeWhile((x, i) => i == 0 || sortedArtists[i - 1].MatchProb - x.MatchProb < fuzzGap) .TakeWhile((x, i) => x.MatchProb > fuzzThreshold || (i > 0 && sortedArtists[i - 1].MatchProb > fuzzThreshold)) .Select(x => x.Artist) diff --git a/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs b/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs index 78b2779bf..ba00ea9cb 100644 --- a/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs +++ b/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs @@ -11,24 +11,29 @@ using NzbDrone.Core.Exceptions; using NzbDrone.Core.History; using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Music.Commands; using NzbDrone.Core.Music.Events; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.Music { - public class RefreshArtistService : RefreshEntityServiceBase, IExecute + public class RefreshArtistService : RefreshEntityServiceBase, + IExecute, + IExecute { private readonly IProvideArtistInfo _artistInfo; private readonly IArtistService _artistService; private readonly IAlbumService _albumService; private readonly IRefreshAlbumService _refreshAlbumService; private readonly IEventAggregator _eventAggregator; + private readonly IManageCommandQueue _commandQueueManager; private readonly IMediaFileService _mediaFileService; private readonly IHistoryService _historyService; - private readonly IDiskScanService _diskScanService; + private readonly IRootFolderService _rootFolderService; private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed; private readonly IConfigService _configService; private readonly IImportListExclusionService _importListExclusionService; @@ -40,9 +45,10 @@ namespace NzbDrone.Core.Music IAlbumService albumService, IRefreshAlbumService refreshAlbumService, IEventAggregator eventAggregator, + IManageCommandQueue commandQueueManager, IMediaFileService mediaFileService, IHistoryService historyService, - IDiskScanService diskScanService, + IRootFolderService rootFolderService, ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed, IConfigService configService, IImportListExclusionService importListExclusionService, @@ -54,9 +60,10 @@ namespace NzbDrone.Core.Music _albumService = albumService; _refreshAlbumService = refreshAlbumService; _eventAggregator = eventAggregator; + _commandQueueManager = commandQueueManager; _mediaFileService = mediaFileService; _historyService = historyService; - _diskScanService = diskScanService; + _rootFolderService = rootFolderService; _checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed; _configService = configService; _importListExclusionService = importListExclusionService; @@ -258,24 +265,24 @@ namespace NzbDrone.Core.Music _eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(entity, newChildren, updateChildren)); } - private void RescanArtist(Artist artist, bool isNew, CommandTrigger trigger, bool infoUpdated) + private void Rescan(List artistIds, bool isNew, CommandTrigger trigger, bool infoUpdated) { var rescanAfterRefresh = _configService.RescanAfterRefresh; var shouldRescan = true; if (isNew) { - _logger.Trace("Forcing rescan of {0}. Reason: New artist", artist); + _logger.Trace("Forcing rescan. Reason: New artist added"); shouldRescan = true; } else if (rescanAfterRefresh == RescanAfterRefreshType.Never) { - _logger.Trace("Skipping rescan of {0}. Reason: never recan after refresh", artist); + _logger.Trace("Skipping rescan. Reason: never rescan after refresh"); shouldRescan = false; } else if (rescanAfterRefresh == RescanAfterRefreshType.AfterManual && trigger != CommandTrigger.Manual) { - _logger.Trace("Skipping rescan of {0}. Reason: not after automatic scans", artist); + _logger.Trace("Skipping rescan. Reason: not after automatic refreshes"); shouldRescan = false; } @@ -289,64 +296,80 @@ namespace NzbDrone.Core.Music // If some metadata has been updated then rescan unmatched files. // Otherwise only scan files that haven't been seen before. var filter = infoUpdated ? FilterFilesType.Matched : FilterFilesType.Known; - _diskScanService.Scan(artist, filter); + _logger.Trace($"InfoUpdated: {infoUpdated}, using scan filter {filter}"); + + var folders = _rootFolderService.All().Select(x => x.Path).ToList(); + + _commandQueueManager.Push(new RescanFoldersCommand(folders, filter, artistIds)); } catch (Exception e) { - _logger.Error(e, "Couldn't rescan artist {0}", artist); + _logger.Error(e, "Couldn't rescan"); } } - public void Execute(RefreshArtistCommand message) + private void RefreshSelectedArtists(List artistIds, bool isNew, CommandTrigger trigger) { - var trigger = message.Trigger; - var isNew = message.IsNewArtist; + bool updated = false; + var artists = _artistService.GetArtists(artistIds); - if (message.ArtistId.HasValue) + foreach (var artist in artists) { - var artist = _artistService.GetArtist(message.ArtistId.Value); - bool updated = false; try { - updated = RefreshEntityInfo(artist, null, true, false); - _logger.Trace($"Artist {artist} updated: {updated}"); - RescanArtist(artist, isNew, trigger, updated); + updated |= RefreshEntityInfo(artist, null, true, false); } catch (Exception e) { _logger.Error(e, "Couldn't refresh info for {0}", artist); - RescanArtist(artist, isNew, trigger, updated); - throw; } } + + Rescan(artistIds, isNew, trigger, updated); + } + + public void Execute(BulkRefreshArtistCommand message) + { + RefreshSelectedArtists(message.ArtistIds, message.AreNewArtists, message.Trigger); + } + + public void Execute(RefreshArtistCommand message) + { + var trigger = message.Trigger; + var isNew = message.IsNewArtist; + + if (message.ArtistId.HasValue) + { + RefreshSelectedArtists(new List { message.ArtistId.Value }, isNew, trigger); + } else { - var allArtists = _artistService.GetAllArtists().OrderBy(c => c.Name).ToList(); + var updated = false; + var artists = _artistService.GetAllArtists().OrderBy(c => c.Name).ToList(); + var artistIds = artists.Select(x => x.Id).ToList(); - foreach (var artist in allArtists) + foreach (var artist in artists) { var manualTrigger = message.Trigger == CommandTrigger.Manual; if (manualTrigger || _checkIfArtistShouldBeRefreshed.ShouldRefresh(artist)) { - bool updated = false; try { - updated = RefreshEntityInfo(artist, null, manualTrigger, false); + updated |= RefreshEntityInfo(artist, null, manualTrigger, false); } catch (Exception e) { _logger.Error(e, "Couldn't refresh info for {0}", artist); } - - RescanArtist(artist, false, trigger, updated); } else { _logger.Info("Skipping refresh of artist: {0}", artist.Name); - RescanArtist(artist, false, trigger, false); } } + + Rescan(artistIds, isNew, trigger, updated); } } } diff --git a/src/NzbDrone.Core/Music/Utilities/AddArtistValidator.cs b/src/NzbDrone.Core/Music/Utilities/AddArtistValidator.cs index 745cb7f9d..c77cfacc2 100644 --- a/src/NzbDrone.Core/Music/Utilities/AddArtistValidator.cs +++ b/src/NzbDrone.Core/Music/Utilities/AddArtistValidator.cs @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Music public AddArtistValidator(RootFolderValidator rootFolderValidator, ArtistPathValidator artistPathValidator, ArtistAncestorValidator artistAncestorValidator, - ProfileExistsValidator profileExistsValidator, + QualityProfileExistsValidator qualityProfileExistsValidator, MetadataProfileExistsValidator metadataProfileExistsValidator) { RuleFor(c => c.Path).Cascade(CascadeMode.StopOnFirstFailure) @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Music .SetValidator(artistPathValidator) .SetValidator(artistAncestorValidator); - RuleFor(c => c.QualityProfileId).SetValidator(profileExistsValidator); + RuleFor(c => c.QualityProfileId).SetValidator(qualityProfileExistsValidator); RuleFor(c => c.MetadataProfileId).SetValidator(metadataProfileExistsValidator); } diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 93115a1ef..8383fd7df 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -326,9 +326,11 @@ namespace NzbDrone.Core.Parser return null; } + var artistName = artist.Name == "Various Artists" ? "VA" : artist.Name.RemoveAccent(); + Logger.Debug("Parsing string '{0}' using search criteria artist: '{1}' album: '{2}'", title, - artist.Name.RemoveAccent(), + artistName.RemoveAccent(), string.Join(", ", album.Select(a => a.Title.RemoveAccent()))); var releaseTitle = RemoveFileExtension(title); @@ -339,7 +341,7 @@ namespace NzbDrone.Core.Parser simpleTitle = CleanTorrentSuffixRegex.Replace(simpleTitle); - var escapedArtist = Regex.Escape(artist.Name.RemoveAccent()).Replace(@"\ ", @"[\W_]"); + var escapedArtist = Regex.Escape(artistName.RemoveAccent()).Replace(@"\ ", @"[\W_]"); var escapedAlbums = string.Join("|", album.Select(s => Regex.Escape(s.Title.RemoveAccent())).ToList()).Replace(@"\ ", @"[\W_]"); var releaseRegex = new Regex(@"^(\W*|\b)(?" + escapedArtist + @")(\W*|\b).*(\W*|\b)(?" + escapedAlbums + @")(\W*|\b)", RegexOptions.IgnoreCase); @@ -492,10 +494,8 @@ namespace NzbDrone.Core.Parser public static string CleanArtistName(this string name) { - long number = 0; - - //If Title only contains numbers return it as is. - if (long.TryParse(name, out number)) + // If Title only contains numbers return it as is. + if (long.TryParse(name, out _)) { return name; } @@ -650,9 +650,6 @@ namespace NzbDrone.Core.Parser artistName = artistName.Trim(' '); - int trackNumber; - int.TryParse(matchCollection[0].Groups["trackNumber"].Value, out trackNumber); - ParsedTrackInfo result = new ParsedTrackInfo(); result.ArtistTitle = artistName; diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs index cf42814b4..aa3c31446 100644 --- a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.ImportLists; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.Profiles.Metadata { @@ -25,16 +26,19 @@ namespace NzbDrone.Core.Profiles.Metadata private readonly IMetadataProfileRepository _profileRepository; private readonly IArtistService _artistService; private readonly IImportListFactory _importListFactory; + private readonly IRootFolderService _rootFolderService; private readonly Logger _logger; public MetadataProfileService(IMetadataProfileRepository profileRepository, IArtistService artistService, IImportListFactory importListFactory, + IRootFolderService rootFolderService, Logger logger) { _profileRepository = profileRepository; _artistService = artistService; _importListFactory = importListFactory; + _rootFolderService = rootFolderService; _logger = logger; } @@ -59,7 +63,8 @@ namespace NzbDrone.Core.Profiles.Metadata if (profile.Name == NONE_PROFILE_NAME || _artistService.GetAllArtists().Any(c => c.MetadataProfileId == id) || - _importListFactory.All().Any(c => c.MetadataProfileId == id)) + _importListFactory.All().Any(c => c.MetadataProfileId == id) || + _rootFolderService.All().Any(c => c.DefaultMetadataProfileId == id)) { throw new MetadataProfileInUseException(profile.Name); } diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs index 18e0c87d6..bcceaccf4 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs @@ -6,6 +6,7 @@ using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; using NzbDrone.Core.Qualities; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.Profiles.Qualities { @@ -25,13 +26,19 @@ namespace NzbDrone.Core.Profiles.Qualities private readonly IProfileRepository _profileRepository; private readonly IArtistService _artistService; private readonly IImportListFactory _importListFactory; + private readonly IRootFolderService _rootFolderService; private readonly Logger _logger; - public QualityProfileService(IProfileRepository profileRepository, IArtistService artistService, IImportListFactory importListFactory, Logger logger) + public QualityProfileService(IProfileRepository profileRepository, + IArtistService artistService, + IImportListFactory importListFactory, + IRootFolderService rootFolderService, + Logger logger) { _profileRepository = profileRepository; _artistService = artistService; _importListFactory = importListFactory; + _rootFolderService = rootFolderService; _logger = logger; } @@ -47,7 +54,9 @@ namespace NzbDrone.Core.Profiles.Qualities public void Delete(int id) { - if (_artistService.GetAllArtists().Any(c => c.QualityProfileId == id) || _importListFactory.All().Any(c => c.ProfileId == id)) + if (_artistService.GetAllArtists().Any(c => c.QualityProfileId == id) || + _importListFactory.All().Any(c => c.ProfileId == id) || + _rootFolderService.All().Any(c => c.DefaultQualityProfileId == id)) { var profile = _profileRepository.Get(id); throw new QualityProfileInUseException(profile.Name); diff --git a/src/NzbDrone.Core/RootFolders/RootFolder.cs b/src/NzbDrone.Core/RootFolders/RootFolder.cs index 7e6c5444b..528d02cd8 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolder.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolder.cs @@ -1,16 +1,20 @@ using System.Collections.Generic; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Music; namespace NzbDrone.Core.RootFolders { public class RootFolder : ModelBase { + public string Name { get; set; } public string Path { get; set; } + public int DefaultMetadataProfileId { get; set; } + public int DefaultQualityProfileId { get; set; } + public MonitorTypes DefaultMonitorOption { get; set; } + public HashSet DefaultTags { get; set; } public bool Accessible { get; set; } public long? FreeSpace { get; set; } public long? TotalSpace { get; set; } - - public List UnmappedFolders { get; set; } } } diff --git a/src/NzbDrone.Core/RootFolders/RootFolderRepository.cs b/src/NzbDrone.Core/RootFolders/RootFolderRepository.cs index 21a1a5264..47cc06d55 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolderRepository.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolderRepository.cs @@ -15,5 +15,12 @@ namespace NzbDrone.Core.RootFolders } protected override bool PublishModelEvents => true; + + public new void Delete(int id) + { + var model = Get(id); + base.Delete(id); + ModelDeleted(model); + } } } diff --git a/src/NzbDrone.Core/RootFolders/RootFolderService.cs b/src/NzbDrone.Core/RootFolders/RootFolderService.cs index 9d1cc1194..541e7cb06 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolderService.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolderService.cs @@ -7,17 +7,22 @@ using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Music; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.RootFolders { public interface IRootFolderService { List All(); - List AllWithUnmappedFolders(); - RootFolder Add(RootFolder rootDir); + List AllWithSpaceStats(); + RootFolder Add(RootFolder rootFolder); + RootFolder Update(RootFolder rootFolder); void Remove(int id); RootFolder Get(int id); + List AllForTag(int tagId); + RootFolder GetBestRootFolder(string path); string GetBestRootFolderPath(string path); } @@ -25,30 +30,17 @@ namespace NzbDrone.Core.RootFolders { private readonly IRootFolderRepository _rootFolderRepository; private readonly IDiskProvider _diskProvider; - private readonly IArtistRepository _artistRepository; + private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; - private static readonly HashSet SpecialFolders = new HashSet - { - "$recycle.bin", - "system volume information", - "recycler", - "lost+found", - ".appledb", - ".appledesktop", - ".appledouble", - "@eadir", - ".grab" - }; - public RootFolderService(IRootFolderRepository rootFolderRepository, IDiskProvider diskProvider, - IArtistRepository artistRepository, + IManageCommandQueue commandQueueManager, Logger logger) { _rootFolderRepository = rootFolderRepository; _diskProvider = diskProvider; - _artistRepository = artistRepository; + _commandQueueManager = commandQueueManager; _logger = logger; } @@ -59,7 +51,7 @@ namespace NzbDrone.Core.RootFolders return rootFolders; } - public List AllWithUnmappedFolders() + public List AllWithSpaceStats() { var rootFolders = _rootFolderRepository.All().ToList(); @@ -77,17 +69,14 @@ namespace NzbDrone.Core.RootFolders catch (Exception ex) { _logger.Error(ex, "Unable to get free space and unmapped folders for root folder {0}", folder.Path); - folder.UnmappedFolders = new List(); } }); return rootFolders; } - public RootFolder Add(RootFolder rootFolder) + private void VerifyRootFolder(RootFolder rootFolder) { - var all = All(); - if (string.IsNullOrWhiteSpace(rootFolder.Path) || !Path.IsPathRooted(rootFolder.Path)) { throw new ArgumentException("Invalid path"); @@ -98,60 +87,44 @@ namespace NzbDrone.Core.RootFolders throw new DirectoryNotFoundException("Can't add root directory that doesn't exist."); } - if (all.Exists(r => r.Path.PathEquals(rootFolder.Path))) + if (!_diskProvider.FolderWritable(rootFolder.Path)) { - throw new InvalidOperationException("Recent directory already exists."); + throw new UnauthorizedAccessException(string.Format("Root folder path '{0}' is not writable by user '{1}'", rootFolder.Path, Environment.UserName)); } + } - if (!_diskProvider.FolderWritable(rootFolder.Path)) + public RootFolder Add(RootFolder rootFolder) + { + VerifyRootFolder(rootFolder); + + if (All().Exists(r => r.Path.PathEquals(rootFolder.Path))) { - throw new UnauthorizedAccessException(string.Format("Root folder path '{0}' is not writable by user '{1}'", rootFolder.Path, Environment.UserName)); + throw new InvalidOperationException("Root folder already exists."); } _rootFolderRepository.Insert(rootFolder); + _commandQueueManager.Push(new RescanFoldersCommand(new List { rootFolder.Path }, FilterFilesType.None, null)); + GetDetails(rootFolder); return rootFolder; } - public void Remove(int id) - { - _rootFolderRepository.Delete(id); - } - - private List GetUnmappedFolders(string path) + public RootFolder Update(RootFolder rootFolder) { - _logger.Debug("Generating list of unmapped folders"); - - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentException("Invalid path provided", nameof(path)); - } + VerifyRootFolder(rootFolder); - var results = new List(); - var artist = _artistRepository.All().ToList(); + _rootFolderRepository.Update(rootFolder); - if (!_diskProvider.FolderExists(path)) - { - _logger.Debug("Path supplied does not exist: {0}", path); - return results; - } - - var possibleArtistFolders = _diskProvider.GetDirectories(path).ToList(); - var unmappedFolders = possibleArtistFolders.Except(artist.Select(s => s.Path), PathEqualityComparer.Instance).ToList(); - - foreach (string unmappedFolder in unmappedFolders) - { - var di = new DirectoryInfo(unmappedFolder.Normalize()); - results.Add(new UnmappedFolder { Name = di.Name, Path = di.FullName }); - } + GetDetails(rootFolder); - var setToRemove = SpecialFolders; - results.RemoveAll(x => setToRemove.Contains(new DirectoryInfo(x.Path.ToLowerInvariant()).Name)); + return rootFolder; + } - _logger.Debug("{0} unmapped folders detected.", results.Count); - return results.OrderBy(u => u.Name, StringComparer.InvariantCultureIgnoreCase).ToList(); + public void Remove(int id) + { + _rootFolderRepository.Delete(id); } public RootFolder Get(int id) @@ -162,11 +135,21 @@ namespace NzbDrone.Core.RootFolders return rootFolder; } - public string GetBestRootFolderPath(string path) + public List AllForTag(int tagId) + { + return All().Where(r => r.DefaultTags.Contains(tagId)).ToList(); + } + + public RootFolder GetBestRootFolder(string path) { - var possibleRootFolder = All().Where(r => r.Path.IsParentPath(path)) + return All().Where(r => PathEqualityComparer.Instance.Equals(r.Path, path) || r.Path.IsParentPath(path)) .OrderByDescending(r => r.Path.Length) .FirstOrDefault(); + } + + public string GetBestRootFolderPath(string path) + { + var possibleRootFolder = GetBestRootFolder(path); if (possibleRootFolder == null) { @@ -185,7 +168,6 @@ namespace NzbDrone.Core.RootFolders rootFolder.Accessible = true; rootFolder.FreeSpace = _diskProvider.GetAvailableSpace(rootFolder.Path); rootFolder.TotalSpace = _diskProvider.GetTotalSize(rootFolder.Path); - rootFolder.UnmappedFolders = GetUnmappedFolders(rootFolder.Path); } }).Wait(5000); } diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index 7037ff57f..1730ce760 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -12,12 +12,13 @@ namespace NzbDrone.Core.Tags public List RestrictionIds { get; set; } public List DelayProfileIds { get; set; } public List ImportListIds { get; set; } + public List RootFolderIds { get; set; } public bool InUse { get { - return ArtistIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any(); + return ArtistIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || RootFolderIds.Any(); } } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 87ecbe808..9c5d07eb6 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Music; using NzbDrone.Core.Notifications; using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.Tags { @@ -31,6 +32,7 @@ namespace NzbDrone.Core.Tags private readonly INotificationFactory _notificationFactory; private readonly IReleaseProfileService _releaseProfileService; private readonly IArtistService _artistService; + private readonly IRootFolderService _rootFolderService; public TagService(ITagRepository repo, IEventAggregator eventAggregator, @@ -38,7 +40,8 @@ namespace NzbDrone.Core.Tags ImportListFactory importListFactory, INotificationFactory notificationFactory, IReleaseProfileService releaseProfileService, - IArtistService artistService) + IArtistService artistService, + IRootFolderService rootFolderService) { _repo = repo; _eventAggregator = eventAggregator; @@ -47,6 +50,7 @@ namespace NzbDrone.Core.Tags _notificationFactory = notificationFactory; _releaseProfileService = releaseProfileService; _artistService = artistService; + _rootFolderService = rootFolderService; } public Tag GetTag(int tagId) @@ -74,6 +78,7 @@ namespace NzbDrone.Core.Tags var notifications = _notificationFactory.AllForTag(tagId); var restrictions = _releaseProfileService.AllForTag(tagId); var artist = _artistService.AllForTag(tagId); + var rootFolders = _rootFolderService.AllForTag(tagId); return new TagDetails { @@ -83,7 +88,8 @@ namespace NzbDrone.Core.Tags ImportListIds = importLists.Select(c => c.Id).ToList(), NotificationIds = notifications.Select(c => c.Id).ToList(), RestrictionIds = restrictions.Select(c => c.Id).ToList(), - ArtistIds = artist.Select(c => c.Id).ToList() + ArtistIds = artist.Select(c => c.Id).ToList(), + RootFolderIds = rootFolders.Select(c => c.Id).ToList() }; } @@ -95,6 +101,7 @@ namespace NzbDrone.Core.Tags var notifications = _notificationFactory.All(); var restrictions = _releaseProfileService.All(); var artists = _artistService.GetAllArtists(); + var rootFolders = _rootFolderService.All(); var details = new List(); @@ -108,7 +115,8 @@ namespace NzbDrone.Core.Tags ImportListIds = importLists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), - ArtistIds = artists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList() + ArtistIds = artists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + RootFolderIds = rootFolders.Where(c => c.DefaultTags.Contains(tag.Id)).Select(c => c.Id).ToList() }); } diff --git a/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs b/src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs similarity index 80% rename from src/NzbDrone.Core/Validation/ProfileExistsValidator.cs rename to src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs index c708a196e..657260dd4 100644 --- a/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs +++ b/src/NzbDrone.Core/Validation/QualityProfileExistsValidator.cs @@ -3,11 +3,11 @@ using NzbDrone.Core.Profiles.Qualities; namespace NzbDrone.Core.Validation { - public class ProfileExistsValidator : PropertyValidator + public class QualityProfileExistsValidator : PropertyValidator { private readonly IProfileService _profileService; - public ProfileExistsValidator(IProfileService profileService) + public QualityProfileExistsValidator(IProfileService profileService) : base("Quality Profile does not exist") { _profileService = profileService; diff --git a/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs index bd2f7d0ec..b286b4d25 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs @@ -115,7 +115,6 @@ namespace NzbDrone.Integration.Test.ApiTests result.Should().HaveCount(1); result.First().Should().ContainKey("path"); - result.First().Should().ContainKey("relativePath"); result.First().Should().ContainKey("name"); result.First()["name"].Should().Be("somevideo.mp3"); diff --git a/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs index 377597389..d11f1bcb6 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs @@ -1,6 +1,8 @@ using System.Linq; using FluentAssertions; +using Lidarr.Api.V1.RootFolders; using NUnit.Framework; +using NzbDrone.Core.Music; using NzbDrone.Core.Qualities; namespace NzbDrone.Integration.Test.ApiTests @@ -8,6 +10,20 @@ namespace NzbDrone.Integration.Test.ApiTests [TestFixture] public class WantedFixture : IntegrationTest { + [SetUp] + public void Setup() + { + // Add a root folder + RootFolders.Post(new RootFolderResource + { + Name = "TestLibrary", + Path = ArtistRootFolder, + DefaultMetadataProfileId = 1, + DefaultQualityProfileId = 1, + DefaultMonitorOption = MonitorTypes.All + }); + } + [Test] [Order(0)] public void missing_should_be_empty()