From 4413c7e46cfd6177cb3dddc61e74701fe58cbd1d Mon Sep 17 00:00:00 2001 From: ta264 Date: Sun, 25 Aug 2019 16:49:30 +0100 Subject: [PATCH] New: Unmapped files view (#888) * New: Unmapped files view Displays all trackfiles that haven't been matched to a track. Generalised the file details component and adds it to the album details screen. * Add sorting by quality * New: MediaServiceTests & MediaRepoTests --- .../src/Album/Details/TrackActionsCell.js | 89 +++++- frontend/src/Album/Details/TrackRow.js | 25 +- .../src/Album/Details/TrackRowConnector.js | 8 +- frontend/src/App/AppRoutes.js | 6 + .../Components/Page/Sidebar/PageSidebar.js | 6 +- frontend/src/Components/SignalRConnector.js | 6 +- frontend/src/InteractiveImport/FileDetails.js | 258 ------------------ .../Track/SelectTrackModalContent.js | 4 +- .../Store/Actions/interactiveImportActions.js | 2 +- .../src/Store/Actions/trackFileActions.js | 71 ++++- .../ExpandingFileDetails.css} | 20 +- .../src/TrackFile/ExpandingFileDetails.js | 83 ++++++ frontend/src/TrackFile/FileDetails.css | 11 + frontend/src/TrackFile/FileDetails.js | 206 ++++++++++++++ .../src/TrackFile/FileDetailsConnector.js | 77 ++++++ frontend/src/TrackFile/FileDetailsModal.js | 52 ++++ .../src/UnmappedFiles/UnmappedFilesTable.js | 161 +++++++++++ .../UnmappedFilesTableConnector.js | 100 +++++++ .../UnmappedFilesTableHeader.css | 19 ++ .../UnmappedFiles/UnmappedFilesTableHeader.js | 77 ++++++ .../UnmappedFiles/UnmappedFilesTableRow.css | 22 ++ .../UnmappedFiles/UnmappedFilesTableRow.js | 218 +++++++++++++++ .../TrackFiles/TrackFileModule.cs | 67 +++-- .../TrackFiles/TrackFileResource.cs | 29 +- src/NzbDrone.Automation.Test/MainPagesTest.cs | 4 +- .../PageModel/PageBase.cs | 2 +- .../MediaFiles/MediaFileRepositoryFixture.cs | 72 ++++- .../MediaFileServiceFixture.cs | 66 +++++ .../MediaFileTableCleanupServiceFixture.cs | 40 +-- .../MediaFiles/MediaFileDeletionService.cs | 21 +- .../MediaFiles/MediaFileRepository.cs | 13 + .../MediaFiles/MediaFileService.cs | 19 +- .../MediaFileTableCleanupService.cs | 45 +-- src/NzbDrone.Core/Music/TrackRepository.cs | 6 + src/NzbDrone.Core/Music/TrackService.cs | 6 + test.sh | 2 +- 36 files changed, 1508 insertions(+), 405 deletions(-) delete mode 100644 frontend/src/InteractiveImport/FileDetails.js rename frontend/src/{InteractiveImport/FileDetails.css => TrackFile/ExpandingFileDetails.css} (89%) create mode 100644 frontend/src/TrackFile/ExpandingFileDetails.js create mode 100644 frontend/src/TrackFile/FileDetails.css create mode 100644 frontend/src/TrackFile/FileDetails.js create mode 100644 frontend/src/TrackFile/FileDetailsConnector.js create mode 100644 frontend/src/TrackFile/FileDetailsModal.js create mode 100644 frontend/src/UnmappedFiles/UnmappedFilesTable.js create mode 100644 frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js create mode 100644 frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css create mode 100644 frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js create mode 100644 frontend/src/UnmappedFiles/UnmappedFilesTableRow.css create mode 100644 frontend/src/UnmappedFiles/UnmappedFilesTableRow.js create mode 100644 src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs diff --git a/frontend/src/Album/Details/TrackActionsCell.js b/frontend/src/Album/Details/TrackActionsCell.js index 82d4b3466..db73b35b7 100644 --- a/frontend/src/Album/Details/TrackActionsCell.js +++ b/frontend/src/Album/Details/TrackActionsCell.js @@ -1,34 +1,109 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import IconButton from 'Components/Link/IconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import FileDetailsModal from 'TrackFile/FileDetailsModal'; import styles from './TrackActionsCell.css'; class TrackActionsCell extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onDeleteFilePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.deleteTrackFile({ id: this.props.trackFileId }); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + // // Render render() { - return ( + const { + trackFileId, + trackFilePath + } = this.props; - // TODO: Placeholder until we figure out what to show here. + const { + isDetailsModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + return ( - + } + { + trackFilePath && + + } + + + + ); } } TrackActionsCell.propTypes = { id: PropTypes.number.isRequired, - albumId: PropTypes.number.isRequired + albumId: PropTypes.number.isRequired, + trackFilePath: PropTypes.string, + trackFileId: PropTypes.number.isRequired, + deleteTrackFile: PropTypes.func.isRequired }; export default TrackActionsCell; diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index f830f5959..217215f5c 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -12,24 +12,6 @@ import styles from './TrackRow.css'; class TrackRow extends Component { - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - } - // // Render @@ -44,7 +26,8 @@ class TrackRow extends Component { duration, trackFilePath, trackFileRelativePath, - columns + columns, + deleteTrackFile } = this.props; return ( @@ -160,6 +143,9 @@ class TrackRow extends Component { key={name} albumId={albumId} id={id} + trackFilePath={trackFilePath} + trackFileId={trackFileId} + deleteTrackFile={deleteTrackFile} /> ); } @@ -173,6 +159,7 @@ class TrackRow extends Component { } TrackRow.propTypes = { + deleteTrackFile: PropTypes.func.isRequired, id: PropTypes.number.isRequired, albumId: PropTypes.number.isRequired, trackFileId: PropTypes.number, diff --git a/frontend/src/Album/Details/TrackRowConnector.js b/frontend/src/Album/Details/TrackRowConnector.js index 50c290afd..8074c7b61 100644 --- a/frontend/src/Album/Details/TrackRowConnector.js +++ b/frontend/src/Album/Details/TrackRowConnector.js @@ -1,6 +1,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector'; +import { deleteTrackFile } from 'Store/Actions/trackFileActions'; import TrackRow from './TrackRow'; function createMapStateToProps() { @@ -15,4 +16,9 @@ function createMapStateToProps() { } ); } -export default connect(createMapStateToProps)(TrackRow); + +const mapDispatchToProps = { + deleteTrackFile +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow); diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 249f86661..ed55547e0 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -9,6 +9,7 @@ import AddNewArtistConnector from 'AddArtist/AddNewArtist/AddNewArtistConnector' import ImportArtist from 'AddArtist/ImportArtist/ImportArtist'; import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector'; import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector'; +import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector'; import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector'; import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; @@ -90,6 +91,11 @@ function AppRoutes(props) { component={AlbumStudioConnector} /> + + { diff --git a/frontend/src/InteractiveImport/FileDetails.js b/frontend/src/InteractiveImport/FileDetails.js deleted file mode 100644 index 6a7383ca7..000000000 --- a/frontend/src/InteractiveImport/FileDetails.js +++ /dev/null @@ -1,258 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; -import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; -import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; -import styles from './FileDetails.css'; - -class FileDetails extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isExpanded: props.isExpanded - }; - } - - // - // Listeners - - onExpandPress = () => { - const { - isExpanded - } = this.state; - this.setState({ isExpanded: !isExpanded }); - } - - // - // Render - - renderRejections() { - const { - rejections - } = this.props; - - return ( - - - Rejections - - { - _.map(rejections, (item, key) => { - return ( - - {item.reason} - - ); - }) - } - - ); - } - - render() { - const { - filename, - audioTags, - rejections - } = this.props; - - const { - isExpanded - } = this.state; - - return ( -
-
-
- {filename} -
- -
- -
-
- -
- { - isExpanded && -
- - - { - audioTags.title !== undefined && - - } - { - audioTags.trackNumbers[0] > 0 && - - } - { - audioTags.discNumber > 0 && - - } - { - audioTags.discCount > 0 && - - } - { - audioTags.albumTitle !== undefined && - - } - { - audioTags.artistTitle !== undefined && - - } - { - audioTags.country !== undefined && - - } - { - audioTags.year > 0 && - - } - { - audioTags.label !== undefined && - - } - { - audioTags.catalogNumber !== undefined && - - } - { - audioTags.disambiguation !== undefined && - - } - { - audioTags.duration !== undefined && - - } - { - audioTags.artistMBId !== undefined && - - - - } - { - audioTags.albumMBId !== undefined && - - - - } - { - audioTags.releaseMBId !== undefined && - - - - } - { - audioTags.recordingMBId !== undefined && - - - - } - { - audioTags.trackMBId !== undefined && - - - - } - { - rejections.length > 0 && - this.renderRejections() - } - -
- } -
-
- ); - } -} - -FileDetails.propTypes = { - audioTags: PropTypes.object.isRequired, - filename: PropTypes.string.isRequired, - rejections: PropTypes.arrayOf(PropTypes.object).isRequired, - isExpanded: PropTypes.bool -}; - -export default FileDetails; diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js index 96f903bfa..0934cc047 100644 --- a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js +++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js @@ -15,7 +15,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import SelectTrackRow from './SelectTrackRow'; -import FileDetails from '../FileDetails'; +import ExpandingFileDetails from 'TrackFile/ExpandingFileDetails'; const columns = [ { @@ -151,7 +151,7 @@ class SelectTrackModalContent extends Component {
{errorMessage}
} - { - return Object.assign({}, state, defaultState); - } + [SET_TRACK_FILES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_TRACK_FILES_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_TRACK_FILES]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }) }, defaultState, section); diff --git a/frontend/src/InteractiveImport/FileDetails.css b/frontend/src/TrackFile/ExpandingFileDetails.css similarity index 89% rename from frontend/src/InteractiveImport/FileDetails.css rename to frontend/src/TrackFile/ExpandingFileDetails.css index 967c6c275..d0bd945f8 100644 --- a/frontend/src/InteractiveImport/FileDetails.css +++ b/frontend/src/TrackFile/ExpandingFileDetails.css @@ -9,6 +9,14 @@ } } +.filename { + flex-grow: 1; + margin-right: 10px; + margin-left: 10px; + font-size: 14px; + font-family: $monoSpaceFontFamily; +} + .header { position: relative; display: flex; @@ -17,12 +25,6 @@ font-size: 18px; } -.filename { - flex-grow: 1; - margin-right: 10px; - margin-left: 10px; -} - .expandButton { position: relative; width: 60px; @@ -35,12 +37,6 @@ width: 30px; } -.audioTags { - padding-top: 15px; - padding-bottom: 15px; - border-top: 1px solid $borderColor; -} - .expandButtonIcon { composes: actionButton; diff --git a/frontend/src/TrackFile/ExpandingFileDetails.js b/frontend/src/TrackFile/ExpandingFileDetails.js new file mode 100644 index 000000000..aa1a9ab91 --- /dev/null +++ b/frontend/src/TrackFile/ExpandingFileDetails.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FileDetails from './FileDetails'; +import styles from './ExpandingFileDetails.css'; + +class ExpandingFileDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isExpanded: props.isExpanded + }; + } + + // + // Listeners + + onExpandPress = () => { + const { + isExpanded + } = this.state; + this.setState({ isExpanded: !isExpanded }); + } + + // + // Render + + render() { + const { + filename, + audioTags, + rejections + } = this.props; + + const { + isExpanded + } = this.state; + + return ( +
+
+
+ {filename} +
+ +
+ +
+
+ + { + isExpanded && + + } +
+ ); + } +} + +ExpandingFileDetails.propTypes = { + audioTags: PropTypes.object.isRequired, + filename: PropTypes.string.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object), + isExpanded: PropTypes.bool +}; + +export default ExpandingFileDetails; diff --git a/frontend/src/TrackFile/FileDetails.css b/frontend/src/TrackFile/FileDetails.css new file mode 100644 index 000000000..f3e51ea39 --- /dev/null +++ b/frontend/src/TrackFile/FileDetails.css @@ -0,0 +1,11 @@ +.audioTags { + padding-top: 15px; + padding-bottom: 15px; + /* border-top: 1px solid $borderColor; */ +} + +.filename { + composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css'; + + font-family: $monoSpaceFontFamily; +} diff --git a/frontend/src/TrackFile/FileDetails.js b/frontend/src/TrackFile/FileDetails.js new file mode 100644 index 000000000..725a1f0a4 --- /dev/null +++ b/frontend/src/TrackFile/FileDetails.js @@ -0,0 +1,206 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import Link from 'Components/Link/Link'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import styles from './FileDetails.css'; + +function renderRejections(rejections) { + return ( + + + Rejections + + { + _.map(rejections, (item, key) => { + return ( + + {item.reason} + + ); + }) + } + + ); +} + +function FileDetails(props) { + + const { + filename, + audioTags, + rejections + } = props; + + return ( + +
+ + { + filename && + + } + { + audioTags.title !== undefined && + + } + { + audioTags.trackNumbers[0] > 0 && + + } + { + audioTags.discNumber > 0 && + + } + { + audioTags.discCount > 0 && + + } + { + audioTags.albumTitle !== undefined && + + } + { + audioTags.artistTitle !== undefined && + + } + { + audioTags.country !== undefined && + + } + { + audioTags.year > 0 && + + } + { + audioTags.label !== undefined && + + } + { + audioTags.catalogNumber !== undefined && + + } + { + audioTags.disambiguation !== undefined && + + } + { + audioTags.duration !== undefined && + + } + { + audioTags.artistMBId !== undefined && + + + + } + { + audioTags.albumMBId !== undefined && + + + + } + { + audioTags.releaseMBId !== undefined && + + + + } + { + audioTags.recordingMBId !== undefined && + + + + } + { + audioTags.trackMBId !== undefined && + + + + } + { + !!rejections && rejections.length > 0 && + renderRejections(rejections) + } + +
+
+ ); +} + +FileDetails.propTypes = { + filename: PropTypes.string, + audioTags: PropTypes.object.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object) +}; + +export default FileDetails; diff --git a/frontend/src/TrackFile/FileDetailsConnector.js b/frontend/src/TrackFile/FileDetailsConnector.js new file mode 100644 index 000000000..f52dbcacd --- /dev/null +++ b/frontend/src/TrackFile/FileDetailsConnector.js @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import { fetchTrackFiles } from 'Store/Actions/trackFileActions'; +import FileDetails from './FileDetails'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; + +function createMapStateToProps() { + return createSelector( + (state) => state.trackFiles, + (trackFiles) => { + return { + ...trackFiles + }; + } + ); +} + +const mapDispatchToProps = { + fetchTrackFiles +}; + +class FileDetailsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchTrackFiles({ id: this.props.id }); + } + + // + // Render + + render() { + const { + items, + id, + isFetching, + error + } = this.props; + + const item = _.find(items, { id }); + const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); + + if (isFetching || !item.audioTags) { + return ( + + ); + } else if (error) { + return ( +
{errorMessage}
+ ); + } + + return ( + + ); + + } +} + +FileDetailsConnector.propTypes = { + fetchTrackFiles: PropTypes.func.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + id: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FileDetailsConnector); diff --git a/frontend/src/TrackFile/FileDetailsModal.js b/frontend/src/TrackFile/FileDetailsModal.js new file mode 100644 index 000000000..e9677b647 --- /dev/null +++ b/frontend/src/TrackFile/FileDetailsModal.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FileDetailsConnector from './FileDetailsConnector'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function FileDetailsModal(props) { + const { + isOpen, + onModalClose, + id + } = props; + + return ( + + + + Details + + + + + + + + + + + + ); +} + +FileDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + id: PropTypes.number.isRequired +}; + +export default FileDetailsModal; diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js new file mode 100644 index 000000000..e6a02fee6 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js @@ -0,0 +1,161 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import VirtualTable from 'Components/Table/VirtualTable'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import UnmappedFilesTableRow from './UnmappedFilesTableRow'; +import UnmappedFilesTableHeader from './UnmappedFilesTableHeader'; + +class UnmappedFilesTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + contentBody: null, + scrollTop: 0 + }; + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + rowRenderer = ({ key, rowIndex, style }) => { + const { + items, + columns, + deleteUnmappedFile + } = this.props; + + const item = items[rowIndex]; + + return ( + + ); + } + + // + // Listeners + + onScroll = ({ scrollTop }) => { + this.setState({ scrollTop }); + } + + render() { + + const { + isFetching, + isPopulated, + error, + items, + columns, + sortKey, + sortDirection, + onTableOptionChange, + onSortPress, + deleteUnmappedFile, + ...otherProps + } = this.props; + + const { + scrollTop, + contentBody + } = this.state; + + return ( + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + isPopulated && !error && !items.length && +
+ Success! My work is done, all files on disk are matched to known tracks. +
+ } + + { + isPopulated && !error && !!items.length && contentBody && + + } + sortKey={sortKey} + sortDirection={sortDirection} + /> + } +
+
+ ); + } +} + +UnmappedFilesTable.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + onTableOptionChange: PropTypes.func.isRequired, + onSortPress: PropTypes.func.isRequired, + deleteUnmappedFile: PropTypes.func.isRequired +}; + +export default UnmappedFilesTable; diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js new file mode 100644 index 000000000..2f9dffd63 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js @@ -0,0 +1,100 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { fetchTrackFiles, deleteTrackFile, setTrackFilesSort, setTrackFilesTableOption } from 'Store/Actions/trackFileActions'; +import withCurrentPage from 'Components/withCurrentPage'; +import UnmappedFilesTable from './UnmappedFilesTable'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('trackFiles'), + createDimensionsSelector(), + ( + trackFiles, + dimensionsState + ) => { + // trackFiles could pick up mapped entries via signalR so filter again here + const { + items, + ...otherProps + } = trackFiles; + const unmappedFiles = _.filter(items, { albumId: 0 }); + return { + items: unmappedFiles, + ...otherProps, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setTrackFilesTableOption(payload)); + }, + + onSortPress(sortKey) { + dispatch(setTrackFilesSort({ sortKey })); + }, + + fetchUnmappedFiles() { + dispatch(fetchTrackFiles({ unmapped: true })); + }, + + deleteUnmappedFile(id) { + dispatch(deleteTrackFile({ id })); + } + }; +} + +class UnmappedFilesTableConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + registerPagePopulator(this.repopulate, ['trackFileUpdated']); + + this.repopulate(); + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + } + + // + // Control + + repopulate = () => { + this.props.fetchUnmappedFiles(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UnmappedFilesTableConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onTableOptionChange: PropTypes.func.isRequired, + fetchUnmappedFiles: PropTypes.func.isRequired, + deleteUnmappedFile: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, createMapDispatchToProps)(UnmappedFilesTableConnector) +); diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css new file mode 100644 index 000000000..cd8c47183 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css @@ -0,0 +1,19 @@ +.quality, +.size, +.dateAdded { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 120px; +} + +.path { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 400px; +} + +.actions { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js new file mode 100644 index 000000000..0d5701c9c --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +// import hasGrowableColumns from './hasGrowableColumns'; +import styles from './UnmappedFilesTableHeader.css'; + +function UnmappedFilesTableHeader(props) { + const { + columns, + onTableOptionChange, + ...otherProps + } = props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + + ); + } + + return ( + + {label} + + ); + }) + } + + ); +} + +UnmappedFilesTableHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default UnmappedFilesTableHeader; diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css new file mode 100644 index 000000000..f82ac9889 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css @@ -0,0 +1,22 @@ +.path { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 4 0 400px; + font-size: 13px; + font-family: $monoSpaceFontFamily; +} + +.quality, +.dateAdded, +.size { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 120px; + white-space: nowrap; +} + +.actions { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 90px; +} diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js new file mode 100644 index 000000000..6806f5468 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js @@ -0,0 +1,218 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import formatBytes from 'Utilities/Number/formatBytes'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import TrackQuality from 'Album/TrackQuality'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import FileDetailsModal from 'TrackFile/FileDetailsModal'; +import styles from './UnmappedFilesTableRow.css'; + +class UnmappedFilesTableRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isInteractiveImportModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + onDeleteFilePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.deleteUnmappedFile(this.props.id); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + // + // Render + + render() { + const { + style, + id, + path, + size, + dateAdded, + quality, + columns + } = this.props; + + const folder = path.substring(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))); + + const { + isInteractiveImportModalOpen, + isDetailsModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'size') { + return ( + + {formatBytes(size)} + + ); + } + + if (name === 'dateAdded') { + return ( + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + + + + + + ); + } + + return null; + }) + } + + + + + + + + + ); + } + +} + +UnmappedFilesTableRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + quality: PropTypes.object.isRequired, + dateAdded: PropTypes.string.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + deleteUnmappedFile: PropTypes.func.isRequired +}; + +export default UnmappedFilesTableRow; diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs index 88265d667..0a088f188 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using Nancy; using NzbDrone.Core.Datastore.Events; @@ -23,20 +22,23 @@ namespace Lidarr.Api.V1.TrackFiles { private readonly IMediaFileService _mediaFileService; private readonly IDeleteMediaFiles _mediaFileDeletionService; + private readonly IAudioTagService _audioTagService; private readonly IArtistService _artistService; private readonly IAlbumService _albumService; private readonly IUpgradableSpecification _upgradableSpecification; public TrackFileModule(IBroadcastSignalRMessage signalRBroadcaster, - IMediaFileService mediaFileService, - IDeleteMediaFiles mediaFileDeletionService, - IArtistService artistService, - IAlbumService albumService, - IUpgradableSpecification upgradableSpecification) + IMediaFileService mediaFileService, + IDeleteMediaFiles mediaFileDeletionService, + IAudioTagService audioTagService, + IArtistService artistService, + IAlbumService albumService, + IUpgradableSpecification upgradableSpecification) : base(signalRBroadcaster) { _mediaFileService = mediaFileService; _mediaFileDeletionService = mediaFileDeletionService; + _audioTagService = audioTagService; _artistService = artistService; _albumService = albumService; _upgradableSpecification = upgradableSpecification; @@ -50,11 +52,23 @@ namespace Lidarr.Api.V1.TrackFiles Delete["/bulk"] = trackFiles => DeleteTrackFiles(); } - private TrackFileResource GetTrackFile(int id) + private TrackFileResource MapToResource(TrackFile trackFile) { - var trackFile = _mediaFileService.Get(id); + if (trackFile.AlbumId > 0 && trackFile.Artist != null && trackFile.Artist.Value != null) + { + return trackFile.ToResource(trackFile.Artist.Value, _upgradableSpecification); + } + else + { + return trackFile.ToResource(); + } + } - return trackFile.ToResource(trackFile.Artist.Value, _upgradableSpecification); + private TrackFileResource GetTrackFile(int id) + { + var resource = MapToResource(_mediaFileService.Get(id)); + resource.AudioTags = _audioTagService.ReadTags(resource.Path); + return resource; } private List GetTrackFiles() @@ -62,10 +76,17 @@ namespace Lidarr.Api.V1.TrackFiles var artistIdQuery = Request.Query.ArtistId; var trackFileIdsQuery = Request.Query.TrackFileIds; var albumIdQuery = Request.Query.AlbumId; + var unmappedQuery = Request.Query.Unmapped; - if (!artistIdQuery.HasValue && !trackFileIdsQuery.HasValue && !albumIdQuery.HasValue) + if (!artistIdQuery.HasValue && !trackFileIdsQuery.HasValue && !albumIdQuery.HasValue && !unmappedQuery.HasValue) { - throw new Lidarr.Http.REST.BadRequestException("artistId, albumId, or trackFileIds must be provided"); + throw new Lidarr.Http.REST.BadRequestException("artistId, albumId, trackFileIds or unmapped must be provided"); + } + + if (unmappedQuery.HasValue && Convert.ToBoolean(unmappedQuery.Value)) + { + var files = _mediaFileService.GetUnmappedFiles(); + return files.ConvertAll(f => MapToResource(f)); } if (artistIdQuery.HasValue && !albumIdQuery.HasValue) @@ -105,7 +126,7 @@ namespace Lidarr.Api.V1.TrackFiles // trackfiles will come back with the artist already populated var trackFiles = _mediaFileService.Get(trackFileIds); - return trackFiles.ConvertAll(e => e.ToResource(e.Artist.Value, _upgradableSpecification)); + return trackFiles.ConvertAll(e => MapToResource(e)); } } @@ -144,9 +165,14 @@ namespace Lidarr.Api.V1.TrackFiles throw new NzbDroneClientException(HttpStatusCode.NotFound, "Track file not found"); } - var artist = trackFile.Artist.Value; - - _mediaFileDeletionService.DeleteTrackFile(artist, trackFile); + if (trackFile.AlbumId > 0 && trackFile.Artist != null && trackFile.Artist.Value != null) + { + _mediaFileDeletionService.DeleteTrackFile(trackFile.Artist.Value, trackFile); + } + else + { + _mediaFileDeletionService.DeleteTrackFile(trackFile, "Unmapped_Files"); + } } private Response DeleteTrackFiles() @@ -165,19 +191,12 @@ namespace Lidarr.Api.V1.TrackFiles public void Handle(TrackFileAddedEvent message) { - // don't process files that are added but not matched - if (message.TrackFile.AlbumId == 0) - { - return; - } - - BroadcastResourceChange(ModelAction.Updated, message.TrackFile.ToResource(message.TrackFile.Artist.Value, _upgradableSpecification)); + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.TrackFile)); } public void Handle(TrackFileDeletedEvent message) { - BroadcastResourceChange(ModelAction.Deleted, message.TrackFile.ToResource(message.TrackFile.Artist.Value, _upgradableSpecification)); + BroadcastResourceChange(ModelAction.Deleted, MapToResource(message.TrackFile)); } - } } diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index 28473ba51..481980f75 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -4,6 +4,8 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; using Lidarr.Http.REST; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; +using System.Linq; namespace Lidarr.Api.V1.TrackFiles { @@ -16,34 +18,43 @@ namespace Lidarr.Api.V1.TrackFiles public long Size { get; set; } public DateTime DateAdded { get; set; } public QualityModel Quality { get; set; } + public int QualityWeight { get; set; } public MediaInfoResource MediaInfo { get; set; } public bool QualityCutoffNotMet { get; set; } - + public ParsedTrackInfo AudioTags { get; set; } } public static class TrackFileResourceMapper { - private static TrackFileResource ToResource(this TrackFile model) + private static int QualityWeight(QualityModel quality) + { + if (quality == null) + { + return 0; + } + + int qualityWeight = Quality.DefaultQualityDefinitions.Single(q => q.Quality == quality.Quality).Weight; + qualityWeight += quality.Revision.Real * 10; + qualityWeight += quality.Revision.Version; + return qualityWeight; + } + + public static TrackFileResource ToResource(this TrackFile model) { if (model == null) return null; return new TrackFileResource { Id = model.Id, - - ArtistId = model.Artist.Value.Id, AlbumId = model.AlbumId, - RelativePath = model.Artist.Value.Path.GetRelativePath(model.Path), Path = model.Path, Size = model.Size, DateAdded = model.DateAdded, - // SceneName = model.SceneName, Quality = model.Quality, + QualityWeight = QualityWeight(model.Quality), MediaInfo = model.MediaInfo.ToResource() - //QualityCutoffNotMet }; - } public static TrackFileResource ToResource(this TrackFile model, NzbDrone.Core.Music.Artist artist, IUpgradableSpecification upgradableSpecification) @@ -60,8 +71,8 @@ namespace Lidarr.Api.V1.TrackFiles RelativePath = artist.Path.GetRelativePath(model.Path), Size = model.Size, DateAdded = model.DateAdded, - //SceneName = model.SceneName, Quality = model.Quality, + QualityWeight = QualityWeight(model.Quality), MediaInfo = model.MediaInfo.ToResource(), QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.QualityProfile.Value, model.Quality) }; diff --git a/src/NzbDrone.Automation.Test/MainPagesTest.cs b/src/NzbDrone.Automation.Test/MainPagesTest.cs index 005d55cec..95eae8f8d 100644 --- a/src/NzbDrone.Automation.Test/MainPagesTest.cs +++ b/src/NzbDrone.Automation.Test/MainPagesTest.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Automation.Test [Test] public void artist_page() { - page.ArtistNavIcon.Click(); + page.LibraryNavIcon.Click(); page.WaitForNoSpinner(); page.Find(By.CssSelector("div[class*='ArtistIndex']")).Should().NotBeNull(); } @@ -66,7 +66,7 @@ namespace NzbDrone.Automation.Test [Test] public void add_artist_page() { - page.ArtistNavIcon.Click(); + page.LibraryNavIcon.Click(); page.WaitForNoSpinner(); page.Find(By.LinkText("Add New")).Click(); diff --git a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs index 720eafd8c..acf8cfe73 100644 --- a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs +++ b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs @@ -47,7 +47,7 @@ namespace NzbDrone.Automation.Test.PageModel }); } - public IWebElement ArtistNavIcon => Find(By.LinkText("Artist")); + public IWebElement LibraryNavIcon => Find(By.LinkText("Library")); public IWebElement CalendarNavIcon => Find(By.LinkText("Calendar")); diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs index ceba74021..fbf128409 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Core.Test.MediaFiles { private Artist artist; private Album album; + private List releases; [SetUp] public void Setup() @@ -36,7 +37,7 @@ namespace NzbDrone.Core.Test.MediaFiles .Build(); Db.Insert(album); - var releases = Builder.CreateListOfSize(2) + releases = Builder.CreateListOfSize(2) .All() .With(a => a.Id = 0) .With(a => a.AlbumId = album.Id) @@ -44,7 +45,7 @@ namespace NzbDrone.Core.Test.MediaFiles .With(a => a.Monitored = true) .TheNext(1) .With(a => a.Monitored = false) - .Build(); + .Build().ToList(); Db.InsertMany(releases); var files = Builder.CreateListOfSize(10) @@ -53,6 +54,10 @@ namespace NzbDrone.Core.Test.MediaFiles .With(c => c.Quality =new QualityModel(Quality.MP3_192)) .TheFirst(5) .With(c => c.AlbumId = album.Id) + .TheFirst(1) + .With(c => c.Path = "/Test/Path/Artist/somefile1.flac") + .TheNext(1) + .With(c => c.Path = "/Test/Path/Artist/somefile2.flac") .BuildListOfNew(); Db.InsertMany(files); @@ -88,6 +93,55 @@ namespace NzbDrone.Core.Test.MediaFiles artistFiles.Should().OnlyContain(c => c.Artist.Value.Id == artist.Id); } + [Test] + public void get_unmapped_files() + { + VerifyData(); + var unmappedfiles = Subject.GetUnmappedFiles(); + VerifyUnmapped(unmappedfiles); + + unmappedfiles.Should().HaveCount(5); + } + + [Test] + public void get_files_by_release() + { + VerifyData(); + var firstReleaseFiles = Subject.GetFilesByRelease(releases[0].Id); + var secondReleaseFiles = Subject.GetFilesByRelease(releases[1].Id); + VerifyEagerLoaded(firstReleaseFiles); + VerifyEagerLoaded(secondReleaseFiles); + + firstReleaseFiles.Should().HaveCount(4); + secondReleaseFiles.Should().HaveCount(1); + } + + [Test] + public void get_files_by_base_path() + { + VerifyData(); + var firstReleaseFiles = Subject.GetFilesWithBasePath("/Test/Path"); + VerifyEagerLoaded(firstReleaseFiles); + + firstReleaseFiles.Should().HaveCount(2); + } + + [Test] + public void get_file_by_path() + { + VerifyData(); + var file = Subject.GetFileWithPath("/Test/Path/Artist/somefile2.flac"); + + file.Should().NotBeNull(); + file.Tracks.IsLoaded.Should().BeTrue(); + file.Tracks.Value.Should().NotBeNull(); + file.Tracks.Value.Should().NotBeEmpty(); + file.Album.IsLoaded.Should().BeTrue(); + file.Album.Value.Should().NotBeNull(); + file.Artist.IsLoaded.Should().BeTrue(); + file.Artist.Value.Should().NotBeNull(); + } + [Test] public void get_files_by_artist_should_only_return_tracks_for_monitored_releases() { @@ -142,6 +196,20 @@ namespace NzbDrone.Core.Test.MediaFiles } } + private void VerifyUnmapped(List files) + { + foreach (var file in files) + { + file.Tracks.IsLoaded.Should().BeFalse(); + file.Tracks.Value.Should().NotBeNull(); + file.Tracks.Value.Should().BeEmpty(); + file.Album.IsLoaded.Should().BeFalse(); + file.Album.Value.Should().BeNull(); + file.Artist.IsLoaded.Should().BeFalse(); + file.Artist.Value.Should().BeNull(); + } + } + [Test] public void delete_files_by_album_should_work_if_join_fails() { diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs new file mode 100644 index 000000000..9a9dfc9b6 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests +{ + [TestFixture] + public class MediaFileServiceFixture : CoreTest + { + private Album _album; + private List _trackFiles; + + [SetUp] + public void Setup() + { + _album = Builder.CreateNew() + .Build(); + + _trackFiles = Builder.CreateListOfSize(3) + .TheFirst(2) + .With(f => f.AlbumId = _album.Id) + .TheNext(1) + .With(f => f.AlbumId = 0) + .Build().ToList(); + } + + [Test] + public void should_throw_trackFileDeletedEvent_for_each_mapped_track_on_deletemany() + { + Subject.DeleteMany(_trackFiles, DeleteMediaFileReason.Manual); + + VerifyEventPublished(Times.Exactly(2)); + } + + [Test] + public void should_throw_trackFileDeletedEvent_for_mapped_track_on_delete() + { + Subject.Delete(_trackFiles[0], DeleteMediaFileReason.Manual); + + VerifyEventPublished(Times.Once()); + } + + [Test] + public void should_throw_trackFileAddedEvent_for_each_track_added_on_addmany() + { + Subject.AddMany(_trackFiles); + + VerifyEventPublished(Times.Exactly(3)); + } + + [Test] + public void should_throw_trackFileAddedEvent_for_track_added() + { + Subject.Add(_trackFiles[0]); + + VerifyEventPublished(Times.Once()); + } + + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs index e8fcd97b0..4ac1c3ad8 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs @@ -14,7 +14,7 @@ namespace NzbDrone.Core.Test.MediaFiles { public class MediaFileTableCleanupServiceFixture : CoreTest { - private readonly string DELETED_PATH = @"c:\ANY FILE 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; @@ -29,29 +29,23 @@ namespace NzbDrone.Core.Test.MediaFiles .With(s => s.Path = @"C:\Test\Music\Artist".AsOsAgnostic()) .Build(); - Mocker.GetMock() - .Setup(e => e.FileExists(It.Is(c => !c.Contains(DELETED_PATH)))) - .Returns(true); - Mocker.GetMock() - .Setup(c => c.GetTracksByArtist(It.IsAny())) - .Returns(_tracks); + .Setup(c => c.GetTracksByFileId(It.IsAny>())) + .Returns((IEnumerable ids) => _tracks.Where(y => ids.Contains(y.TrackFileId)).ToList()); } private void GivenTrackFiles(IEnumerable trackFiles) { Mocker.GetMock() - .Setup(c => c.GetFilesByArtist(It.IsAny())) + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) .Returns(trackFiles.ToList()); } private void GivenFilesAreNotAttachedToTrack() { - _tracks.ForEach(e => e.TrackFileId = 0); - Mocker.GetMock() - .Setup(c => c.GetTracksByArtist(It.IsAny())) - .Returns(_tracks); + .Setup(c => c.GetTracksByFileId(It.IsAny())) + .Returns(new List()); } private List FilesOnDisk(IEnumerable trackFiles) @@ -71,7 +65,8 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.Clean(_artist, FilesOnDisk(trackFiles)); - Mocker.GetMock().Verify(c => c.UpdateTrack(It.IsAny()), Times.Never()); + Mocker.GetMock() + .Verify(c => c.DeleteMany(It.Is>(x => x.Count == 0), DeleteMediaFileReason.MissingFromDisk), Times.Once()); } [Test] @@ -81,24 +76,31 @@ namespace NzbDrone.Core.Test.MediaFiles .All() .With(x => x.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName())) .Random(2) - .With(c => c.Path = DELETED_PATH) + .With(c => c.Path = Path.Combine(DELETED_PATH, Path.GetRandomFileName())) .Build(); GivenTrackFiles(trackFiles); - Subject.Clean(_artist, FilesOnDisk(trackFiles.Where(e => e.Path != DELETED_PATH))); + Subject.Clean(_artist, FilesOnDisk(trackFiles.Where(e => !e.Path.StartsWith(DELETED_PATH)))); - Mocker.GetMock().Verify(c => c.Delete(It.Is(e => e.Path == DELETED_PATH), DeleteMediaFileReason.MissingFromDisk), Times.Exactly(2)); + Mocker.GetMock() + .Verify(c => c.DeleteMany(It.Is>(e => e.Count == 2 && e.All(y => y.Path.StartsWith(DELETED_PATH))), DeleteMediaFileReason.MissingFromDisk), Times.Once()); } [Test] public void should_unlink_track_when_trackFile_does_not_exist() { - GivenTrackFiles(new List()); + var trackFiles = Builder.CreateListOfSize(10) + .Random(10) + .With(c => c.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName())) + .Build(); + + GivenTrackFiles(trackFiles); Subject.Clean(_artist, new List()); - Mocker.GetMock().Verify(c => c.UpdateTrack(It.Is(e => e.TrackFileId == 0)), Times.Exactly(10)); + Mocker.GetMock() + .Verify(c => c.SetFileIds(It.Is>(e => e.Count == 10 && e.All(y => y.TrackFileId == 0))), Times.Once()); } [Test] @@ -113,7 +115,7 @@ namespace NzbDrone.Core.Test.MediaFiles Subject.Clean(_artist, FilesOnDisk(trackFiles)); - Mocker.GetMock().Verify(c => c.UpdateTrack(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(c => c.SetFileIds(It.Is>(x => x.Count == 0)), Times.Once()); } } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs index 7b3e314cf..c8124a94d 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.MediaFiles public interface IDeleteMediaFiles { void DeleteTrackFile(Artist artist, TrackFile trackFile); + void DeleteTrackFile(TrackFile trackFile, string subfolder = ""); } public class MediaFileDeletionService : IDeleteMediaFiles, @@ -62,11 +63,25 @@ namespace NzbDrone.Core.MediaFiles throw new NzbDroneClientException(HttpStatusCode.Conflict, "Artist's root folder ({0}) is empty.", rootFolder); } - if (_diskProvider.FolderExists(artist.Path) && _diskProvider.FileExists(fullPath)) + if (_diskProvider.FolderExists(artist.Path)) { - _logger.Info("Deleting track file: {0}", fullPath); - var subfolder = _diskProvider.GetParentFolder(artist.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath)); + DeleteTrackFile(trackFile, subfolder); + } + else + { + // delete from db even if the artist folder is missing + _mediaFileService.Delete(trackFile, DeleteMediaFileReason.Manual); + } + } + + public void DeleteTrackFile(TrackFile trackFile, string subfolder = "") + { + var fullPath = trackFile.Path; + + if (_diskProvider.FileExists(fullPath)) + { + _logger.Info("Deleting track file: {0}", fullPath); try { diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs index b1800a6d6..da8b84ae7 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using System.Linq; +using Marr.Data; using Marr.Data.QGen; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore.Extensions; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; @@ -12,6 +14,7 @@ namespace NzbDrone.Core.MediaFiles List GetFilesByArtist(int artistId); List GetFilesByAlbum(int albumId); List GetFilesByRelease(int releaseId); + List GetUnmappedFiles(); List GetFilesWithBasePath(string path); TrackFile GetFileWithPath(string path); void DeleteFilesByAlbum(int albumId); @@ -52,6 +55,16 @@ namespace NzbDrone.Core.MediaFiles .ToList(); } + public List GetUnmappedFiles() + { + var query = "SELECT TrackFiles.* " + + "FROM TrackFiles " + + "LEFT JOIN Tracks ON Tracks.TrackFileId = TrackFiles.Id " + + "WHERE Tracks.Id IS NULL "; + + return DataMapper.Query().QueryText(query).ToList(); + } + public void DeleteFilesByAlbum(int albumId) { var ids = DataMapper.Query().Where(x => x.AlbumId == albumId); diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index bc52483b0..ea947992f 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.IO; using System.IO.Abstractions; using System.Linq; using NLog; @@ -20,9 +19,11 @@ namespace NzbDrone.Core.MediaFiles void Update(TrackFile trackFile); void Update(List trackFile); void Delete(TrackFile trackFile, DeleteMediaFileReason reason); + void DeleteMany(List trackFiles, DeleteMediaFileReason reason); List GetFilesByArtist(int artistId); List GetFilesByAlbum(int albumId); List GetFilesByRelease(int releaseId); + List GetUnmappedFiles(); List FilterUnchangedFiles(List files, Artist artist, FilterFilesType filter); TrackFile Get(int id); List Get(IEnumerable ids); @@ -81,6 +82,17 @@ namespace NzbDrone.Core.MediaFiles } } + public void DeleteMany(List trackFiles, DeleteMediaFileReason reason) + { + _mediaFileRepository.DeleteMany(trackFiles); + + // publish events where trackfile was mapped to a track + foreach (var trackFile in trackFiles.Where(x => x.AlbumId > 0)) + { + _eventAggregator.PublishEvent(new TrackFileDeletedEvent(trackFile, reason)); + } + } + public List FilterUnchangedFiles(List files, Artist artist, FilterFilesType filter) { _logger.Debug($"Filtering {files.Count} files for unchanged files"); @@ -166,6 +178,11 @@ namespace NzbDrone.Core.MediaFiles return _mediaFileRepository.GetFilesByRelease(releaseId); } + public List GetUnmappedFiles() + { + return _mediaFileRepository.GetUnmappedFiles(); + } + public void UpdateMediaInfo(List trackFiles) { _mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo); diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs index 08bfc038d..5fb72354b 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs @@ -1,6 +1,5 @@ -using System; using System.Collections.Generic; -using System.IO; +using System.Linq; using NLog; using NzbDrone.Common; using NzbDrone.Common.Extensions; @@ -30,42 +29,20 @@ namespace NzbDrone.Core.MediaFiles public void Clean(Artist artist, List filesOnDisk) { - var artistFiles = _mediaFileService.GetFilesByArtist(artist.Id); - var tracks = _trackService.GetTracksByArtist(artist.Id); + var dbFiles = _mediaFileService.GetFilesWithBasePath(artist.Path); + // 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(); - var filesOnDiskKeys = new HashSet(filesOnDisk, PathEqualityComparer.Instance); - - foreach (var artistFile in artistFiles) - { - var trackFile = artistFile; - var trackFilePath = trackFile.Path; + _logger.Debug("The following files no longer exist on disk, removing from db:\n{0}", + string.Join("\n", missingFiles.Select(x => x.Path))); - try - { - if (!filesOnDiskKeys.Contains(trackFilePath)) - { - _logger.Debug("File [{0}] no longer exists on disk, removing from db", trackFilePath); - _mediaFileService.Delete(artistFile, DeleteMediaFileReason.MissingFromDisk); - continue; - } - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to cleanup TrackFile in DB: {0}", trackFile.Id); - } - } + _mediaFileService.DeleteMany(missingFiles, DeleteMediaFileReason.MissingFromDisk); - foreach (var t in tracks) - { - var track = t; - - if (track.TrackFileId > 0 && artistFiles.None(f => f.Id == track.TrackFileId)) - { - track.TrackFileId = 0; - _trackService.UpdateTrack(track); - } - } + // get any tracks matched to these trackfiles and unlink them + var orphanedTracks = _trackService.GetTracksByFileId(missingFiles.Select(x => x.Id)); + orphanedTracks.ForEach(x => x.TrackFileId = 0); + _trackService.SetFileIds(orphanedTracks); } } } diff --git a/src/NzbDrone.Core/Music/TrackRepository.cs b/src/NzbDrone.Core/Music/TrackRepository.cs index 508925c28..ee8aa1e6d 100644 --- a/src/NzbDrone.Core/Music/TrackRepository.cs +++ b/src/NzbDrone.Core/Music/TrackRepository.cs @@ -13,6 +13,7 @@ namespace NzbDrone.Core.Music List GetTracksByReleases(List albumReleaseId); List GetTracksForRefresh(int albumReleaseId, IEnumerable foreignTrackIds); List GetTracksByFileId(int fileId); + List GetTracksByFileId(IEnumerable ids); List TracksWithFiles(int artistId); List TracksWithoutFiles(int albumId); void SetFileId(List tracks); @@ -85,6 +86,11 @@ namespace NzbDrone.Core.Music return Query.Where(e => e.TrackFileId == fileId).ToList(); } + public List GetTracksByFileId(IEnumerable ids) + { + return Query.Where($"[TrackFileId] IN ({string.Join(", ", ids)})").ToList(); + } + public List TracksWithFiles(int artistId) { string query = string.Format("SELECT Tracks.* " + diff --git a/src/NzbDrone.Core/Music/TrackService.cs b/src/NzbDrone.Core/Music/TrackService.cs index 0aaaeb77e..005fdf67b 100644 --- a/src/NzbDrone.Core/Music/TrackService.cs +++ b/src/NzbDrone.Core/Music/TrackService.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.Music List TracksWithFiles(int artistId); List TracksWithoutFiles(int albumId); List GetTracksByFileId(int trackFileId); + List GetTracksByFileId(IEnumerable trackFileIds); void UpdateTrack(Track track); void InsertMany(List tracks); void UpdateMany(List tracks); @@ -93,6 +94,11 @@ namespace NzbDrone.Core.Music return _trackRepository.GetTracksByFileId(trackFileId); } + public List GetTracksByFileId(IEnumerable trackFileIds) + { + return _trackRepository.GetTracksByFileId(trackFileIds); + } + public void UpdateTrack(Track track) { _trackRepository.Update(track); diff --git a/test.sh b/test.sh index 062b43992..fa8bc606f 100755 --- a/test.sh +++ b/test.sh @@ -70,7 +70,7 @@ if [ "$COVERAGE" = "Coverage" ]; then dotnet tool install coverlet.console --tool-path="$TEST_DIR/coverlet/" mkdir $COVERAGE_RESULT_DIRECTORY OPEN_COVER="$TEST_DIR/coverlet/coverlet" - $OPEN_COVER "$TEST_DIR/" --verbosity "detailed" --format "cobertura" --format "opencover" --output "$COVERAGE_RESULT_DIRECTORY" --exclude "[Lidarr.*.Test]*" --exclude "[Lidarr.Test.*]*" --exclude "[Marr.Data]*" --exclude "[MonoTorrent]*" --exclude "[CurlSharp]*" --target "$NUNIT" --targetargs "$NUNIT_PARAMS --where=\"$WHERE\" $ASSEMBLIES"; + $OPEN_COVER "$TEST_DIR/" --verbosity "detailed" --format "cobertura" --format "opencover" --output "$COVERAGE_RESULT_DIRECTORY" --exclude "[Lidarr.*.Test]*" --exclude "[Lidarr.Test.*]*" --exclude "[Lidarr.Api.V1]*" --exclude "[Marr.Data]*" --exclude "[MonoTorrent]*" --exclude "[CurlSharp]*" --target "$NUNIT" --targetargs "$NUNIT_PARAMS --where=\"$WHERE\" $ASSEMBLIES"; EXIT_CODE=$? else echo "Coverage only supported on Windows and Linux"