diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index 3dbd2a77d..39dc925d3 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -7,6 +7,8 @@ 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 { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; import styles from './HistoryDetails.css'; function getDetailedList(statusMessages) { @@ -36,6 +38,19 @@ function getDetailedList(statusMessages) { ); } +function formatMissing(value) { + if (value === undefined || value === 0 || value === '0') { + return (); + } + return value; +} + +function formatChange(oldValue, newValue) { + return ( +
{formatMissing(oldValue)} {formatMissing(newValue)}
+ ); +} + function HistoryDetails(props) { const { eventType, @@ -259,6 +274,37 @@ function HistoryDetails(props) { ); } + if (eventType === 'trackFileRetagged') { + const { + diff, + tagsScrubbed + } = data; + + return ( + + + { + JSON.parse(diff).map(({ field, oldValue, newValue }) => { + return ( + + ); + }) + } + : } + /> + + ); + } + if (eventType === 'albumImportIncomplete') { const { statusMessages diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js index 0786cc821..865024491 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -23,6 +23,8 @@ function getHeaderTitle(eventType) { return 'Track File Deleted'; case 'trackFileRenamed': return 'Track File Renamed'; + case 'trackFileRetagged': + return 'Track File Tags Updated'; case 'albumImportIncomplete': return 'Album Import Incomplete'; case 'downloadImported': diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js index af4511f2e..172796cd4 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -19,6 +19,8 @@ function getIconName(eventType) { return icons.DELETE; case 'trackFileRenamed': return icons.ORGANIZE; + case 'trackFileRetagged': + return icons.RETAG; case 'albumImportIncomplete': return icons.DOWNLOADED; case 'downloadImported': @@ -53,6 +55,8 @@ function getTooltip(eventType, data) { return 'Track file deleted'; case 'trackFileRenamed': return 'Track file renamed'; + case 'trackFileRetagged': + return 'Track file tags updated'; case 'albumImportIncomplete': return 'Files downloaded but not all could be imported'; case 'downloadImported': diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js index 2bc3f4568..75cfcb7a8 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -16,6 +16,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton'; import Tooltip from 'Components/Tooltip/Tooltip'; import AlbumCover from 'Album/AlbumCover'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; @@ -82,6 +83,7 @@ class AlbumDetails extends Component { this.state = { isOrganizeModalOpen: false, + isRetagModalOpen: false, isArtistHistoryModalOpen: false, isInteractiveSearchModalOpen: false, isManageTracksOpen: false, @@ -103,6 +105,14 @@ class AlbumDetails extends Component { this.setState({ isOrganizeModalOpen: false }); } + onRetagPress = () => { + this.setState({ isRetagModalOpen: true }); + } + + onRetagModalClose = () => { + this.setState({ isRetagModalOpen: false }); + } + onEditAlbumPress = () => { this.setState({ isEditAlbumModalOpen: true }); } @@ -193,6 +203,7 @@ class AlbumDetails extends Component { const { isOrganizeModalOpen, + isRetagModalOpen, isArtistHistoryModalOpen, isInteractiveSearchModalOpen, isEditAlbumModalOpen, @@ -235,6 +246,12 @@ class AlbumDetails extends Component { onPress={this.onOrganizePress} /> + + + + { + this.setState({ isRetagModalOpen: true }); + } + + onRetagModalClose = () => { + this.setState({ isRetagModalOpen: false }); + } + onManageTracksPress = () => { this.setState({ isManageTracksOpen: true }); } @@ -207,6 +217,7 @@ class ArtistDetails extends Component { const { isOrganizeModalOpen, + isRetagModalOpen, isManageTracksOpen, isEditArtistModalOpen, isDeleteArtistModalOpen, @@ -276,6 +287,12 @@ class ArtistDetails extends Component { onPress={this.onOrganizePress} /> + + + + { + this.setState({ isRetaggingArtistModalOpen: true }); + } + + onRetagArtistModalClose = (organized) => { + this.setState({ isRetaggingArtistModalOpen: false }); + + if (organized === true) { + this.onSelectAllChange({ value: false }); + } + } + // // Render @@ -162,6 +176,7 @@ class ArtistEditor extends Component { isDeleting, deleteError, isOrganizingArtist, + isRetaggingArtist, showLanguageProfile, showMetadataProfile, onSortPress, @@ -250,10 +265,12 @@ class ArtistEditor extends Component { isDeleting={isDeleting} deleteError={deleteError} isOrganizingArtist={isOrganizingArtist} + isRetaggingArtist={isRetaggingArtist} showLanguageProfile={showLanguageProfile} showMetadataProfile={showMetadataProfile} onSaveSelected={this.onSaveSelected} onOrganizeArtistPress={this.onOrganizeArtistPress} + onRetagArtistPress={this.onRetagArtistPress} /> + + + ); } @@ -282,6 +306,7 @@ ArtistEditor.propTypes = { isDeleting: PropTypes.bool.isRequired, deleteError: PropTypes.object, isOrganizingArtist: PropTypes.bool.isRequired, + isRetaggingArtist: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired, onSortPress: PropTypes.func.isRequired, diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js index d029bbec2..b155a0647 100644 --- a/frontend/src/Artist/Editor/ArtistEditorConnector.js +++ b/frontend/src/Artist/Editor/ArtistEditorConnector.js @@ -16,9 +16,11 @@ function createMapStateToProps() { (state) => state.settings.metadataProfiles, createClientSideCollectionSelector('artist', 'artistEditor'), createCommandExecutingSelector(commandNames.RENAME_ARTIST), - (languageProfiles, metadataProfiles, artist, isOrganizingArtist) => { + createCommandExecutingSelector(commandNames.RETAG_ARTIST), + (languageProfiles, metadataProfiles, artist, isOrganizingArtist, isRetaggingArtist) => { return { isOrganizingArtist, + isRetaggingArtist, showLanguageProfile: languageProfiles.items.length > 1, showMetadataProfile: metadataProfiles.items.length > 1, ...artist diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.js b/frontend/src/Artist/Editor/ArtistEditorFooter.js index 36b39c518..bb1a5dca2 100644 --- a/frontend/src/Artist/Editor/ArtistEditorFooter.js +++ b/frontend/src/Artist/Editor/ArtistEditorFooter.js @@ -145,9 +145,11 @@ class ArtistEditorFooter extends Component { isSaving, isDeleting, isOrganizingArtist, + isRetaggingArtist, showLanguageProfile, showMetadataProfile, - onOrganizeArtistPress + onOrganizeArtistPress, + onRetagArtistPress } = this.props; const { @@ -288,19 +290,29 @@ class ArtistEditorFooter extends Component { className={styles.organizeSelectedButton} kind={kinds.WARNING} isSpinning={isOrganizingArtist} - isDisabled={!selectedCount || isOrganizingArtist} + isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist} onPress={onOrganizeArtistPress} > Rename Files + + Write Metadata Tags + + - Set Tags + Set Lidarr Tags @@ -350,10 +362,12 @@ ArtistEditorFooter.propTypes = { isDeleting: PropTypes.bool.isRequired, deleteError: PropTypes.object, isOrganizingArtist: PropTypes.bool.isRequired, + isRetaggingArtist: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired, onSaveSelected: PropTypes.func.isRequired, - onOrganizeArtistPress: PropTypes.func.isRequired + onOrganizeArtistPress: PropTypes.func.isRequired, + onRetagArtistPress: PropTypes.func.isRequired }; export default ArtistEditorFooter; diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js new file mode 100644 index 000000000..636ca6618 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RetagArtistModalContentConnector from './RetagArtistModalContentConnector'; + +function RetagArtistModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +RetagArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RetagArtistModal; diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css new file mode 100644 index 000000000..02c52edc8 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css @@ -0,0 +1,8 @@ +.retagIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js new file mode 100644 index 000000000..015112556 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +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 styles from './RetagArtistModalContent.css'; + +function RetagArtistModalContent(props) { + const { + artistNames, + onModalClose, + onRetagArtistPress + } = props; + + return ( + + + Retag Selected Artist + + + + + Tip: To preview the tags that will be written... select "Cancel" then click any artist name and use the + + + +
+ Are you sure you want to re-tag all files in the {artistNames.length} selected artist? +
+
    + { + artistNames.map((artistName) => { + return ( +
  • + {artistName} +
  • + ); + }) + } +
+
+ + + + + + +
+ ); +} + +RetagArtistModalContent.propTypes = { + artistNames: PropTypes.arrayOf(PropTypes.string).isRequired, + onModalClose: PropTypes.func.isRequired, + onRetagArtistPress: PropTypes.func.isRequired +}; + +export default RetagArtistModalContent; diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js new file mode 100644 index 000000000..1c104db00 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import RetagArtistModalContent from './RetagArtistModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { artistIds }) => artistIds, + createAllArtistSelector(), + (artistIds, allArtists) => { + const artist = _.intersectionWith(allArtists, artistIds, (s, id) => { + return s.id === id; + }); + + const sortedArtist = _.orderBy(artist, 'sortName'); + const artistNames = _.map(sortedArtist, 'artistName'); + + return { + artistNames + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class RetagArtistModalContentConnector extends Component { + + // + // Listeners + + onRetagArtistPress = () => { + this.props.executeCommand({ + name: commandNames.RETAG_ARTIST, + artistIds: this.props.artistIds + }); + + this.props.onModalClose(true); + } + + // + // Render + + render(props) { + return ( + + ); + } +} + +RetagArtistModalContentConnector.propTypes = { + artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RetagArtistModalContentConnector); diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index f9ff7103a..110f94939 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -14,6 +14,8 @@ export const MOVE_ARTIST = 'MoveArtist'; export const REFRESH_ARTIST = 'RefreshArtist'; export const RENAME_FILES = 'RenameFiles'; export const RENAME_ARTIST = 'RenameArtist'; +export const RETAG_FILES = 'RetagFiles'; +export const RETAG_ARTIST = 'RetagArtist'; export const RESET_API_KEY = 'ResetApiKey'; export const RSS_SYNC = 'RssSync'; export const SEASON_SEARCH = 'AlbumSearch'; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 1e3311aff..86ea9c58b 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -25,7 +25,9 @@ import { faArrowCircleLeft as fasArrowCircleLeft, faArrowCircleRight as fasArrowCircleRight, faArrowCircleUp as fasArrowCircleUp, + faLongArrowAltRight as fasLongArrowAltRight, faBackward as fasBackward, + faBan as fasBan, faBars as fasBars, faBolt as fasBolt, faBookmark as fasBookmark, @@ -47,6 +49,7 @@ import { faCopy as fasCopy, faDesktop as fasDesktop, faDownload as fasDownload, + faEdit as fasEdit, faEllipsisH as fasEllipsisH, faExclamationCircle as fasExclamationCircle, faExclamationTriangle as fasExclamationTriangle, @@ -111,8 +114,10 @@ export const ALTERNATE_TITLES = farClone; export const ADVANCED_SETTINGS = fasCog; export const ARROW_LEFT = fasArrowCircleLeft; export const ARROW_RIGHT = fasArrowCircleRight; +export const ARROW_RIGHT_NO_CIRCLE = fasLongArrowAltRight; export const ARROW_UP = fasArrowCircleUp; export const BACKUP = farFileArchive; +export const BAN = fasBan; export const BUG = fasBug; export const CALENDAR = fasCalendarAlt; export const CALENDAR_O = farCalendar; @@ -176,9 +181,10 @@ export const QUEUED = fasCloud; export const QUICK = fasRocket; export const REFRESH = fasSync; export const REMOVE = fasTimes; +export const REORDER = fasBars; export const RESTART = fasRedoAlt; export const RESTORE = fasHistory; -export const REORDER = fasBars; +export const RETAG = fasEdit; export const RSS = fasRss; export const SAVE = fasSave; export const SCHEDULED = farClock; diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js index 3bb724cfa..6f20a9d3c 100644 --- a/frontend/src/Organize/OrganizePreviewModalContent.js +++ b/frontend/src/Organize/OrganizePreviewModalContent.js @@ -74,7 +74,6 @@ class OrganizePreviewModalContent extends Component { isPopulated, error, items, - renameTracks, trackFormat, path, onModalClose @@ -107,13 +106,7 @@ class OrganizePreviewModalContent extends Component { { !isFetching && isPopulated && !items.length && -
- { - renameTracks ? -
Success! My work is done, no files to rename.
: -
Renaming is disabled, nothing to rename
- } -
+
Success! My work is done, no files to rename.
} { @@ -191,7 +184,6 @@ OrganizePreviewModalContent.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, path: PropTypes.string.isRequired, - renameTracks: PropTypes.bool, trackFormat: PropTypes.string, onOrganizePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js index f620f8a0e..deec48a13 100644 --- a/frontend/src/Organize/OrganizePreviewModalContentConnector.js +++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.js @@ -19,7 +19,6 @@ function createMapStateToProps() { props.isFetching = organizePreview.isFetching || naming.isFetching; props.isPopulated = organizePreview.isPopulated && naming.isPopulated; props.error = organizePreview.error || naming.error; - props.renameTracks = naming.item.renameTracks; props.trackFormat = naming.item.standardTrackFormat; props.path = artist.path; diff --git a/frontend/src/Retag/RetagPreviewModal.js b/frontend/src/Retag/RetagPreviewModal.js new file mode 100644 index 000000000..6abcfa09a --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RetagPreviewModalContentConnector from './RetagPreviewModalContentConnector'; + +function RetagPreviewModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +RetagPreviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RetagPreviewModal; diff --git a/frontend/src/Retag/RetagPreviewModalConnector.js b/frontend/src/Retag/RetagPreviewModalConnector.js new file mode 100644 index 000000000..fa2e69d20 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearRetagPreview } from 'Store/Actions/retagPreviewActions'; +import RetagPreviewModal from './RetagPreviewModal'; + +const mapDispatchToProps = { + clearRetagPreview +}; + +class RetagPreviewModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearRetagPreview(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RetagPreviewModalConnector.propTypes = { + clearRetagPreview: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(RetagPreviewModalConnector); diff --git a/frontend/src/Retag/RetagPreviewModalContent.css b/frontend/src/Retag/RetagPreviewModalContent.css new file mode 100644 index 000000000..cf20af7a2 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalContent.css @@ -0,0 +1,24 @@ +.path { + margin-left: 5px; + font-weight: bold; +} + +.trackFormat { + margin-left: 5px; + font-family: $monoSpaceFontFamily; +} + +.previews { + margin-top: 10px; +} + +.selectAllInputContainer { + margin-right: auto; + line-height: 30px; +} + +.selectAllInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Retag/RetagPreviewModalContent.js b/frontend/src/Retag/RetagPreviewModalContent.js new file mode 100644 index 000000000..5530d63fb --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalContent.js @@ -0,0 +1,186 @@ +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 { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +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 CheckInput from 'Components/Form/CheckInput'; +import RetagPreviewRow from './RetagPreviewRow'; +import styles from './RetagPreviewModalContent.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +class RetagPreviewModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onRetagPress = () => { + this.props.onRetagPress(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + path, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const selectAllValue = getValue(allSelected, allUnselected); + + return ( + + + Write Metadata Tags + + + + { + isFetching && + + } + + { + !isFetching && error && +
Error loading previews
+ } + + { + !isFetching && ((isPopulated && !items.length)) && +
Success! My work is done, no files to retag.
+ } + + { + !isFetching && isPopulated && !!items.length && +
+ +
+ All paths are relative to: + + {path} + +
+
+ MusicBrainz identifiers will also be added to the files; these are not shown below. +
+
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+ } +
+ + + { + isPopulated && !!items.length && + + } + + + + + +
+ ); + } +} + +RetagPreviewModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + path: PropTypes.string.isRequired, + onRetagPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RetagPreviewModalContent; diff --git a/frontend/src/Retag/RetagPreviewModalContentConnector.js b/frontend/src/Retag/RetagPreviewModalContentConnector.js new file mode 100644 index 000000000..0e7255eb5 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalContentConnector.js @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { fetchRetagPreview } from 'Store/Actions/retagPreviewActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import RetagPreviewModalContent from './RetagPreviewModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.retagPreview, + createArtistSelector(), + (retagPreview, artist) => { + const props = { ...retagPreview }; + props.isFetching = retagPreview.isFetching; + props.isPopulated = retagPreview.isPopulated; + props.error = retagPreview.error; + props.path = artist.path; + + return props; + } + ); +} + +const mapDispatchToProps = { + fetchRetagPreview, + executeCommand +}; + +class RetagPreviewModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + artistId, + albumId + } = this.props; + + this.props.fetchRetagPreview({ + artistId, + albumId + }); + } + + // + // Listeners + + onRetagPress = (files) => { + this.props.executeCommand({ + name: commandNames.RETAG_FILES, + artistId: this.props.artistId, + files + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RetagPreviewModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + albumId: PropTypes.number, + retagTracks: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + fetchRetagPreview: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RetagPreviewModalContentConnector); diff --git a/frontend/src/Retag/RetagPreviewRow.css b/frontend/src/Retag/RetagPreviewRow.css new file mode 100644 index 000000000..e59b03f19 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewRow.css @@ -0,0 +1,26 @@ +.row { + display: flex; + margin-bottom: 5px; + padding: 5px 0; + border-bottom: 1px solid $borderColor; + + &:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } +} + +.column { + display: flex; + flex-direction: column; +} + +.selectedContainer { + margin-right: 30px; +} + +.path { + margin-left: 10px; + font-weight: bold; +} diff --git a/frontend/src/Retag/RetagPreviewRow.js b/frontend/src/Retag/RetagPreviewRow.js new file mode 100644 index 000000000..e23fe914c --- /dev/null +++ b/frontend/src/Retag/RetagPreviewRow.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './RetagPreviewRow.css'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function formatMissing(value) { + if (value === undefined || value === 0 || value === '0') { + return (); + } + return value; +} + +function formatChange(oldValue, newValue) { + return ( +
{formatMissing(oldValue)} {formatMissing(newValue)}
+ ); +} + +class RetagPreviewRow extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: true }); + } + + // + // Listeners + + onSelectedChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + id, + path, + changes, + isSelected + } = this.props; + + return ( +
+ + +
+ + {path} + + + + { + changes.map(({ field, oldValue, newValue }) => { + return ( + + ); + }) + } + +
+
+ ); + } +} + +RetagPreviewRow.propTypes = { + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default RetagPreviewRow; diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js index 655dc71d1..f58a8ec49 100644 --- a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js +++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js @@ -8,6 +8,13 @@ import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputGroup from 'Components/Form/FormInputGroup'; +const writeAudioTagOptions = [ + { key: 'sync', value: 'All files; keep in sync with MusicBrainz' }, + { key: 'allFiles', value: 'All files; initial import only' }, + { key: 'newFiles', value: 'For new downloads only' }, + { key: 'no', value: 'Never' } +]; + function MetadataProvider(props) { const { advancedSettings, @@ -54,6 +61,35 @@ function MetadataProvider(props) { } + +
+ + Tag Audio Files with Metadata + + + + + + Scrub Existing Tags + + + + +
} diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js index e1ebe547e..fc7fd0bb4 100644 --- a/frontend/src/Settings/Metadata/MetadataSettings.js +++ b/frontend/src/Settings/Metadata/MetadataSettings.js @@ -55,11 +55,11 @@ class MetadataSettings extends Component { /> - + ); diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 8cc5646e0..4c2e5e78f 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -173,6 +173,17 @@ export const defaultState = { type: filterTypes.EQUAL } ] + }, + { + key: 'retagged', + label: 'Retagged', + filters: [ + { + key: 'eventType', + value: '9', + type: filterTypes.EQUAL + } + ] } ] diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 3a8e5ebc0..4e367fc89 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -14,6 +14,7 @@ import * as importArtist from './importArtistActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; import * as organizePreview from './organizePreviewActions'; +import * as retagPreview from './retagPreviewActions'; import * as paths from './pathActions'; import * as queue from './queueActions'; import * as releases from './releaseActions'; @@ -46,6 +47,7 @@ export default [ interactiveImportActions, oAuth, organizePreview, + retagPreview, paths, queue, releases, diff --git a/frontend/src/Store/Actions/retagPreviewActions.js b/frontend/src/Store/Actions/retagPreviewActions.js new file mode 100644 index 000000000..73632fcf8 --- /dev/null +++ b/frontend/src/Store/Actions/retagPreviewActions.js @@ -0,0 +1,51 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'retagPreview'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_RETAG_PREVIEW = 'retagPreview/fetchRetagPreview'; +export const CLEAR_RETAG_PREVIEW = 'retagPreview/clearRetagPreview'; + +// +// Action Creators + +export const fetchRetagPreview = createThunk(FETCH_RETAG_PREVIEW); +export const clearRetagPreview = createAction(CLEAR_RETAG_PREVIEW); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_RETAG_PREVIEW]: createFetchHandler('retagPreview', '/retag') + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RETAG_PREVIEW]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/src/Lidarr.Api.V1/Config/MetadataProviderConfigResource.cs b/src/Lidarr.Api.V1/Config/MetadataProviderConfigResource.cs index 827ab6905..3e356eedb 100644 --- a/src/Lidarr.Api.V1/Config/MetadataProviderConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/MetadataProviderConfigResource.cs @@ -5,9 +5,9 @@ namespace Lidarr.Api.V1.Config { public class MetadataProviderConfigResource : RestResource { - //Calendar public string MetadataSource { get; set; } - + public WriteAudioTagsType WriteAudioTags { get; set; } + public bool ScrubAudioTags { get; set; } } public static class MetadataProviderConfigResourceMapper @@ -17,7 +17,8 @@ namespace Lidarr.Api.V1.Config return new MetadataProviderConfigResource { MetadataSource = model.MetadataSource, - + WriteAudioTags = model.WriteAudioTags, + ScrubAudioTags = model.ScrubAudioTags, }; } } diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj index 9dd489523..1bc41b04f 100644 --- a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj +++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj @@ -160,6 +160,8 @@ + + diff --git a/src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs b/src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs new file mode 100644 index 000000000..1248d4215 --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using Lidarr.Http; +using Lidarr.Http.REST; +using NzbDrone.Core.MediaFiles; + +namespace Lidarr.Api.V1.Tracks +{ + public class RetagTrackModule : LidarrRestModule + { + private readonly IAudioTagService _audioTagService; + + public RetagTrackModule(IAudioTagService audioTagService) + : base("retag") + { + _audioTagService = audioTagService; + + GetResourceAll = GetTracks; + } + + private List GetTracks() + { + if (Request.Query.albumId.HasValue) + { + var albumId = (int)Request.Query.albumId; + return _audioTagService.GetRetagPreviewsByAlbum(albumId).Where(x => x.Changes.Any()).ToResource(); + } + else if (Request.Query.ArtistId.HasValue) + { + var artistId = (int)Request.Query.ArtistId; + return _audioTagService.GetRetagPreviewsByArtist(artistId).Where(x => x.Changes.Any()).ToResource(); + } + else + { + throw new BadRequestException("One of artistId or albumId must be specified"); + } + + } + } +} diff --git a/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs b/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs new file mode 100644 index 000000000..62c5c9085 --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Tracks +{ + public class TagDifference + { + public string Field { get; set; } + public string OldValue { get; set; } + public string NewValue { get; set; } + } + + public class RetagTrackResource : RestResource + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public List TrackNumbers { get; set; } + public int TrackFileId { get; set; } + public string RelativePath { get; set; } + public List Changes { get; set; } + } + + public static class RetagTrackResourceMapper + { + public static RetagTrackResource ToResource(this NzbDrone.Core.MediaFiles.RetagTrackFilePreview model) + { + if (model == null) + { + return null; + } + + return new RetagTrackResource + { + ArtistId = model.ArtistId, + AlbumId = model.AlbumId, + TrackNumbers = model.TrackNumbers.ToList(), + TrackFileId = model.TrackFileId, + RelativePath = model.RelativePath, + Changes = model.Changes.Select(x => new TagDifference { + Field = x.Key, + OldValue = x.Value.Item1, + NewValue = x.Value.Item2 + }).ToList() + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/NzbDrone.Core.Test/Files/Media/H264_sample.mp4 b/src/NzbDrone.Core.Test/Files/Media/H264_sample.mp4 deleted file mode 100644 index 35bc6b353..000000000 Binary files a/src/NzbDrone.Core.Test/Files/Media/H264_sample.mp4 and /dev/null differ diff --git a/src/NzbDrone.Core.Test/Files/Media/LICENSE b/src/NzbDrone.Core.Test/Files/Media/LICENSE new file mode 100644 index 000000000..3f10abe21 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Media/LICENSE @@ -0,0 +1,11 @@ +nin.* in this directory are re-encodes of nin.mp3 + +title : 999,999 +artist : Nine Inch Nails +track : 1 +album : The Slip +copyright : Attribution-Noncommercial-Share Alike 3.0 United States: http://creativecommons.org/licenses/by-nc-sa/3.0/us/ +comment : URL: http://freemusicarchive.org/music/Nine_Inch_Nails/The_Slip/999999 + : Comments: http://freemusicarchive.org/ + : Curator: + : Copyright: Attribution-Noncommercial-Share Alike 3.0 United States: http://creativecommons.org/licenses/by-nc-sa/3.0/us/ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.ape b/src/NzbDrone.Core.Test/Files/Media/nin.ape new file mode 100644 index 000000000..42a45db51 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.ape differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.m4a b/src/NzbDrone.Core.Test/Files/Media/nin.m4a new file mode 100644 index 000000000..e447782a1 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.m4a differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.mp2 b/src/NzbDrone.Core.Test/Files/Media/nin.mp2 new file mode 100644 index 000000000..5faec56b3 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.mp2 differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.opus b/src/NzbDrone.Core.Test/Files/Media/nin.opus new file mode 100644 index 000000000..280670f28 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.opus differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.wma b/src/NzbDrone.Core.Test/Files/Media/nin.wma new file mode 100644 index 000000000..cbd9b1ee7 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.wma differ diff --git a/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs new file mode 100644 index 000000000..f9dc3c9f3 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs @@ -0,0 +1,212 @@ +using System.IO; +using NUnit.Framework; +using FluentAssertions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Configuration; +using FizzWare.NBuilder; +using System; +using System.Collections; +using System.Linq; +using NzbDrone.Common.Extensions; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.MediaFiles.AudioTagServiceFixture +{ + [TestFixture] + public class AudioTagServiceFixture : CoreTest + { + public static class TestCaseFactory + { + private static readonly string[] MediaFiles = new [] { "nin.mp2", "nin.mp3", "nin.flac", "nin.m4a", "nin.wma", "nin.ape", "nin.opus" }; + + private static readonly string[] SkipProperties = new [] { "IsValid", "Duration", "Quality", "MediaInfo" }; + private static readonly Dictionary SkipPropertiesByFile = new Dictionary { + { "nin.mp2", new [] {"OriginalReleaseDate"} } + }; + + public static IEnumerable TestCases + { + get + { + foreach (var file in MediaFiles) + { + var toSkip = SkipProperties; + if (SkipPropertiesByFile.ContainsKey(file)) + { + toSkip = toSkip.Union(SkipPropertiesByFile[file]).ToArray(); + } + yield return new TestCaseData(file, toSkip).SetName($"{{m}}_{file.Replace("nin.", "")}"); + } + } + } + } + + private readonly string testdir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media"); + private string copiedFile; + private AudioTag testTags; + + [SetUp] + public void Setup() + { + Mocker.GetMock() + .Setup(x => x.WriteAudioTags) + .Returns(WriteAudioTagsType.Sync); + + // have to manually set the arrays of string parameters and integers to values > 1 + testTags = Builder.CreateNew() + .With(x => x.Track = 2) + .With(x => x.TrackCount = 33) + .With(x => x.Disc = 44) + .With(x => x.DiscCount = 55) + .With(x => x.Date = new DateTime(2019, 3, 1)) + .With(x => x.Year = 2019) + .With(x => x.OriginalReleaseDate = new DateTime(2009, 4, 1)) + .With(x => x.OriginalYear = 2009) + .With(x => x.Performers = new [] { "Performer1" }) + .With(x => x.AlbumArtists = new [] { "방탄소년단" }) + .Build(); + } + + [TearDown] + public void Cleanup() + { + if (File.Exists(copiedFile)) + { + File.Delete(copiedFile); + } + } + + private void GivenFileCopy(string filename) + { + var original = Path.Combine(testdir, filename); + var tempname = $"temp_{Path.GetRandomFileName()}{Path.GetExtension(filename)}"; + copiedFile = Path.Combine(testdir, tempname); + + File.Copy(original, copiedFile); + } + + private void VerifyDifferent(AudioTag a, AudioTag b, string[] skipProperties) + { + foreach (var property in typeof(AudioTag).GetProperties()) + { + if (skipProperties.Contains(property.Name)) + { + continue; + } + + if (property.CanRead) + { + if (property.PropertyType.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEquatable<>)) || + Nullable.GetUnderlyingType(property.PropertyType) != null) + { + var val1 = property.GetValue(a, null); + var val2 = property.GetValue(b, null); + val1.Should().NotBe(val2, $"{property.Name} should not be equal. Found {val1.NullSafe()} for both tags"); + } + else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + var val1 = (IEnumerable) property.GetValue(a, null); + var val2 = (IEnumerable) property.GetValue(b, null); + + if (val1 != null && val2 != null) + { + val1.Should().NotBeEquivalentTo(val2, $"{property.Name} should not be equal"); + } + } + } + } + } + + private void VerifySame(AudioTag a, AudioTag b, string[] skipProperties) + { + foreach (var property in typeof(AudioTag).GetProperties()) + { + if (skipProperties.Contains(property.Name)) + { + continue; + } + + if (property.CanRead) + { + if (property.PropertyType.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEquatable<>)) || + Nullable.GetUnderlyingType(property.PropertyType) != null) + { + var val1 = property.GetValue(a, null); + var val2 = property.GetValue(b, null); + val1.Should().Be(val2, $"{property.Name} should be equal"); + } + else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + var val1 = (IEnumerable) property.GetValue(a, null); + var val2 = (IEnumerable) property.GetValue(b, null); + val1.Should().BeEquivalentTo(val2, $"{property.Name} should be equal"); + } + } + } + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_read_duration(string filename, string[] ignored) + { + var path = Path.Combine(testdir, filename); + + var tags = Subject.ReadTags(path); + + tags.Duration.Should().BeCloseTo(new TimeSpan(0, 0, 1, 25, 130), 100); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_read_write_tags(string filename, string[] skipProperties) + { + GivenFileCopy(filename); + var path = copiedFile; + + var initialtags = Subject.ReadAudioTag(path); + + VerifyDifferent(initialtags, testTags, skipProperties); + + testTags.Write(path); + + var writtentags = Subject.ReadAudioTag(path); + + VerifySame(writtentags, testTags, skipProperties); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_remove_mb_tags(string filename, string[] skipProperties) + { + GivenFileCopy(filename); + var path = copiedFile; + + var track = new TrackFile { + Artist = new Artist { + Path = Path.GetDirectoryName(path) + }, + RelativePath = Path.GetFileName(path) + }; + + testTags.Write(path); + + var withmb = Subject.ReadAudioTag(path); + + VerifySame(withmb, testTags, skipProperties); + + Subject.RemoveMusicBrainzTags(track); + + var tag = Subject.ReadAudioTag(path); + + tag.MusicBrainzReleaseCountry.Should().BeNull(); + tag.MusicBrainzReleaseStatus.Should().BeNull(); + tag.MusicBrainzReleaseType.Should().BeNull(); + tag.MusicBrainzReleaseId.Should().BeNull(); + tag.MusicBrainzArtistId.Should().BeNull(); + tag.MusicBrainzReleaseArtistId.Should().BeNull(); + tag.MusicBrainzReleaseGroupId.Should().BeNull(); + tag.MusicBrainzTrackId.Should().BeNull(); + tag.MusicBrainzAlbumComment.Should().BeNull(); + tag.MusicBrainzReleaseTrackId.Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs index b734bb387..99f2d0cab 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs @@ -146,8 +146,8 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification var localAlbumRelease = new LocalAlbumRelease(localTracks); Mocker.GetMock() - .Setup(x => x.GetReleasesByForeignReleaseId(new List{ "xxx" })) - .Returns(new List { release }); + .Setup(x => x.GetReleaseByForeignReleaseId("xxx")) + .Returns(release); Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null).ShouldBeEquivalentTo(new List { release }); } diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs index 93e8071f8..c39a90caf 100644 --- a/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs @@ -11,6 +11,8 @@ using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Music; using NzbDrone.Core.Music.Commands; using NzbDrone.Test.Common; +using FluentAssertions; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.Test.MusicTests { @@ -54,13 +56,13 @@ namespace NzbDrone.Core.Test.MusicTests .Returns(_artist); Mocker.GetMock() - .Setup(s => s.GetReleasesByAlbum(album1.Id)) + .Setup(s => s.GetReleasesForRefresh(album1.Id, It.IsAny>())) .Returns(new List { release }); - Mocker.GetMock() - .Setup(s => s.GetReleasesByForeignReleaseId(It.IsAny>())) - .Returns(new List { release }); - + Mocker.GetMock() + .Setup(s => s.FindById(It.IsAny>())) + .Returns(new List()); + Mocker.GetMock() .Setup(s => s.GetAlbumInfo(It.IsAny())) .Callback(() => { throw new AlbumNotFoundException(album1.ForeignAlbumId); }); @@ -80,7 +82,7 @@ namespace NzbDrone.Core.Test.MusicTests [Test] public void should_log_error_if_musicbrainz_id_not_found() { - Subject.RefreshAlbumInfo(_albums, false); + Subject.RefreshAlbumInfo(_albums, false, false); Mocker.GetMock() .Verify(v => v.UpdateMany(It.IsAny>()), Times.Never()); @@ -97,12 +99,56 @@ namespace NzbDrone.Core.Test.MusicTests GivenNewAlbumInfo(newAlbumInfo); - Subject.RefreshAlbumInfo(_albums, false); + Subject.RefreshAlbumInfo(_albums, false, false); Mocker.GetMock() .Verify(v => v.UpdateMany(It.Is>(s => s.First().ForeignAlbumId == newAlbumInfo.ForeignAlbumId))); ExceptionVerification.ExpectedWarns(1); } + + [Test] + public void two_equivalent_releases_should_be_equal() + { + var release = Builder.CreateNew().Build(); + var release2 = Builder.CreateNew().Build(); + + ReferenceEquals(release, release2).Should().BeFalse(); + release.Equals(release2).Should().BeTrue(); + + release.Label?.ToJson().Should().Be(release2.Label?.ToJson()); + release.Country?.ToJson().Should().Be(release2.Country?.ToJson()); + release.Media?.ToJson().Should().Be(release2.Media?.ToJson()); + + } + + [Test] + public void two_equivalent_tracks_should_be_equal() + { + var track = Builder.CreateNew().Build(); + var track2 = Builder.CreateNew().Build(); + + ReferenceEquals(track, track2).Should().BeFalse(); + track.Equals(track2).Should().BeTrue(); + } + + [Test] + public void two_equivalent_metadata_should_be_equal() + { + var meta = Builder.CreateNew().Build(); + var meta2 = Builder.CreateNew().Build(); + + ReferenceEquals(meta, meta2).Should().BeFalse(); + meta.Equals(meta2).Should().BeTrue(); + } + + [Test] + public void should_remove_items_from_list() + { + var releases = Builder.CreateListOfSize(2).Build(); + var release = releases[0]; + releases.Remove(release); + releases.Should().HaveCount(1); + } } } diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs index 28b051916..f97d49a94 100644 --- a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs @@ -46,13 +46,9 @@ namespace NzbDrone.Core.Test.MusicTests .Returns(_artist); Mocker.GetMock() - .Setup(s => s.GetAlbumsByArtist(It.IsAny())) + .Setup(s => s.GetAlbumsForRefresh(It.IsAny(), It.IsAny>())) .Returns(new List()); - Mocker.GetMock() - .Setup(s => s.FindById(It.IsAny>())) - .Returns(new List()); - Mocker.GetMock() .Setup(s => s.GetArtistInfo(It.IsAny(), It.IsAny())) .Callback(() => { throw new ArtistNotFoundException(_artist.ForeignArtistId); }); diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index b93803de2..d4306cc23 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -87,6 +87,10 @@ ..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll + + ..\packages\TagLibSharp.2.2.0-beta\lib\netstandard2.0\taglib-sharp.dll + True + @@ -281,6 +285,7 @@ + @@ -502,7 +507,7 @@ Always - + Always @@ -511,6 +516,18 @@ Always + + Always + + + Always + + + Always + + + Always + Always diff --git a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs index f4e4db8f1..33de373d5 100644 --- a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs @@ -183,12 +183,14 @@ namespace NzbDrone.Core.Test.ParserTests } [TestCase("", "MPEG-4 Audio (mp4a)", 320)] + [TestCase("", "MPEG-4 Audio (drms)", 320)] public void should_parse_aac_320_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_320); } - + [TestCase("", "MPEG-4 Audio (mp4a)", 321)] + [TestCase("", "MPEG-4 Audio (drms)", 321)] public void should_parse_aac_vbr_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_VBR); @@ -196,12 +198,14 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Kirlian Camera - The Ice Curtain - Album 1998 - Ogg-Vorbis Q10", null, 0)] [TestCase("", "Vorbis Version 0 Audio", 500)] + [TestCase("", "Opus Version 1 Audio", 501)] public void should_parse_vorbis_q10_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q10); } [TestCase("", "Vorbis Version 0 Audio", 320)] + [TestCase("", "Opus Version 1 Audio", 321)] public void should_parse_vorbis_q9_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q9); @@ -209,6 +213,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Various Artists - No New York [1978/Ogg/q8]", null, 0)] [TestCase("", "Vorbis Version 0 Audio", 256)] + [TestCase("", "Opus Version 1 Audio", 257)] public void should_parse_vorbis_q8_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q8); @@ -216,18 +221,21 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Masters_At_Work-Nuyorican_Soul-.Talkin_Loud.-1997-OGG.Q7", null, 0)] [TestCase("", "Vorbis Version 0 Audio", 224)] + [TestCase("", "Opus Version 1 Audio", 225)] public void should_parse_vorbis_q7_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q7); } [TestCase("", "Vorbis Version 0 Audio", 192)] + [TestCase("", "Opus Version 1 Audio", 193)] public void should_parse_vorbis_q6_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q6); } [TestCase("", "Vorbis Version 0 Audio", 160)] + [TestCase("", "Opus Version 1 Audio", 161)] public void should_parse_vorbis_q5_quality(string title, string desc, int bitrate) { ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q5); diff --git a/src/NzbDrone.Core.Test/packages.config b/src/NzbDrone.Core.Test/packages.config index 1e5c946ce..e86c65bc1 100644 --- a/src/NzbDrone.Core.Test/packages.config +++ b/src/NzbDrone.Core.Test/packages.config @@ -1,4 +1,4 @@ - + @@ -14,4 +14,5 @@ - \ No newline at end of file + + diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index be5765be4..bd907f7b7 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -265,6 +265,20 @@ namespace NzbDrone.Core.Configuration set { SetValue("MetadataSource", value); } } + public WriteAudioTagsType WriteAudioTags + { + get { return GetValueEnum("WriteAudioTags", WriteAudioTagsType.No); } + + set { SetValue("WriteAudioTags", value); } + } + + public bool ScrubAudioTags + { + get { return GetValueBoolean("ScrubAudioTags", false); } + + set { SetValue("ScrubAudioTags", value); } + } + public int FirstDayOfWeek { get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 3ba61b2ad..5d32059ea 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -69,9 +69,10 @@ namespace NzbDrone.Core.Configuration string PlexClientIdentifier { get; } - //MetadataSource + //Metadata string MetadataSource { get; set; } - + WriteAudioTagsType WriteAudioTags { get; set; } + bool ScrubAudioTags { get; set; } //Forms Auth string RijndaelPassphrase { get; } diff --git a/src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs b/src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs new file mode 100644 index 000000000..0cdbbf256 --- /dev/null +++ b/src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Configuration +{ + public enum WriteAudioTagsType + { + No, + NewFiles, + AllFiles, + Sync + } +} diff --git a/src/NzbDrone.Core/History/History.cs b/src/NzbDrone.Core/History/History.cs index 1ac17e01a..25233fb0a 100644 --- a/src/NzbDrone.Core/History/History.cs +++ b/src/NzbDrone.Core/History/History.cs @@ -43,6 +43,7 @@ namespace NzbDrone.Core.History TrackFileDeleted = 5, TrackFileRenamed = 6, AlbumImportIncomplete = 7, - DownloadImported = 8 + DownloadImported = 8, + TrackFileRetagged = 9 } } diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 9029c8b17..9a82d3c5b 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -38,6 +38,7 @@ namespace NzbDrone.Core.History IHandle, IHandle, IHandle, + IHandle, IHandle { private readonly IHistoryRepository _historyRepository; @@ -345,6 +346,35 @@ namespace NzbDrone.Core.History } } + public void Handle(TrackFileRetaggedEvent message) + { + var path = Path.Combine(message.Artist.Path, message.TrackFile.RelativePath); + var relativePath = message.TrackFile.RelativePath; + + foreach (var track in message.TrackFile.Tracks.Value) + { + var history = new History + { + EventType = HistoryEventType.TrackFileRetagged, + Date = DateTime.UtcNow, + Quality = message.TrackFile.Quality, + SourceTitle = path, + ArtistId = message.TrackFile.Artist.Value.Id, + AlbumId = message.TrackFile.AlbumId, + TrackId = track.Id, + }; + + history.Data.Add("TagsScrubbed", message.Scrubbed.ToString()); + history.Data.Add("Diff", message.Diff.Select(x => new { + Field = x.Key, + OldValue = x.Value.Item1, + NewValue = x.Value.Item2 + }).ToJson()); + + _historyRepository.Insert(history); + } + } + public void Handle(ArtistDeletedEvent message) { _historyRepository.DeleteForArtist(message.Artist.Id); diff --git a/src/NzbDrone.Core/MediaFiles/AudioTag.cs b/src/NzbDrone.Core/MediaFiles/AudioTag.cs new file mode 100644 index 000000000..6ca3ba5b1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/AudioTag.cs @@ -0,0 +1,590 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Languages; +using System.Linq; +using System.Collections.Generic; +using System; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Instrumentation; +using NLog; +using TagLib; +using TagLib.Id3v2; +using NLog.Fluent; +using NzbDrone.Common.Instrumentation.Extensions; +using System.Globalization; + +namespace NzbDrone.Core.MediaFiles +{ + public class AudioTag + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AudioTag)); + + public string Title { get; set; } + public string[] Performers { get; set; } + public string[] AlbumArtists { get; set; } + public uint Track { get; set; } + public uint TrackCount { get; set; } + public string Album { get; set; } + public uint Disc { get; set; } + public uint DiscCount { get; set; } + public string Media { get; set; } + public DateTime? Date { get; set; } + public DateTime? OriginalReleaseDate { get; set; } + public uint Year { get; set; } + public uint OriginalYear { get; set; } + public string Publisher { get; set; } + public TimeSpan Duration { get; set; } + public string MusicBrainzReleaseCountry { get; set; } + public string MusicBrainzReleaseStatus { get; set; } + public string MusicBrainzReleaseType { get; set; } + public string MusicBrainzReleaseId { get; set; } + public string MusicBrainzArtistId { get; set; } + public string MusicBrainzReleaseArtistId { get; set; } + public string MusicBrainzReleaseGroupId { get; set; } + public string MusicBrainzTrackId { get; set; } + public string MusicBrainzReleaseTrackId { get; set; } + public string MusicBrainzAlbumComment { get; set; } + + public bool IsValid { get; private set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + + public AudioTag() + { + IsValid = true; + } + + public AudioTag(string path) + { + Read(path); + } + + public void Read(string path) + { + Logger.Debug($"Starting tag read for {path}"); + + IsValid = false; + TagLib.File file = null; + try + { + file = TagLib.File.Create(path); + var tag = file.Tag; + + Title = tag.Title ?? tag.TitleSort; + Performers = tag.Performers ?? tag.PerformersSort; + AlbumArtists = tag.AlbumArtists ?? tag.AlbumArtistsSort; + Track = tag.Track; + TrackCount = tag.TrackCount; + Album = tag.Album ?? tag.AlbumSort; + Disc = tag.Disc; + DiscCount = tag.DiscCount; + Year = tag.Year; + Publisher = tag.Publisher; + Duration = file.Properties.Duration; + MusicBrainzReleaseCountry = tag.MusicBrainzReleaseCountry; + MusicBrainzReleaseStatus = tag.MusicBrainzReleaseStatus; + MusicBrainzReleaseType = tag.MusicBrainzReleaseType; + MusicBrainzReleaseId = tag.MusicBrainzReleaseId; + MusicBrainzArtistId = tag.MusicBrainzArtistId; + MusicBrainzReleaseArtistId = tag.MusicBrainzReleaseArtistId; + MusicBrainzReleaseGroupId = tag.MusicBrainzReleaseGroupId; + MusicBrainzTrackId = tag.MusicBrainzTrackId; + + DateTime tempDate; + + // Do the ones that aren't handled by the generic taglib implementation + if (file.TagTypesOnDisk.HasFlag(TagTypes.Id3v2)) + { + var id3tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2); + Media = id3tag.GetTextAsString("TMED"); + Date = ReadId3Date(id3tag, "TDRC"); + OriginalReleaseDate = ReadId3Date(id3tag, "TDOR"); + MusicBrainzAlbumComment = UserTextInformationFrame.Get(id3tag, "MusicBrainz Album Comment", false)?.Text.ExclusiveOrDefault(); + MusicBrainzReleaseTrackId = UserTextInformationFrame.Get(id3tag, "MusicBrainz Release Track Id", false)?.Text.ExclusiveOrDefault(); + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Xiph)) + { + // while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is + // https://picard.musicbrainz.org/docs/mappings/ + var flactag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph); + Media = flactag.GetField("MEDIA").ExclusiveOrDefault(); + Date = DateTime.TryParse(flactag.GetField("DATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(flactag.GetField("ORIGINALDATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?); + Publisher = flactag.GetField("LABEL").ExclusiveOrDefault(); + MusicBrainzAlbumComment = flactag.GetField("MUSICBRAINZ_ALBUMCOMMENT").ExclusiveOrDefault(); + MusicBrainzReleaseTrackId = flactag.GetField("MUSICBRAINZ_RELEASETRACKID").ExclusiveOrDefault(); + + // If we haven't managed to read status/type, try the alternate mapping + if (MusicBrainzReleaseStatus.IsNullOrWhiteSpace()) + { + MusicBrainzReleaseStatus = flactag.GetField("RELEASESTATUS").ExclusiveOrDefault(); + } + + if (MusicBrainzReleaseType.IsNullOrWhiteSpace()) + { + MusicBrainzReleaseType = flactag.GetField("RELEASETYPE").ExclusiveOrDefault(); + } + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Ape)) + { + var apetag = (TagLib.Ape.Tag) file.GetTag(TagTypes.Ape); + Media = apetag.GetItem("Media")?.ToString(); + Date = DateTime.TryParse(apetag.GetItem("Year")?.ToString(), out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(apetag.GetItem("Original Date")?.ToString(), out tempDate) ? tempDate : default(DateTime?); + Publisher = apetag.GetItem("Label")?.ToString(); + MusicBrainzAlbumComment = apetag.GetItem("MUSICBRAINZ_ALBUMCOMMENT")?.ToString(); + MusicBrainzReleaseTrackId = apetag.GetItem("MUSICBRAINZ_RELEASETRACKID")?.ToString(); + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Asf)) + { + var asftag = (TagLib.Asf.Tag) file.GetTag(TagTypes.Asf); + Media = asftag.GetDescriptorString("WM/Media"); + Date = DateTime.TryParse(asftag.GetDescriptorString("WM/Year"), out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(asftag.GetDescriptorString("WM/OriginalReleaseTime"), out tempDate) ? tempDate : default(DateTime?); + Publisher = asftag.GetDescriptorString("WM/Publisher"); + MusicBrainzAlbumComment = asftag.GetDescriptorString("MusicBrainz/Album Comment"); + MusicBrainzReleaseTrackId = asftag.GetDescriptorString("MusicBrainz/Release Track Id"); + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Apple)) + { + var appletag = (TagLib.Mpeg4.AppleTag) file.GetTag(TagTypes.Apple); + Media = appletag.GetDashBox("com.apple.iTunes", "MEDIA"); + Date = DateTime.TryParse(appletag.DataBoxes(FixAppleId("day")).First().Text, out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(appletag.GetDashBox("com.apple.iTunes", "Original Date"), out tempDate) ? tempDate : default(DateTime?); + MusicBrainzAlbumComment = appletag.GetDashBox("com.apple.iTunes", "MusicBrainz Album Comment"); + MusicBrainzReleaseTrackId = appletag.GetDashBox("com.apple.iTunes", "MusicBrainz Release Track Id"); + } + + OriginalYear = OriginalReleaseDate.HasValue ? (uint)OriginalReleaseDate?.Year : 0; + + foreach (ICodec codec in file.Properties.Codecs) + { + IAudioCodec acodec = codec as IAudioCodec; + + if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None) + { + int bitrate = acodec.AudioBitrate; + if (bitrate == 0) + { + // Taglib can't read bitrate for Opus. + // Taglib File.Length is unreliable so use System.IO + var size = new System.IO.FileInfo(path).Length; + var duration = file.Properties.Duration.TotalSeconds; + bitrate = (int) ((size * 8L) / (duration * 1024)); + Logger.Trace($"Estimating bitrate. Size: {size} Duration: {duration} Bitrate: {bitrate}"); + } + + Logger.Debug("Audio Properties: " + acodec.Description + ", Bitrate: " + bitrate + ", Sample Size: " + + file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels); + + Quality = QualityParser.ParseQuality(file.Name, acodec.Description, bitrate, file.Properties.BitsPerSample); + Logger.Debug($"Quality parsed: {Quality}, Source: {Quality.QualityDetectionSource}"); + + MediaInfo = new MediaInfoModel { + AudioFormat = acodec.Description, + AudioBitrate = bitrate, + AudioChannels = acodec.AudioChannels, + AudioBits = file.Properties.BitsPerSample, + AudioSampleRate = acodec.AudioSampleRate + }; + } + } + + IsValid = true; + } + catch (CorruptFileException ex) + { + Logger.Warn(ex, $"Tag reading failed for {path}. File is corrupt"); + } + catch (Exception ex) + { + Logger.Warn() + .Exception(ex) + .Message($"Tag reading failed for {path}") + .WriteSentryWarn("Tag reading failed") + .Write(); + } + finally + { + file?.Dispose(); + } + } + + private DateTime? ReadId3Date(TagLib.Id3v2.Tag tag, string dateTag) + { + string date = tag.GetTextAsString(dateTag); + + if (tag.Version == 4) + { + // the unabused TDRC/TDOR tags + return DateTime.TryParse(date, out DateTime result) ? result : default(DateTime?); + } + else if (dateTag == "TDRC") + { + // taglib maps the v3 TYER and TDAT to TDRC but does it incorrectly + return DateTime.TryParseExact(date, "yyyy-dd-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : default(DateTime?); + } + else + { + // taglib maps the v3 TORY to TDRC so we just get a year + return Int32.TryParse(date, out int year) ? new DateTime(year, 1, 1) : default(DateTime?); + } + } + + private void RemoveId3UserTextFrame(TagLib.Id3v2.Tag tag, string ident) + { + var frame = UserTextInformationFrame.Get(tag, ident, false); + if (frame != null) + { + tag.RemoveFrame(frame); + } + tag.RemoveFrames(ident); + } + + private void WriteId3Date(TagLib.Id3v2.Tag tag, string v4field, string v3yyyy, string v3ddmm, DateTime? date) + { + if (date.HasValue) + { + if (tag.Version == 4) + { + RemoveId3UserTextFrame(tag, v3yyyy); + if (v3ddmm.IsNotNullOrWhiteSpace()) + { + RemoveId3UserTextFrame(tag, v3ddmm); + } + tag.SetTextFrame(v4field, date.Value.ToString("yyyy-MM-dd")); + } + else + { + RemoveId3UserTextFrame(tag, v4field); + tag.SetTextFrame(v3yyyy, date.Value.ToString("yyyy")); + if (v3ddmm.IsNotNullOrWhiteSpace()) + { + tag.SetTextFrame(v3ddmm, date.Value.ToString("ddMM")); + } + } + } + } + + private void WriteId3Tag(TagLib.Id3v2.Tag tag, string id, string value) + { + var frame = UserTextInformationFrame.Get(tag, id, true); + + if (value.IsNotNullOrWhiteSpace()) + { + frame.Text = value.Split(';'); + } + else + { + tag.RemoveFrame(frame); + } + } + + private static ReadOnlyByteVector FixAppleId(ByteVector id) + { + if (id.Count == 4) { + var roid = id as ReadOnlyByteVector; + if (roid != null) + return roid; + + return new ReadOnlyByteVector(id); + } + + if (id.Count == 3) + return new ReadOnlyByteVector(0xa9, id[0], id[1], id[2]); + + return null; + } + + public void Write(string path) + { + Logger.Debug($"Starting tag write for {path}"); + + TagLib.File file = null; + try + { + file = TagLib.File.Create(path); + var tag = file.Tag; + + // do the ones with direct support in TagLib + tag.Title = Title; + tag.Performers = Performers; + tag.AlbumArtists = AlbumArtists; + tag.Track = Track; + tag.TrackCount = TrackCount; + tag.Album = Album; + tag.Disc = Disc; + tag.DiscCount = DiscCount; + tag.Publisher = Publisher; + tag.MusicBrainzReleaseCountry = MusicBrainzReleaseCountry; + tag.MusicBrainzReleaseStatus = MusicBrainzReleaseStatus; + tag.MusicBrainzReleaseType = MusicBrainzReleaseType; + tag.MusicBrainzReleaseId = MusicBrainzReleaseId; + tag.MusicBrainzArtistId = MusicBrainzArtistId; + tag.MusicBrainzReleaseArtistId = MusicBrainzReleaseArtistId; + tag.MusicBrainzReleaseGroupId = MusicBrainzReleaseGroupId; + tag.MusicBrainzTrackId = MusicBrainzTrackId; + + if (file.TagTypes.HasFlag(TagTypes.Id3v2)) + { + var id3tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2); + id3tag.SetTextFrame("TMED", Media); + WriteId3Date(id3tag, "TDRC", "TYER", "TDAT", Date); + WriteId3Date(id3tag, "TDOR", "TORY", null, OriginalReleaseDate); + WriteId3Tag(id3tag, "MusicBrainz Album Comment", MusicBrainzAlbumComment); + WriteId3Tag(id3tag, "MusicBrainz Release Track Id", MusicBrainzReleaseTrackId); + } + else if (file.TagTypes.HasFlag(TagTypes.Xiph)) + { + // while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is + // https://picard.musicbrainz.org/docs/mappings/ + tag.Publisher = null; + // taglib inserts leading zeros so set manually + tag.Track = 0; + + var flactag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph); + + if (Date.HasValue) + { + flactag.SetField("DATE", Date.Value.ToString("yyyy-MM-dd")); + } + if (OriginalReleaseDate.HasValue) + { + flactag.SetField("ORIGINALDATE", OriginalReleaseDate.Value.ToString("yyyy-MM-dd")); + flactag.SetField("ORIGINALYEAR", OriginalReleaseDate.Value.Year.ToString()); + } + + flactag.SetField("TRACKTOTAL", TrackCount); + flactag.SetField("TOTALTRACKS", TrackCount); + flactag.SetField("TRACKNUMBER", Track); + flactag.SetField("TOTALDISCS", DiscCount); + flactag.SetField("MEDIA", Media); + flactag.SetField("LABEL", Publisher); + flactag.SetField("MUSICBRAINZ_ALBUMCOMMENT", MusicBrainzAlbumComment); + flactag.SetField("MUSICBRAINZ_RELEASETRACKID", MusicBrainzReleaseTrackId); + + // Add the alternate mappings used by picard (we write both) + flactag.SetField("RELEASESTATUS", MusicBrainzReleaseStatus); + flactag.SetField("RELEASETYPE", MusicBrainzReleaseType); + } + else if (file.TagTypes.HasFlag(TagTypes.Ape)) + { + var apetag = (TagLib.Ape.Tag) file.GetTag(TagTypes.Ape); + + if (Date.HasValue) + { + apetag.SetValue("Year", Date.Value.ToString("yyyy-MM-dd")); + } + if (OriginalReleaseDate.HasValue) + { + apetag.SetValue("Original Date", OriginalReleaseDate.Value.ToString("yyyy-MM-dd")); + apetag.SetValue("Original Year", OriginalReleaseDate.Value.Year.ToString()); + } + + apetag.SetValue("Media", Media); + apetag.SetValue("Label", Publisher); + apetag.SetValue("MUSICBRAINZ_ALBUMCOMMENT", MusicBrainzAlbumComment); + apetag.SetValue("MUSICBRAINZ_RELEASETRACKID", MusicBrainzReleaseTrackId); + } + else if (file.TagTypes.HasFlag(TagTypes.Asf)) + { + var asftag = (TagLib.Asf.Tag) file.GetTag(TagTypes.Asf); + + if (Date.HasValue) + { + asftag.SetDescriptorString(Date.Value.ToString("yyyy-MM-dd"), "WM/Year"); + } + if (OriginalReleaseDate.HasValue) + { + asftag.SetDescriptorString(OriginalReleaseDate.Value.ToString("yyyy-MM-dd"), "WM/OriginalReleaseTime"); + asftag.SetDescriptorString(OriginalReleaseDate.Value.Year.ToString(), "WM/OriginalReleaseYear"); + } + + asftag.SetDescriptorString(Media, "WM/Media"); + asftag.SetDescriptorString(Publisher, "WM/Publisher"); + asftag.SetDescriptorString(MusicBrainzAlbumComment, "MusicBrainz/Album Comment"); + asftag.SetDescriptorString(MusicBrainzReleaseTrackId, "MusicBrainz/Release Track Id"); + } + else if (file.TagTypes.HasFlag(TagTypes.Apple)) + { + var appletag = (TagLib.Mpeg4.AppleTag) file.GetTag(TagTypes.Apple); + + if (Date.HasValue) + { + appletag.SetText(FixAppleId("day"), Date.Value.ToString("yyyy-MM-dd")); + } + if (OriginalReleaseDate.HasValue) + { + appletag.SetDashBox("com.apple.iTunes", "Original Date", OriginalReleaseDate.Value.ToString("yyyy-MM-dd")); + appletag.SetDashBox("com.apple.iTunes", "Original Year", OriginalReleaseDate.Value.Year.ToString()); + } + + appletag.SetDashBox("com.apple.iTunes", "MEDIA", Media); + appletag.SetDashBox("com.apple.iTunes", "MusicBrainz Album Comment", MusicBrainzAlbumComment); + appletag.SetDashBox("com.apple.iTunes", "MusicBrainz Release Track Id", MusicBrainzReleaseTrackId); + } + + file.Save(); + } + catch (CorruptFileException ex) + { + Logger.Warn(ex, $"Tag writing failed for {path}. File is corrupt"); + } + catch (Exception ex) + { + Logger.Warn() + .Exception(ex) + .Message($"Tag writing failed for {path}") + .WriteSentryWarn("Tag writing failed") + .Write(); + } + finally + { + file?.Dispose(); + } + } + + public Dictionary> Diff(AudioTag other) + { + var output = new Dictionary>(); + + if (!IsValid || !other.IsValid) + { + return output; + } + + if (Title != other.Title) + { + output.Add("Title", Tuple.Create(Title, other.Title)); + } + + if (!Performers.SequenceEqual(other.Performers)) + { + var oldValue = Performers.Any() ? string.Join(" / ", Performers) : null; + var newValue = other.Performers.Any() ? string.Join(" / ", other.Performers) : null; + + output.Add("Artist", Tuple.Create(oldValue, newValue)); + } + + if (Album != other.Album) + { + output.Add("Album", Tuple.Create(Album, other.Album)); + } + + if (!AlbumArtists.SequenceEqual(other.AlbumArtists)) + { + var oldValue = AlbumArtists.Any() ? string.Join(" / ", AlbumArtists) : null; + var newValue = other.AlbumArtists.Any() ? string.Join(" / ", other.AlbumArtists) : null; + + output.Add("Album Artist", Tuple.Create(oldValue, newValue)); + } + + if (Track != other.Track) + { + output.Add("Track", Tuple.Create(Track.ToString(), other.Track.ToString())); + } + + if (TrackCount != other.TrackCount) + { + output.Add("Track Count", Tuple.Create(TrackCount.ToString(), other.TrackCount.ToString())); + } + + if (Disc != other.Disc) + { + output.Add("Disc", Tuple.Create(Disc.ToString(), other.Disc.ToString())); + } + + if (DiscCount != other.DiscCount) + { + output.Add("Disc Count", Tuple.Create(DiscCount.ToString(), other.DiscCount.ToString())); + } + + if (Media != other.Media) + { + output.Add("Media Format", Tuple.Create(Media, other.Media)); + } + + if (Date != other.Date) + { + var oldValue = Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null; + var newValue = other.Date.HasValue ? other.Date.Value.ToString("yyyy-MM-dd") : null; + output.Add("Date", Tuple.Create(oldValue, newValue)); + } + + if (OriginalReleaseDate != other.OriginalReleaseDate) + { + // Id3v2.3 tags can only store the year, not the full date + if (OriginalReleaseDate.HasValue && + OriginalReleaseDate.Value.Month == 1 && + OriginalReleaseDate.Value.Day == 1) + { + if (OriginalReleaseDate.Value.Year != other.OriginalReleaseDate.Value.Year) + { + output.Add("Original Year", Tuple.Create(OriginalReleaseDate.Value.Year.ToString(), other.OriginalReleaseDate.Value.Year.ToString())); + } + } + else + { + var oldValue = OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null; + var newValue = other.OriginalReleaseDate.HasValue ? other.OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null; + output.Add("Original Release Date", Tuple.Create(oldValue, newValue)); + } + } + + if (Publisher != other.Publisher) + { + output.Add("Label", Tuple.Create(Publisher, other.Publisher)); + } + + return output; + } + + public static implicit operator ParsedTrackInfo (AudioTag tag) + { + if (!tag.IsValid) + { + return new ParsedTrackInfo { Language = Language.English }; + } + + var artist = tag.AlbumArtists?.FirstOrDefault(); + + if (artist.IsNullOrWhiteSpace()) + { + artist = tag.Performers?.FirstOrDefault(); + } + + var artistTitleInfo = new ArtistTitleInfo + { + Title = artist, + Year = (int)tag.Year + }; + + return new ParsedTrackInfo { + Language = Language.English, + AlbumTitle = tag.Album, + ArtistTitle = artist, + ArtistMBId = tag.MusicBrainzReleaseArtistId, + AlbumMBId = tag.MusicBrainzReleaseGroupId, + ReleaseMBId = tag.MusicBrainzReleaseId, + // SIC: the recording ID is stored in this field. + // See https://picard.musicbrainz.org/docs/mappings/ + RecordingMBId = tag.MusicBrainzTrackId, + TrackMBId = tag.MusicBrainzReleaseTrackId, + DiscNumber = (int)tag.Disc, + DiscCount = (int)tag.DiscCount, + Year = tag.Year, + Label = tag.Publisher, + TrackNumbers = new [] { (int) tag.Track }, + ArtistTitleInfo = artistTitleInfo, + Title = tag.Title, + CleanTitle = tag.Title?.CleanTrackTitle(), + Country = IsoCountries.Find(tag.MusicBrainzReleaseCountry), + Duration = tag.Duration, + Disambiguation = tag.MusicBrainzAlbumComment, + Quality = tag.Quality, + MediaInfo = tag.MediaInfo + }; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs new file mode 100644 index 000000000..cff10c204 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs @@ -0,0 +1,363 @@ +using NLog; +using NzbDrone.Core.Parser.Model; +using System.IO; +using System.Linq; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Disk; +using System; +using NLog.Fluent; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using TagLib; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IAudioTagService + { + ParsedTrackInfo ReadTags(string file); + void WriteTags(TrackFile trackfile, bool newDownload, bool force = false); + void SyncTags(List tracks); + void RemoveMusicBrainzTags(IEnumerable album); + void RemoveMusicBrainzTags(IEnumerable albumRelease); + void RemoveMusicBrainzTags(IEnumerable tracks); + void RemoveMusicBrainzTags(TrackFile trackfile); + List GetRetagPreviewsByArtist(int artistId); + List GetRetagPreviewsByAlbum(int artistId); + } + + public class AudioTagService : IAudioTagService, + IExecute, + IExecute + { + private readonly IConfigService _configService; + private readonly IMediaFileService _mediaFileService; + private readonly IDiskProvider _diskProvider; + private readonly IArtistService _artistService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public AudioTagService(IConfigService configService, + IMediaFileService mediaFileService, + IDiskProvider diskProvider, + IArtistService artistService, + IEventAggregator eventAggregator, + Logger logger) + { + _configService = configService; + _mediaFileService = mediaFileService; + _diskProvider = diskProvider; + _artistService = artistService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public AudioTag ReadAudioTag(string path) + { + return new AudioTag(path); + } + + public ParsedTrackInfo ReadTags(string path) + { + return new AudioTag(path); + } + + private AudioTag GetTrackMetadata(TrackFile trackfile) + { + var track = trackfile.Tracks.Value[0]; + var release = track.AlbumRelease.Value; + var album = release.Album.Value; + var albumartist = album.Artist.Value; + var artist = track.ArtistMetadata.Value; + + return new AudioTag { + Title = track.Title, + Performers = new [] { artist.Name }, + AlbumArtists = new [] { albumartist.Name }, + Track = (uint)track.AbsoluteTrackNumber, + TrackCount = (uint)release.Tracks.Value.Count(x => x.MediumNumber == track.MediumNumber), + Album = album.Title, + Disc = (uint)track.MediumNumber, + DiscCount = (uint)release.Media.Count, + Media = release.Media[track.MediumNumber - 1].Format, + Date = release.ReleaseDate, + Year = (uint)album.ReleaseDate?.Year, + OriginalReleaseDate = album.ReleaseDate, + OriginalYear = (uint)album.ReleaseDate?.Year, + Publisher = release.Label.FirstOrDefault(), + MusicBrainzReleaseCountry = IsoCountries.Find(release.Country.FirstOrDefault()).TwoLetterCode, + MusicBrainzReleaseStatus = release.Status.ToLower(), + MusicBrainzReleaseType = album.AlbumType.ToLower(), + MusicBrainzReleaseId = release.ForeignReleaseId, + MusicBrainzArtistId = artist.ForeignArtistId, + MusicBrainzReleaseArtistId = albumartist.ForeignArtistId, + MusicBrainzReleaseGroupId = album.ForeignAlbumId, + MusicBrainzTrackId = track.ForeignRecordingId, + MusicBrainzReleaseTrackId = track.ForeignTrackId, + MusicBrainzAlbumComment = album.Disambiguation, + }; + } + + private void UpdateTrackfileSize(TrackFile trackfile, string path) + { + // update the saved file size so that the importer doesn't get confused on the next scan + trackfile.Size = _diskProvider.GetFileSize(path); + if (trackfile.Id > 0) + { + _mediaFileService.Update(trackfile); + } + } + + public void RemoveAllTags(string path) + { + TagLib.File file = null; + try + { + file = TagLib.File.Create(path); + file.RemoveTags(TagLib.TagTypes.AllTags); + file.Save(); + } + catch (CorruptFileException ex) + { + _logger.Warn(ex, $"Tag removal failed for {path}. File is corrupt"); + } + catch (Exception ex) + { + _logger.Warn() + .Exception(ex) + .Message($"Tag removal failed for {path}") + .WriteSentryWarn("Tag removal failed") + .Write(); + } + finally + { + file?.Dispose(); + } + } + + public void RemoveMusicBrainzTags(string path) + { + var tags = new AudioTag(path); + + tags.MusicBrainzReleaseCountry = null; + tags.MusicBrainzReleaseStatus = null; + tags.MusicBrainzReleaseType = null; + tags.MusicBrainzReleaseId = null; + tags.MusicBrainzArtistId = null; + tags.MusicBrainzReleaseArtistId = null; + tags.MusicBrainzReleaseGroupId = null; + tags.MusicBrainzTrackId = null; + tags.MusicBrainzAlbumComment = null; + tags.MusicBrainzReleaseTrackId = null; + + tags.Write(path); + } + + public void WriteTags(TrackFile trackfile, bool newDownload, bool force = false) + { + if (!force) + { + if (_configService.WriteAudioTags == WriteAudioTagsType.No || + (_configService.WriteAudioTags == WriteAudioTagsType.NewFiles && !newDownload)) + { + return; + } + } + + if (trackfile.Tracks.Value.Count > 1) + { + _logger.Debug($"File {trackfile} is linked to multiple tracks. Not writing tags."); + return; + } + + var newTags = GetTrackMetadata(trackfile); + var path = Path.Combine(trackfile.Artist.Value.Path, trackfile.RelativePath); + + var diff = ReadAudioTag(path).Diff(newTags); + + if (_configService.ScrubAudioTags) + { + _logger.Debug($"Scrubbing tags for {trackfile}"); + RemoveAllTags(path); + } + + _logger.Debug($"Writing tags for {trackfile}"); + newTags.Write(path); + + UpdateTrackfileSize(trackfile, path); + + _eventAggregator.PublishEvent(new TrackFileRetaggedEvent(trackfile.Artist.Value, trackfile, diff, _configService.ScrubAudioTags)); + } + + public void SyncTags(List tracks) + { + if (_configService.WriteAudioTags != WriteAudioTagsType.Sync) + { + return; + } + + // get the tracks to update + var trackFiles = _mediaFileService.Get(tracks.Where(x => x.TrackFileId > 0).Select(x => x.TrackFileId)); + + _logger.Debug($"Syncing audio tags for {trackFiles.Count} files"); + + foreach (var file in trackFiles) + { + // populate tracks (which should also have release/album/artist set) because + // not all of the updates will have been committed to the database yet + file.Tracks = tracks.Where(x => x.TrackFileId == file.Id).ToList(); + WriteTags(file, false); + } + } + + public void RemoveMusicBrainzTags(IEnumerable albums) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + foreach (var album in albums) + { + var files = _mediaFileService.GetFilesByAlbum(album.Id); + foreach (var file in files) + { + RemoveMusicBrainzTags(file); + } + } + } + + public void RemoveMusicBrainzTags(IEnumerable releases) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + foreach (var release in releases) + { + var files = _mediaFileService.GetFilesByRelease(release.Id); + foreach (var file in files) + { + RemoveMusicBrainzTags(file); + } + } + } + + public void RemoveMusicBrainzTags(IEnumerable tracks) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + var files = _mediaFileService.Get(tracks.Where(x => x.TrackFileId > 0).Select(x => x.TrackFileId)); + foreach (var file in files) + { + RemoveMusicBrainzTags(file); + } + } + + public void RemoveMusicBrainzTags(TrackFile trackfile) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + var path = Path.Combine(trackfile.Artist.Value.Path, trackfile.RelativePath); + _logger.Debug($"Removing MusicBrainz tags for {path}"); + + RemoveMusicBrainzTags(path); + + UpdateTrackfileSize(trackfile, path); + } + + public List GetRetagPreviewsByArtist(int artistId) + { + var files = _mediaFileService.GetFilesByArtist(artistId); + + return GetPreviews(files).ToList(); + } + + public List GetRetagPreviewsByAlbum(int albumId) + { + var files = _mediaFileService.GetFilesByAlbum(albumId); + + return GetPreviews(files).ToList(); + } + + private IEnumerable GetPreviews(List files) + { + foreach (var f in files.OrderBy(x => x.Album.Value.Title) + .ThenBy(x => x.Tracks.Value.First().MediumNumber) + .ThenBy(x => x.Tracks.Value.First().AbsoluteTrackNumber)) + { + var file = f; + + if (!f.Tracks.Value.Any()) + { + _logger.Warn($"File {f} is not linked to any tracks"); + continue; + } + + if (f.Tracks.Value.Count > 1) + { + _logger.Debug($"File {f} is linked to multiple tracks. Not writing tags."); + continue; + } + + var oldTags = ReadAudioTag(Path.Combine(f.Artist.Value.Path, f.RelativePath)); + var newTags = GetTrackMetadata(f); + var diff = oldTags.Diff(newTags); + + if (diff.Any()) + { + yield return new RetagTrackFilePreview { + ArtistId = file.Artist.Value.Id, + AlbumId = file.Album.Value.Id, + TrackNumbers = file.Tracks.Value.Select(e => e.AbsoluteTrackNumber).ToList(), + TrackFileId = file.Id, + RelativePath = file.RelativePath, + Changes = diff + }; + } + } + } + + public void Execute(RetagFilesCommand message) + { + var artist = _artistService.GetArtist(message.ArtistId); + var trackFiles = _mediaFileService.Get(message.Files); + + _logger.ProgressInfo("Re-tagging {0} files for {1}", trackFiles.Count, artist.Name); + foreach (var file in trackFiles) + { + WriteTags(file, false, force: true); + } + _logger.ProgressInfo("Selected track files re-tagged for {0}", artist.Name); + } + + public void Execute(RetagArtistCommand message) + { + _logger.Debug("Re-tagging all files for selected artists"); + var artistToRename = _artistService.GetArtists(message.ArtistIds); + + foreach (var artist in artistToRename) + { + var trackFiles = _mediaFileService.GetFilesByArtist(artist.Id); + _logger.ProgressInfo("Re-tagging all files in artist: {0}", artist.Name); + foreach (var file in trackFiles) + { + WriteTags(file, false, force: true); + } + _logger.ProgressInfo("All track files re-tagged for {0}", artist.Name); + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs new file mode 100644 index 000000000..6ae6514f1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class RetagArtistCommand : Command + { + public List ArtistIds { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + + public RetagArtistCommand() + { + ArtistIds = new List(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs new file mode 100644 index 000000000..dcee0d979 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class RetagFilesCommand : Command + { + public int ArtistId { get; set; } + public List Files { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + + public RetagFilesCommand() + { + } + + public RetagFilesCommand(int artistId, List files) + { + ArtistId = artistId; + Files = files; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackFileRetaggedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackFileRetaggedEvent.cs new file mode 100644 index 000000000..3023103ec --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackFileRetaggedEvent.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class TrackFileRetaggedEvent : IEvent + { + public Artist Artist { get; private set; } + public TrackFile TrackFile { get; private set; } + public Dictionary> Diff { get; private set; } + public bool Scrubbed { get; private set; } + + public TrackFileRetaggedEvent(Artist artist, + TrackFile trackFile, + Dictionary> diff, + bool scrubbed) + { + Artist = artist; + TrackFile = trackFile; + Diff = diff; + Scrubbed = scrubbed; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs index f660ae5c0..e248a4796 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs @@ -13,9 +13,14 @@ namespace NzbDrone.Core.MediaFiles { _fileExtensions = new Dictionary(StringComparer.OrdinalIgnoreCase) { + { ".mp2", Quality.Unknown }, { ".mp3", Quality.Unknown }, { ".m4a", Quality.Unknown }, + { ".m4b", Quality.Unknown }, + { ".m4p", Quality.Unknown }, { ".ogg", Quality.Unknown }, + { ".oga", Quality.Unknown }, + { ".opus", Quality.Unknown }, { ".wma", Quality.WMA }, { ".wav", Quality.WAV }, { ".wv" , Quality.WAVPACK }, diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs index ffc79a866..06aafda30 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using Marr.Data.QGen; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; @@ -12,6 +10,7 @@ namespace NzbDrone.Core.MediaFiles { List GetFilesByArtist(int artistId); List GetFilesByAlbum(int albumId); + List GetFilesByRelease(int releaseId); List GetFilesWithRelativePath(int artistId, string relativePath); } @@ -27,10 +26,10 @@ namespace NzbDrone.Core.MediaFiles // needed more often than not so better to load it all now protected override QueryBuilder Query => DataMapper.Query() - .Join(JoinType.Inner, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) - .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) - .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) - .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id); + .Join(JoinType.Left, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) + .Join(JoinType.Left, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Left, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Left, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id); public List GetFilesByArtist(int artistId) { @@ -47,6 +46,14 @@ namespace NzbDrone.Core.MediaFiles .Where(f => f.AlbumId == albumId) .ToList(); } + + public List GetFilesByRelease(int releaseId) + { + return Query + .Where(x => x.AlbumReleaseId == releaseId) + .ToList(); + } + public List GetFilesWithRelativePath(int artistId, string relativePath) { diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index 4c0f35c0f..129a1bc39 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.MediaFiles void Delete(TrackFile trackFile, DeleteMediaFileReason reason); List GetFilesByArtist(int artistId); List GetFilesByAlbum(int albumId); + List GetFilesByRelease(int releaseId); List FilterExistingFiles(List files, Artist artist); TrackFile Get(int id); List Get(IEnumerable ids); @@ -115,6 +116,11 @@ namespace NzbDrone.Core.MediaFiles return _mediaFileRepository.GetFilesByAlbum(albumId); } + public List GetFilesByRelease(int releaseId) + { + return _mediaFileRepository.GetFilesByRelease(releaseId); + } + public void UpdateMediaInfo(List trackFiles) { _mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo); diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs b/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs index f9f3a2fee..49097ab24 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs @@ -33,6 +33,8 @@ namespace NzbDrone.Core.MediaFiles } public static readonly Dictionary CodecNames = new Dictionary { + {Codec.MP1, "MP1"}, + {Codec.MP2, "MP2"}, {Codec.AAC, "AAC"}, {Codec.AACVBR, "AAC"}, {Codec.ALAC, "ALAC"}, @@ -41,6 +43,7 @@ namespace NzbDrone.Core.MediaFiles {Codec.MP3CBR, "MP3"}, {Codec.MP3VBR, "MP3"}, {Codec.OGG, "OGG"}, + {Codec.OPUS, "OPUS"}, {Codec.WAV, "PCM"}, {Codec.WAVPACK, "WavPack"}, {Codec.WMA, "WMA"} diff --git a/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs b/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs new file mode 100644 index 000000000..f9026ffd7 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MediaFiles +{ + public class RetagTrackFilePreview + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public List TrackNumbers { get; set; } + public int TrackFileId { get; set; } + public string RelativePath { get; set; } + public Dictionary> Changes { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs index 804289a80..cec0803a3 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs @@ -189,7 +189,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification var allTracks = _trackService.GetTracksByReleases(candidateReleases.Select(x => x.Id).ToList()); - _logger.Debug($"Got tracks in {watch.ElapsedMilliseconds}ms"); + _logger.Debug($"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms"); GetBestRelease(localAlbumRelease, candidateReleases, allTracks); @@ -228,12 +228,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification List candidateReleases; - // if we have a release ID, use that + // if we have a release ID that makes sense, use that 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]); - return _releaseService.GetReleasesByForeignReleaseId(releaseIds); + var tagRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0]); + if (tagRelease != null) + { + _logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); + return new List { tagRelease }; + } } if (release != null) diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs index 52f6237a1..6fd237e78 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { private readonly IUpgradeMediaFiles _trackFileUpgrader; private readonly IMediaFileService _mediaFileService; + private readonly IAudioTagService _audioTagService; private readonly ITrackService _trackService; private readonly IRecycleBinProvider _recycleBinProvider; private readonly IExtraService _extraService; @@ -36,6 +37,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport public ImportApprovedTracks(IUpgradeMediaFiles trackFileUpgrader, IMediaFileService mediaFileService, + IAudioTagService audioTagService, ITrackService trackService, IRecycleBinProvider recycleBinProvider, IExtraService extraService, @@ -46,6 +48,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { _trackFileUpgrader = trackFileUpgrader; _mediaFileService = mediaFileService; + _audioTagService = audioTagService; _trackService = trackService; _recycleBinProvider = recycleBinProvider; _extraService = extraService; @@ -202,6 +205,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride); } + _audioTagService.WriteTags(trackFile, newDownload); } filesToAdd.Add(trackFile); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs index d6d039dd9..a7571bd4a 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport private readonly IEnumerable> _trackSpecifications; private readonly IEnumerable> _albumSpecifications; private readonly IMediaFileService _mediaFileService; + private readonly IAudioTagService _audioTagService; private readonly IAugmentingService _augmentingService; private readonly IIdentificationService _identificationService; private readonly IAlbumService _albumService; @@ -37,6 +38,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport public ImportDecisionMaker(IEnumerable> trackSpecifications, IEnumerable> albumSpecifications, IMediaFileService mediaFileService, + IAudioTagService audioTagService, IAugmentingService augmentingService, IIdentificationService identificationService, IAlbumService albumService, @@ -48,6 +50,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _trackSpecifications = trackSpecifications; _albumSpecifications = albumSpecifications; _mediaFileService = mediaFileService; + _audioTagService = audioTagService; _augmentingService = augmentingService; _identificationService = identificationService; _albumService = albumService; @@ -95,7 +98,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport DownloadClientAlbumInfo = downloadClientItemInfo, FolderTrackInfo = folderInfo, Path = file, - FileTrackInfo = Parser.Parser.ParseMusicPath(file), + FileTrackInfo = _audioTagService.ReadTags(file) }; try diff --git a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs index bf060cdd0..67e3f223b 100644 --- a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs @@ -17,18 +17,21 @@ namespace NzbDrone.Core.MediaFiles { private readonly IRecycleBinProvider _recycleBinProvider; private readonly IMediaFileService _mediaFileService; + private readonly IAudioTagService _audioTagService; private readonly IMoveTrackFiles _trackFileMover; private readonly IDiskProvider _diskProvider; private readonly Logger _logger; public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, IMediaFileService mediaFileService, + IAudioTagService audioTagService, IMoveTrackFiles trackFileMover, IDiskProvider diskProvider, Logger logger) { _recycleBinProvider = recycleBinProvider; _mediaFileService = mediaFileService; + _audioTagService = audioTagService; _trackFileMover = trackFileMover; _diskProvider = diskProvider; _logger = logger; @@ -76,6 +79,8 @@ namespace NzbDrone.Core.MediaFiles moveFileResult.TrackFile = _trackFileMover.MoveTrackFile(trackFile, localTrack); } + _audioTagService.WriteTags(trackFile, true); + return moveFileResult; } } diff --git a/src/NzbDrone.Core/Music/AddAlbumService.cs b/src/NzbDrone.Core/Music/AddAlbumService.cs index 4d5052ead..170482b8d 100644 --- a/src/NzbDrone.Core/Music/AddAlbumService.cs +++ b/src/NzbDrone.Core/Music/AddAlbumService.cs @@ -47,7 +47,10 @@ namespace NzbDrone.Core.Music _logger.ProgressInfo("Adding Album {0}", newAlbum.Title); _artistMetadataRepository.UpsertMany(tuple.Item3); _albumService.AddAlbum(newAlbum, tuple.Item1); - _refreshTrackService.RefreshTrackInfo(newAlbum); + + // make sure releases are populated for tag writing in the track refresh + newAlbum.AlbumReleases.Value.ForEach(x => x.Album = newAlbum); + _refreshTrackService.RefreshTrackInfo(newAlbum, false); return newAlbum; } @@ -66,7 +69,10 @@ namespace NzbDrone.Core.Music _logger.ProgressInfo("Adding Album {0}", newAlbum.Title); _artistMetadataRepository.UpsertMany(tuple.Item3); album = _albumService.AddAlbum(album, tuple.Item1); - _refreshTrackService.RefreshTrackInfo(album); + + // make sure releases are populated for tag writing in the track refresh + album.AlbumReleases.Value.ForEach(x => x.Album = album); + _refreshTrackService.RefreshTrackInfo(album, false); albumsToAdd.Add(album); } diff --git a/src/NzbDrone.Core/Music/AlbumRepository.cs b/src/NzbDrone.Core/Music/AlbumRepository.cs index e045be6c6..7337124ef 100644 --- a/src/NzbDrone.Core/Music/AlbumRepository.cs +++ b/src/NzbDrone.Core/Music/AlbumRepository.cs @@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music { List GetAlbums(int artistId); List GetAlbumsByArtistMetadataId(int artistMetadataId); + List GetAlbumsForRefresh(int artistId, IEnumerable foreignIds); Album FindByTitle(int artistMetadataId, string title); Album FindById(string foreignId); - List FindById(List foreignIds); PagingSpec AlbumsWithoutFiles(PagingSpec pagingSpec); PagingSpec AlbumsWhereCutoffUnmet(PagingSpec pagingSpec, List qualitiesBelowCutoff, List languagesBelowCutoff); List AlbumsBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored); @@ -53,19 +53,17 @@ namespace NzbDrone.Core.Music return Query.Where(s => s.ArtistMetadataId == artistMetadataId); } - public Album FindById(string foreignAlbumId) + public List GetAlbumsForRefresh(int artistMetadataId, IEnumerable foreignIds) { - return Query.Where(s => s.ForeignAlbumId == foreignAlbumId).SingleOrDefault(); + return Query + .Where(a => a.ArtistMetadataId == artistMetadataId) + .OrWhere($"[ForeignAlbumId] IN ('{string.Join("', '", foreignIds)}')") + .ToList(); } - public List FindById(List ids) + public Album FindById(string foreignAlbumId) { - string query = string.Format("SELECT Albums.* " + - "FROM Albums " + - "WHERE ForeignAlbumId IN ('{0}')", - string.Join("', '", ids)); - - return Query.QueryText(query).ToList(); + return Query.Where(s => s.ForeignAlbumId == foreignAlbumId).SingleOrDefault(); } public PagingSpec AlbumsWithoutFiles(PagingSpec pagingSpec) diff --git a/src/NzbDrone.Core/Music/AlbumService.cs b/src/NzbDrone.Core/Music/AlbumService.cs index ab2e692c5..516626c31 100644 --- a/src/NzbDrone.Core/Music/AlbumService.cs +++ b/src/NzbDrone.Core/Music/AlbumService.cs @@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music List GetAlbums(IEnumerable albumIds); List GetAlbumsByArtist(int artistId); List GetAlbumsByArtistMetadataId(int artistMetadataId); + List GetAlbumsForRefresh(int artistMetadataId, IEnumerable foreignIds); Album AddAlbum(Album newAlbum, string albumArtistId); Album FindById(string foreignId); - List FindById(List foreignIds); Album FindByTitle(int artistId, string title); Album FindByTitleInexact(int artistId, string title); List GetCandidates(int artistId, string title); @@ -41,7 +41,7 @@ namespace NzbDrone.Core.Music } public class AlbumService : IAlbumService, - IHandleAsync + IHandle { private readonly IAlbumRepository _albumRepository; private readonly IReleaseRepository _releaseRepository; @@ -96,11 +96,6 @@ namespace NzbDrone.Core.Music return _albumRepository.FindById(lidarrId); } - public List FindById(List ids) - { - return _albumRepository.FindById(ids); - } - public Album FindByTitle(int artistId, string title) { return _albumRepository.FindByTitle(artistId, title); @@ -200,6 +195,11 @@ namespace NzbDrone.Core.Music return _albumRepository.GetAlbumsByArtistMetadataId(artistMetadataId).ToList(); } + public List GetAlbumsForRefresh(int artistId, IEnumerable foreignIds) + { + return _albumRepository.GetAlbumsForRefresh(artistId, foreignIds); + } + public Album FindAlbumByRelease(string albumReleaseId) { return _albumRepository.FindAlbumByRelease(albumReleaseId); @@ -300,7 +300,7 @@ namespace NzbDrone.Core.Music return albums; } - public void HandleAsync(ArtistDeletedEvent message) + public void Handle(ArtistDeletedEvent message) { var albums = GetAlbumsByArtistMetadataId(message.Artist.ArtistMetadataId); DeleteMany(albums); diff --git a/src/NzbDrone.Core/Music/ArtistMetadata.cs b/src/NzbDrone.Core/Music/ArtistMetadata.cs index 3d7555b33..20d792a78 100644 --- a/src/NzbDrone.Core/Music/ArtistMetadata.cs +++ b/src/NzbDrone.Core/Music/ArtistMetadata.cs @@ -1,17 +1,13 @@ -using Marr.Data; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Datastore; -using NzbDrone.Core.Profiles.Qualities; -using NzbDrone.Core.Profiles.Languages; -using NzbDrone.Core.Profiles.Metadata; using System; using System.Collections.Generic; using System.Linq; -using System.Text; namespace NzbDrone.Core.Music { - public class ArtistMetadata : ModelBase + public class ArtistMetadata : ModelBase, IEquatable { public ArtistMetadata() { @@ -52,5 +48,70 @@ namespace NzbDrone.Core.Music Ratings = otherArtist.Ratings; Members = otherArtist.Members; } + + public bool Equals(ArtistMetadata other) + { + if (other == null) + { + return false; + } + + if (Id == other.Id && + ForeignArtistId == other.ForeignArtistId && + Name == other.Name && + Overview == other.Overview && + Disambiguation == other.Disambiguation && + Type == other.Type && + Status == other.Status && + Images?.ToJson() == other.Images?.ToJson() && + Links?.ToJson() == other.Links?.ToJson() && + (Genres?.SequenceEqual(other.Genres) ?? true) && + Ratings?.ToJson() == other.Ratings?.ToJson() && + Members?.ToJson() == other.Members?.ToJson()) + { + return true; + } + + return false; + } + + public override bool Equals(object obj) + { + if (obj == null) + { + return false; + } + + var other = obj as ArtistMetadata; + if (other == null) + { + return false; + } + else + { + return Equals(other); + } + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 23 + Id; + hash = hash * 23 + ForeignArtistId.GetHashCode(); + hash = hash * 23 + Name?.GetHashCode() ?? 0; + hash = hash * 23 + Overview?.GetHashCode() ?? 0; + hash = hash * 23 + Disambiguation?.GetHashCode() ?? 0; + hash = hash * 23 + Type?.GetHashCode() ?? 0; + hash = hash * 23 + (int)Status; + hash = hash * 23 + Images?.GetHashCode() ?? 0; + hash = hash * 23 + Links?.GetHashCode() ?? 0; + hash = hash * 23 + Genres?.GetHashCode() ?? 0; + hash = hash * 23 + Ratings?.GetHashCode() ?? 0; + hash = hash * 23 + Members?.GetHashCode() ?? 0; + return hash; + } + } } } diff --git a/src/NzbDrone.Core/Music/ArtistMetadataRepository.cs b/src/NzbDrone.Core/Music/ArtistMetadataRepository.cs index 498d091bd..ca355c37a 100644 --- a/src/NzbDrone.Core/Music/ArtistMetadataRepository.cs +++ b/src/NzbDrone.Core/Music/ArtistMetadataRepository.cs @@ -15,6 +15,7 @@ namespace NzbDrone.Core.Music Artist Upsert(Artist artist); void UpdateMany(List artists); ArtistMetadata FindById(string ArtistId); + List FindById(List foreignIds); void UpsertMany(List artists); void UpsertMany(List artists); } @@ -87,6 +88,11 @@ namespace NzbDrone.Core.Music return Query.Where(a => a.ForeignArtistId == artistId).SingleOrDefault(); } + public List FindById(List foreignIds) + { + return Query.Where($"[ForeignArtistId] IN ('{string.Join("','", foreignIds)}')").ToList(); + } + public void UpsertMany(List artists) { foreach (var artist in artists) diff --git a/src/NzbDrone.Core/Music/RefreshAlbumService.cs b/src/NzbDrone.Core/Music/RefreshAlbumService.cs index 94e206a72..9aac86fed 100644 --- a/src/NzbDrone.Core/Music/RefreshAlbumService.cs +++ b/src/NzbDrone.Core/Music/RefreshAlbumService.cs @@ -4,23 +4,20 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music.Events; using System; using System.Collections.Generic; -using NzbDrone.Core.Organizer; using System.Linq; -using System.Text; using NzbDrone.Core.MetadataSource; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Exceptions; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Music.Commands; - - +using NzbDrone.Core.MediaFiles; namespace NzbDrone.Core.Music { public interface IRefreshAlbumService { - void RefreshAlbumInfo(Album album); - void RefreshAlbumInfo(List albums, bool forceAlbumRefresh); + void RefreshAlbumInfo(Album album, bool forceUpdateFileTags); + void RefreshAlbumInfo(List albums, bool forceAlbumRefresh, bool forceUpdateFileTags); } public class RefreshAlbumService : IRefreshAlbumService, IExecute @@ -31,6 +28,7 @@ namespace NzbDrone.Core.Music private readonly IReleaseService _releaseService; private readonly IProvideAlbumInfo _albumInfo; private readonly IRefreshTrackService _refreshTrackService; + private readonly IAudioTagService _audioTagService; private readonly IEventAggregator _eventAggregator; private readonly ICheckIfAlbumShouldBeRefreshed _checkIfAlbumShouldBeRefreshed; private readonly Logger _logger; @@ -41,6 +39,7 @@ namespace NzbDrone.Core.Music IReleaseService releaseService, IProvideAlbumInfo albumInfo, IRefreshTrackService refreshTrackService, + IAudioTagService audioTagService, IEventAggregator eventAggregator, ICheckIfAlbumShouldBeRefreshed checkIfAlbumShouldBeRefreshed, Logger logger) @@ -51,23 +50,24 @@ namespace NzbDrone.Core.Music _releaseService = releaseService; _albumInfo = albumInfo; _refreshTrackService = refreshTrackService; + _audioTagService = audioTagService; _eventAggregator = eventAggregator; _checkIfAlbumShouldBeRefreshed = checkIfAlbumShouldBeRefreshed; _logger = logger; } - public void RefreshAlbumInfo(List albums, bool forceAlbumRefresh) + public void RefreshAlbumInfo(List albums, bool forceAlbumRefresh, bool forceUpdateFileTags) { foreach (var album in albums) { if (forceAlbumRefresh || _checkIfAlbumShouldBeRefreshed.ShouldRefresh(album)) { - RefreshAlbumInfo(album); + RefreshAlbumInfo(album, forceUpdateFileTags); } } } - public void RefreshAlbumInfo(Album album) + public void RefreshAlbumInfo(Album album, bool forceUpdateFileTags) { _logger.ProgressInfo("Updating Info for {0}", album.Title); @@ -79,13 +79,43 @@ namespace NzbDrone.Core.Music } catch (AlbumNotFoundException) { - _logger.Error( - "Album '{0}' (LidarrAPI {1}) was not found, it may have been removed from Metadata sources.", - album.Title, album.ForeignAlbumId); + _logger.Error($"{album} was not found, it may have been removed from Metadata sources."); return; } - _artistMetadataRepository.UpsertMany(tuple.Item3); + var remoteMetadata = tuple.Item3.DistinctBy(x => x.ForeignArtistId).ToList(); + var existingMetadata = _artistMetadataRepository.FindById(remoteMetadata.Select(x => x.ForeignArtistId).ToList()); + var updateMetadataList = new List(); + var addMetadataList = new List(); + var upToDateMetadataCount = 0; + + foreach (var meta in remoteMetadata) + { + var existing = existingMetadata.SingleOrDefault(x => x.ForeignArtistId == meta.ForeignArtistId); + if (existing != null) + { + meta.Id = existing.Id; + if (!meta.Equals(existing)) + { + updateMetadataList.Add(meta); + } + else + { + upToDateMetadataCount++; + } + } + else + { + addMetadataList.Add(meta); + } + } + + _logger.Debug($"{album}: {upToDateMetadataCount} artist metadata up to date; Updating {updateMetadataList.Count}, Adding {addMetadataList.Count} artist metadata entries."); + + _artistMetadataRepository.UpdateMany(updateMetadataList); + _artistMetadataRepository.InsertMany(addMetadataList); + + forceUpdateFileTags |= updateMetadataList.Any(); var albumInfo = tuple.Item2; @@ -97,6 +127,9 @@ namespace NzbDrone.Core.Music album.ForeignAlbumId = albumInfo.ForeignAlbumId; } + // the only thing written to tags from the album object is the title + forceUpdateFileTags |= album.Title != (albumInfo.Title ?? "Unknown"); + album.LastInfoSync = DateTime.UtcNow; album.CleanTitle = albumInfo.CleanTitle; album.Title = albumInfo.Title ?? "Unknown"; @@ -112,28 +145,34 @@ namespace NzbDrone.Core.Music album.AlbumReleases = new List(); var remoteReleases = albumInfo.AlbumReleases.Value.DistinctBy(m => m.ForeignReleaseId).ToList(); - - // Search both ways to make sure we properly deal with releases that have been moved from one album to another - // as well as deleting any releases that have been removed from an album. - // note that under normal circumstances, a release would be captured by both queries. - var existingReleasesByAlbum = _releaseService.GetReleasesByAlbum(album.Id); - var existingReleasesById = _releaseService.GetReleasesByForeignReleaseId(remoteReleases.Select(x => x.ForeignReleaseId).ToList()); - var existingReleases = existingReleasesByAlbum.Union(existingReleasesById).DistinctBy(x => x.Id).ToList(); - + var existingReleases = _releaseService.GetReleasesForRefresh(album.Id, remoteReleases.Select(x => x.ForeignReleaseId)); var newReleaseList = new List(); var updateReleaseList = new List(); + var upToDateCount = 0; foreach (var release in remoteReleases) { release.AlbumId = album.Id; + release.Album = album; var releaseToRefresh = existingReleases.SingleOrDefault(r => r.ForeignReleaseId == release.ForeignReleaseId); if (releaseToRefresh != null) { existingReleases.Remove(releaseToRefresh); + + // copy across the db keys and check for equality release.Id = releaseToRefresh.Id; + release.AlbumId = releaseToRefresh.AlbumId; release.Monitored = releaseToRefresh.Monitored; - updateReleaseList.Add(release); + + if (!releaseToRefresh.Equals(release)) + { + updateReleaseList.Add(release); + } + else + { + upToDateCount++; + } } else { @@ -143,10 +182,11 @@ namespace NzbDrone.Core.Music album.AlbumReleases.Value.Add(release); } - _logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} releases", - album, existingReleases.Count, updateReleaseList.Count, newReleaseList.Count); + _logger.Debug($"{album} {upToDateCount} releases up to date; Deleting {existingReleases.Count}, Updating {updateReleaseList.Count}, Adding {newReleaseList.Count} releases."); + + // before deleting anything, remove musicbrainz ids for things we are deleting + _audioTagService.RemoveMusicBrainzTags(existingReleases); - // Delete first to avoid hitting distinct constraints _releaseService.DeleteMany(existingReleases); _releaseService.UpdateMany(updateReleaseList); _releaseService.InsertMany(newReleaseList); @@ -158,7 +198,10 @@ namespace NzbDrone.Core.Music _releaseService.UpdateMany(new List { toMonitor }); } - _refreshTrackService.RefreshTrackInfo(album); + // if we have updated a monitored release, refresh all file tags + forceUpdateFileTags |= updateReleaseList.Any(x => x.Monitored); + + _refreshTrackService.RefreshTrackInfo(album, forceUpdateFileTags); _albumService.UpdateMany(new List{album}); _logger.Debug("Finished album refresh for {0}", album.Title); @@ -171,7 +214,7 @@ namespace NzbDrone.Core.Music { var album = _albumService.GetAlbum(message.AlbumId.Value); var artist = _artistService.GetArtistByMetadataId(album.ArtistMetadataId); - RefreshAlbumInfo(album); + RefreshAlbumInfo(album, false); _eventAggregator.PublishEvent(new ArtistUpdatedEvent(artist)); } diff --git a/src/NzbDrone.Core/Music/RefreshArtistService.cs b/src/NzbDrone.Core/Music/RefreshArtistService.cs index e916815dc..e1a7f97e4 100644 --- a/src/NzbDrone.Core/Music/RefreshArtistService.cs +++ b/src/NzbDrone.Core/Music/RefreshArtistService.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.Music private readonly IAlbumService _albumService; private readonly IRefreshAlbumService _refreshAlbumService; private readonly IRefreshTrackService _refreshTrackService; + private readonly IAudioTagService _audioTagService; private readonly IEventAggregator _eventAggregator; private readonly IDiskScanService _diskScanService; private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed; @@ -39,6 +40,7 @@ namespace NzbDrone.Core.Music IAlbumService albumService, IRefreshAlbumService refreshAlbumService, IRefreshTrackService refreshTrackService, + IAudioTagService audioTagService, IEventAggregator eventAggregator, IDiskScanService diskScanService, ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed, @@ -52,6 +54,7 @@ namespace NzbDrone.Core.Music _albumService = albumService; _refreshAlbumService = refreshAlbumService; _refreshTrackService = refreshTrackService; + _audioTagService = audioTagService; _eventAggregator = eventAggregator; _diskScanService = diskScanService; _checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed; @@ -72,13 +75,15 @@ namespace NzbDrone.Core.Music } catch (ArtistNotFoundException) { - _logger.Error("Artist '{0}' (LidarrAPI {1}) was not found, it may have been removed from Metadata sources.", artist.Name, artist.Metadata.Value.ForeignArtistId); + _logger.Error($"Artist {artist} was not found, it may have been removed from Metadata sources."); return; } + var forceUpdateFileTags = artist.Name != artistInfo.Name; + if (artist.Metadata.Value.ForeignArtistId != artistInfo.Metadata.Value.ForeignArtistId) { - _logger.Warn("Artist '{0}' (Artist {1}) was replaced with '{2}' (LidarrAPI {3}), because the original was a duplicate.", artist.Name, artist.Metadata.Value.ForeignArtistId, artistInfo.Name, artistInfo.Metadata.Value.ForeignArtistId); + _logger.Warn($"Artist {artist} was replaced with {artistInfo} because the original was a duplicate."); // Update list exclusion if one exists var importExclusion = _importListExclusionService.FindByForeignId(artist.Metadata.Value.ForeignArtistId); @@ -90,6 +95,7 @@ namespace NzbDrone.Core.Music } artist.Metadata.Value.ForeignArtistId = artistInfo.Metadata.Value.ForeignArtistId; + forceUpdateFileTags = true; } artist.Metadata.Value.ApplyChanges(artistInfo.Metadata.Value); @@ -107,13 +113,10 @@ namespace NzbDrone.Core.Music _logger.Warn(e, "Couldn't update artist path for " + artist.Path); } - var remoteAlbums = artistInfo.Albums.Value.DistinctBy(m => new { m.ForeignAlbumId, m.ReleaseDate }).ToList(); + var remoteAlbums = artistInfo.Albums.Value.DistinctBy(m => m.ForeignAlbumId).ToList(); // Get list of DB current db albums for artist - var existingAlbumsByArtist = _albumService.GetAlbumsByArtist(artist.Id); - var existingAlbumsById = _albumService.FindById(remoteAlbums.Select(x => x.ForeignAlbumId).ToList()); - var existingAlbums = existingAlbumsByArtist.Union(existingAlbumsById).DistinctBy(x => x.Id).ToList(); - + var existingAlbums = _albumService.GetAlbumsForRefresh(artist.ArtistMetadataId, remoteAlbums.Select(x => x.ForeignAlbumId)); var newAlbumsList = new List(); var updateAlbumsList = new List(); @@ -121,15 +124,17 @@ namespace NzbDrone.Core.Music foreach (var album in remoteAlbums) { // Check for album in existing albums, if not set properties and add to new list - var albumToRefresh = existingAlbums.FirstOrDefault(s => s.ForeignAlbumId == album.ForeignAlbumId); + var albumToRefresh = existingAlbums.SingleOrDefault(s => s.ForeignAlbumId == album.ForeignAlbumId); if (albumToRefresh != null) { + albumToRefresh.Artist = artist; existingAlbums.Remove(albumToRefresh); updateAlbumsList.Add(albumToRefresh); } else { + album.Artist = artist; newAlbumsList.Add(album); } } @@ -139,6 +144,9 @@ namespace NzbDrone.Core.Music _logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} albums", artist, existingAlbums.Count, updateAlbumsList.Count, newAlbumsList.Count); + // before deleting anything, remove musicbrainz ids for things we are deleting + _audioTagService.RemoveMusicBrainzTags(existingAlbums); + // Delete old albums first - this avoids errors if albums have been merged and we'll // end up trying to duplicate an existing release under a new album _albumService.DeleteMany(existingAlbums); @@ -147,7 +155,7 @@ namespace NzbDrone.Core.Music newAlbumsList = UpdateAlbums(artist, newAlbumsList); _addAlbumService.AddAlbums(newAlbumsList); - _refreshAlbumService.RefreshAlbumInfo(updateAlbumsList, forceAlbumRefresh); + _refreshAlbumService.RefreshAlbumInfo(updateAlbumsList, forceAlbumRefresh, forceUpdateFileTags); _eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(artist, newAlbumsList, updateAlbumsList)); diff --git a/src/NzbDrone.Core/Music/RefreshTrackService.cs b/src/NzbDrone.Core/Music/RefreshTrackService.cs index 4b79ca163..8e6e46cfa 100644 --- a/src/NzbDrone.Core/Music/RefreshTrackService.cs +++ b/src/NzbDrone.Core/Music/RefreshTrackService.cs @@ -1,35 +1,43 @@ using NLog; using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Music.Events; using System; using System.Collections.Generic; using System.Linq; -using System.Text; namespace NzbDrone.Core.Music { public interface IRefreshTrackService { - void RefreshTrackInfo(Album rg); + void RefreshTrackInfo(Album rg, bool forceUpdateFileTags); } public class RefreshTrackService : IRefreshTrackService { private readonly ITrackService _trackService; private readonly IAlbumService _albumService; + private readonly IMediaFileService _mediaFileService; + private readonly IAudioTagService _audioTagService; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; - public RefreshTrackService(ITrackService trackService, IAlbumService albumService, IEventAggregator eventAggregator, Logger logger) + public RefreshTrackService(ITrackService trackService, + IAlbumService albumService, + IMediaFileService mediaFileService, + IAudioTagService audioTagService, + IEventAggregator eventAggregator, + Logger logger) { _trackService = trackService; _albumService = albumService; + _mediaFileService = mediaFileService; + _audioTagService = audioTagService; _eventAggregator = eventAggregator; _logger = logger; } - public void RefreshTrackInfo(Album album) + public void RefreshTrackInfo(Album album, bool forceUpdateFileTags) { _logger.Info("Starting track info refresh for: {0}", album); var successCount = 0; @@ -37,48 +45,50 @@ namespace NzbDrone.Core.Music foreach (var release in album.AlbumReleases.Value) { - var dupeFreeRemoteTracks = release.Tracks.Value.DistinctBy(m => new { m.ForeignTrackId, m.TrackNumber }).ToList(); - - // Search both ways to make sure we properly deal with tracks that have been moved from one release to another - // as well as deleting any tracks that have been removed from a release. - // note that under normal circumstances, a track would be captured by both queries. - var existingTracksByRelease = _trackService.GetTracksByForeignReleaseId(release.ForeignReleaseId); - var existingTracksById = _trackService.GetTracksByForeignTrackIds(dupeFreeRemoteTracks.Select(x => x.ForeignTrackId).ToList()); - var existingTracks = existingTracksByRelease.Union(existingTracksById).DistinctBy(x => x.Id).ToList(); + var remoteTracks = release.Tracks.Value.DistinctBy(m => m.ForeignTrackId).ToList(); + var existingTracks = _trackService.GetTracksForRefresh(release.Id, remoteTracks.Select(x => x.ForeignTrackId)); var updateList = new List(); var newList = new List(); + var upToDateList = new List(); - foreach (var track in OrderTracks(dupeFreeRemoteTracks)) + foreach (var track in remoteTracks) { + track.AlbumRelease = release; + track.AlbumReleaseId = release.Id; + // the artist metadata will have been inserted by RefreshAlbumInfo so the Id will now be populated + track.ArtistMetadataId = track.ArtistMetadata.Value.Id; + try { - var trackToUpdate = GetTrackToUpdate(track, existingTracks); - + var trackToUpdate = existingTracks.SingleOrDefault(e => e.ForeignTrackId == track.ForeignTrackId); if (trackToUpdate != null) { existingTracks.Remove(trackToUpdate); - updateList.Add(trackToUpdate); + + // populate albumrelease for later + trackToUpdate.AlbumRelease = release; + + // copy across the db keys to the remote track and check if we need to update + track.Id = trackToUpdate.Id; + track.TrackFileId = trackToUpdate.TrackFileId; + // make sure title is not null + track.Title = track.Title ?? "Unknown"; + + if (!trackToUpdate.Equals(track)) + { + updateList.Add(track); + } + else + { + upToDateList.Add(track); + } } else { - trackToUpdate = new Track(); - trackToUpdate.Id = track.Id; - newList.Add(trackToUpdate); + newList.Add(track); } - // TODO: Use object mapper to automatically handle this - trackToUpdate.ForeignTrackId = track.ForeignTrackId; - trackToUpdate.ForeignRecordingId = track.ForeignRecordingId; - trackToUpdate.AlbumReleaseId = release.Id; - trackToUpdate.ArtistMetadataId = track.ArtistMetadata.Value.Id; - trackToUpdate.TrackNumber = track.TrackNumber; - trackToUpdate.AbsoluteTrackNumber = track.AbsoluteTrackNumber; - trackToUpdate.Title = track.Title ?? "Unknown"; - trackToUpdate.Explicit = track.Explicit; - trackToUpdate.Duration = track.Duration; - trackToUpdate.MediumNumber = track.MediumNumber; - successCount++; } catch (Exception e) @@ -88,8 +98,19 @@ namespace NzbDrone.Core.Music } } - _logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} tracks", - release, existingTracks.Count, updateList.Count, newList.Count); + // if any tracks with files are deleted, strip out the MB tags from the metadata + // so that we stand a chance of matching next time + _audioTagService.RemoveMusicBrainzTags(existingTracks); + + var tagsToUpdate = updateList; + if (forceUpdateFileTags) + { + _logger.Debug("Forcing tag update due to Artist/Album/Release updates"); + tagsToUpdate = updateList.Concat(upToDateList).ToList(); + } + _audioTagService.SyncTags(tagsToUpdate); + + _logger.Debug($"{release}: {upToDateList.Count} tracks up to date; Deleting {existingTracks.Count}, Updating {updateList.Count}, Adding {newList.Count} tracks."); _trackService.DeleteMany(existingTracks); _trackService.UpdateMany(updateList); @@ -106,17 +127,6 @@ namespace NzbDrone.Core.Music _logger.Info("Finished track refresh for album: {0}.", album); } } - - private Track GetTrackToUpdate(Track track, List existingTracks) - { - var result = existingTracks.FirstOrDefault(e => e.ForeignTrackId == track.ForeignTrackId && e.TrackNumber == track.TrackNumber); - return result; - } - - private IEnumerable OrderTracks(List tracks) - { - return tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber); - } } } diff --git a/src/NzbDrone.Core/Music/Release.cs b/src/NzbDrone.Core/Music/Release.cs index ac2d2e6fe..bafd17b91 100644 --- a/src/NzbDrone.Core/Music/Release.cs +++ b/src/NzbDrone.Core/Music/Release.cs @@ -4,10 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; using Marr.Data; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.Music { - public class AlbumRelease : ModelBase + public class AlbumRelease : ModelBase, IEquatable { // These correspond to columns in the AlbumReleases table public int AlbumId { get; set; } @@ -31,5 +32,72 @@ namespace NzbDrone.Core.Music { return string.Format("[{0}][{1}]", ForeignReleaseId, Title.NullSafe()); } + + public bool Equals (AlbumRelease other) + { + if (other == null) + { + return false; + } + + if (Id == other.Id && + AlbumId == other.AlbumId && + ForeignReleaseId == other.ForeignReleaseId && + Title == other.Title && + Status == other.Status && + Duration == other.Duration && + (Label?.SequenceEqual(other.Label) ?? true) && + Disambiguation == other.Disambiguation && + (Country?.SequenceEqual(other.Country) ?? true) && + ReleaseDate == other.ReleaseDate && + ((Media == null && other.Media == null) || (Media?.ToJson() == other.Media?.ToJson())) && + TrackCount == other.TrackCount && + Monitored == other.Monitored) + { + return true; + } + + return false; + } + + public override bool Equals(object obj) + { + if (obj == null) + { + return false; + } + + var other = obj as AlbumRelease; + if (other == null) + { + return false; + } + else + { + return Equals(other); + } + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 23 + Id; + hash = hash * 23 + AlbumId; + hash = hash * 23 + ForeignReleaseId.GetHashCode(); + hash = hash * 23 + Title?.GetHashCode() ?? 0; + hash = hash * 23 + Status?.GetHashCode() ?? 0; + hash = hash * 23 + Duration; + hash = hash * 23 + Label?.GetHashCode() ?? 0; + hash = hash * 23 + Disambiguation?.GetHashCode() ?? 0; + hash = hash * 23 + Country?.GetHashCode() ?? 0; + hash = hash * 23 + ReleaseDate.GetHashCode(); + hash = hash * 23 + Media?.GetHashCode() ?? 0; + hash = hash * 23 + TrackCount; + hash = hash * 23 + Monitored.GetHashCode(); + return hash; + } + } } } diff --git a/src/NzbDrone.Core/Music/ReleaseRepository.cs b/src/NzbDrone.Core/Music/ReleaseRepository.cs index 2042b2fbb..bc418aeee 100644 --- a/src/NzbDrone.Core/Music/ReleaseRepository.cs +++ b/src/NzbDrone.Core/Music/ReleaseRepository.cs @@ -9,10 +9,11 @@ namespace NzbDrone.Core.Music { public interface IReleaseRepository : IBasicRepository { + AlbumRelease FindByForeignReleaseId(string foreignReleaseId); List FindByAlbum(int id); List FindByRecordingId(List recordingIds); + List GetReleasesForRefresh(int albumId, IEnumerable foreignReleaseIds); List SetMonitored(AlbumRelease release); - List FindByForeignReleaseId(List foreignReleaseIds); } public class ReleaseRepository : BasicRepository, IReleaseRepository @@ -22,23 +23,29 @@ namespace NzbDrone.Core.Music { } - public List FindByAlbum(int id) + public AlbumRelease FindByForeignReleaseId(string foreignReleaseId) { - // populate the albums and artist metadata also - // this hopefully speeds up the track matching a lot return Query - .Join(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id) - .Join(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id) - .Where(r => r.AlbumId == id) + .Where(x => x.ForeignReleaseId == foreignReleaseId) + .SingleOrDefault(); + } + + public List GetReleasesForRefresh(int albumId, IEnumerable foreignReleaseIds) + { + return Query + .Where(r => r.AlbumId == albumId) + .OrWhere($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')") .ToList(); } - public List FindByForeignReleaseId(List foreignReleaseIds) + public List FindByAlbum(int id) { + // populate the albums and artist metadata also + // this hopefully speeds up the track matching a lot return Query .Join(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id) .Join(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id) - .Where($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')") + .Where(r => r.AlbumId == id) .ToList(); } diff --git a/src/NzbDrone.Core/Music/ReleaseService.cs b/src/NzbDrone.Core/Music/ReleaseService.cs index 7f3373828..ced97d025 100644 --- a/src/NzbDrone.Core/Music/ReleaseService.cs +++ b/src/NzbDrone.Core/Music/ReleaseService.cs @@ -7,17 +7,18 @@ namespace NzbDrone.Core.Music public interface IReleaseService { AlbumRelease GetRelease(int id); + AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId); void InsertMany(List releases); void UpdateMany(List releases); void DeleteMany(List releases); + List GetReleasesForRefresh(int albumId, IEnumerable foreignReleaseIds); List GetReleasesByAlbum(int releaseGroupId); - List GetReleasesByForeignReleaseId(List foreignReleaseIds); List GetReleasesByRecordingIds(List recordingIds); List SetMonitored(AlbumRelease release); } public class ReleaseService : IReleaseService, - IHandleAsync + IHandle { private readonly IReleaseRepository _releaseRepository; private readonly IEventAggregator _eventAggregator; @@ -34,6 +35,11 @@ namespace NzbDrone.Core.Music return _releaseRepository.Get(id); } + public AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId) + { + return _releaseRepository.FindByForeignReleaseId(foreignReleaseId); + } + public void InsertMany(List releases) { _releaseRepository.InsertMany(releases); @@ -53,16 +59,16 @@ namespace NzbDrone.Core.Music } } - public List GetReleasesByAlbum(int releaseGroupId) + public List GetReleasesForRefresh(int albumId, IEnumerable foreignReleaseIds) { - return _releaseRepository.FindByAlbum(releaseGroupId); + return _releaseRepository.GetReleasesForRefresh(albumId, foreignReleaseIds); } - public List GetReleasesByForeignReleaseId(List foreignReleaseIds) + public List GetReleasesByAlbum(int releaseGroupId) { - return _releaseRepository.FindByForeignReleaseId(foreignReleaseIds); + return _releaseRepository.FindByAlbum(releaseGroupId); } - + public List GetReleasesByRecordingIds(List recordingIds) { return _releaseRepository.FindByRecordingId(recordingIds); @@ -73,7 +79,7 @@ namespace NzbDrone.Core.Music return _releaseRepository.SetMonitored(release); } - public void HandleAsync(AlbumDeletedEvent message) + public void Handle(AlbumDeletedEvent message) { var releases = GetReleasesByAlbum(message.Album.Id); DeleteMany(releases); diff --git a/src/NzbDrone.Core/Music/Track.cs b/src/NzbDrone.Core/Music/Track.cs index ba309e74e..373505441 100644 --- a/src/NzbDrone.Core/Music/Track.cs +++ b/src/NzbDrone.Core/Music/Track.cs @@ -2,10 +2,12 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaFiles; using Marr.Data; using NzbDrone.Common.Extensions; +using System; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.Music { - public class Track : ModelBase + public class Track : ModelBase, IEquatable { public Track() { @@ -41,5 +43,72 @@ namespace NzbDrone.Core.Music { return string.Format("[{0}]{1}", ForeignTrackId, Title.NullSafe()); } + + public bool Equals(Track other) + { + if (other == null) + { + return false; + } + + if (Id == other.Id && + ForeignTrackId == other.ForeignTrackId && + ForeignRecordingId == other.ForeignRecordingId && + AlbumReleaseId == other.AlbumReleaseId && + ArtistMetadataId == other.ArtistMetadataId && + TrackNumber == other.TrackNumber && + AbsoluteTrackNumber == other.AbsoluteTrackNumber && + Title == other.Title && + Duration == other.Duration && + Explicit == other.Explicit && + Ratings?.ToJson() == other.Ratings?.ToJson() && + MediumNumber == other.MediumNumber && + TrackFileId == other.TrackFileId) + { + return true; + } + + return false; + } + + public override bool Equals(object obj) + { + if (obj == null) + { + return false; + } + + var other = obj as Track; + if (other == null) + { + return false; + } + else + { + return Equals(other); + } + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 23 + Id; + hash = hash * 23 + ForeignTrackId.GetHashCode(); + hash = hash * 23 + ForeignRecordingId.GetHashCode(); + hash = hash * 23 + AlbumReleaseId; + hash = hash * 23 + ArtistMetadataId; + hash = hash * 23 + TrackNumber?.GetHashCode() ?? 0; + hash = hash * 23 + AbsoluteTrackNumber; + hash = hash * 23 + Title?.GetHashCode() ?? 0; + hash = hash * 23 + Duration; + hash = hash * 23 + Explicit.GetHashCode(); + hash = hash * 23 + Ratings?.GetHashCode() ?? 0; + hash = hash * 23 + MediumNumber; + hash = hash * 23 + TrackFileId; + return hash; + } + } } } diff --git a/src/NzbDrone.Core/Music/TrackRepository.cs b/src/NzbDrone.Core/Music/TrackRepository.cs index 2bfbfc2ad..508925c28 100644 --- a/src/NzbDrone.Core/Music/TrackRepository.cs +++ b/src/NzbDrone.Core/Music/TrackRepository.cs @@ -11,8 +11,7 @@ namespace NzbDrone.Core.Music List GetTracksByAlbum(int albumId); List GetTracksByRelease(int albumReleaseId); List GetTracksByReleases(List albumReleaseId); - List GetTracksByForeignReleaseId(string foreignReleaseId); - List GetTracksByForeignTrackIds(List foreignTrackId); + List GetTracksForRefresh(int albumReleaseId, IEnumerable foreignTrackIds); List GetTracksByFileId(int fileId); List TracksWithFiles(int artistId); List TracksWithoutFiles(int albumId); @@ -73,25 +72,12 @@ namespace NzbDrone.Core.Music .ToList(); } - public List GetTracksByForeignReleaseId(string foreignReleaseId) + public List GetTracksForRefresh(int albumReleaseId, IEnumerable foreignTrackIds) { - string query = string.Format("SELECT Tracks.* " + - "FROM AlbumReleases " + - "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + - "WHERE AlbumReleases.ForeignReleaseId = '{0}'", - foreignReleaseId); - - return Query.QueryText(query).ToList(); - } - - public List GetTracksByForeignTrackIds(List ids) - { - string query = string.Format("SELECT Tracks.* " + - "FROM Tracks " + - "WHERE ForeignTrackId IN ('{0}')", - string.Join("', '", ids)); - - return Query.QueryText(query).ToList(); + return Query + .Where(t => t.AlbumReleaseId == albumReleaseId) + .OrWhere($"[ForeignTrackId] IN ('{string.Join("', '", foreignTrackIds)}')") + .ToList(); } public List GetTracksByFileId(int fileId) diff --git a/src/NzbDrone.Core/Music/TrackService.cs b/src/NzbDrone.Core/Music/TrackService.cs index 12d6610df..0aaaeb77e 100644 --- a/src/NzbDrone.Core/Music/TrackService.cs +++ b/src/NzbDrone.Core/Music/TrackService.cs @@ -16,8 +16,7 @@ namespace NzbDrone.Core.Music List GetTracksByAlbum(int albumId); List GetTracksByRelease(int albumReleaseId); List GetTracksByReleases(List albumReleaseIds); - List GetTracksByForeignReleaseId(string foreignReleaseId); - List GetTracksByForeignTrackIds(List ids); + List GetTracksForRefresh(int albumReleaseId, IEnumerable foreignTrackIds); List TracksWithFiles(int artistId); List TracksWithoutFiles(int albumId); List GetTracksByFileId(int trackFileId); @@ -29,7 +28,7 @@ namespace NzbDrone.Core.Music } public class TrackService : ITrackService, - IHandleAsync, + IHandle, IHandle { private readonly ITrackRepository _trackRepository; @@ -74,14 +73,9 @@ namespace NzbDrone.Core.Music return _trackRepository.GetTracksByReleases(albumReleaseIds); } - public List GetTracksByForeignReleaseId(string foreignReleaseId) + public List GetTracksForRefresh(int albumReleaseId, IEnumerable foreignTrackIds) { - return _trackRepository.GetTracksByForeignReleaseId(foreignReleaseId); - } - - public List GetTracksByForeignTrackIds(List ids) - { - return _trackRepository.GetTracksByForeignTrackIds(ids); + return _trackRepository.GetTracksForRefresh(albumReleaseId, foreignTrackIds); } public List TracksWithFiles(int artistId) @@ -124,7 +118,7 @@ namespace NzbDrone.Core.Music _trackRepository.SetFileId(tracks); } - public void HandleAsync(ReleaseDeletedEvent message) + public void Handle(ReleaseDeletedEvent message) { var tracks = GetTracksByRelease(message.Release.Id); _trackRepository.DeleteMany(tracks); diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 656cef105..06a1b6f28 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -141,6 +141,7 @@ + @@ -723,14 +724,19 @@ + + + + + @@ -742,6 +748,7 @@ + Code diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 93b6fbf68..da7476405 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -8,11 +8,6 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation; using NzbDrone.Core.Music; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Languages; -using TagLib; -using TagLib.Id3v2; -using NzbDrone.Common.Serializer; -using Newtonsoft.Json; namespace NzbDrone.Core.Parser { @@ -20,14 +15,6 @@ namespace NzbDrone.Core.Parser { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(Parser)); - private static readonly JsonSerializerSettings SerializerSettings; - - static Parser() - { - SerializerSettings = Json.GetSerializerSettings(); - SerializerSettings.Formatting = Formatting.None; - } - private static readonly Regex[] ReportMusicTitleRegex = new[] { // Track with artist (01 - artist - trackName) @@ -229,34 +216,10 @@ namespace NzbDrone.Core.Parser { var fileInfo = new FileInfo(path); - ParsedTrackInfo result; + ParsedTrackInfo result = null; - if (MediaFiles.MediaFileExtensions.Extensions.Contains(fileInfo.Extension)) - { - try - { - result = ParseAudioTags(path); - } - catch(TagLib.CorruptFileException) - { - Logger.Debug("Caught exception parsing {0}", path); - result = null; - } - } - else - { - result = null; - } - - // TODO: Check if it is common that we might need to fallback to parser to gather details - //var result = ParseMusicTitle(fileInfo.Name); - - - if (result == null) - { - Logger.Debug("Attempting to parse track info using directory and file names. {0}", fileInfo.Directory.Name); - result = ParseMusicTitle(fileInfo.Directory.Name + " " + fileInfo.Name); - } + Logger.Debug("Attempting to parse track info using directory and file names. {0}", fileInfo.Directory.Name); + result = ParseMusicTitle(fileInfo.Directory.Name + " " + fileInfo.Name); if (result == null) { @@ -619,93 +582,6 @@ namespace NzbDrone.Core.Parser return intermediateTitle; } - private static ParsedTrackInfo ParseAudioTags(string path) - { - using(var file = TagLib.File.Create(path)) - { - Logger.Debug("Starting Tag Parse for {0}", file.Name); - - var artist = file.Tag.FirstAlbumArtist; - - if (artist.IsNullOrWhiteSpace()) - { - artist = file.Tag.FirstPerformer; - } - - var artistTitleInfo = new ArtistTitleInfo - { - Title = artist, - Year = (int)file.Tag.Year - }; - - var result = new ParsedTrackInfo - { - Language = Language.English, //TODO Parse from Tag/Mediainfo - AlbumTitle = file.Tag.Album, - ArtistTitle = artist, - ArtistMBId = file.Tag.MusicBrainzArtistId, - AlbumMBId = file.Tag.MusicBrainzReleaseGroupId, - ReleaseMBId = file.Tag.MusicBrainzReleaseId, - // SIC: the recording ID is stored in this field. - // See https://picard.musicbrainz.org/docs/mappings/ - RecordingMBId = file.Tag.MusicBrainzTrackId, - DiscNumber = (int) file.Tag.Disc, - DiscCount = (int) file.Tag.DiscCount, - Duration = file.Properties.Duration, - Year = file.Tag.Year, - Label = file.Tag.Publisher, - TrackNumbers = new [] { (int) file.Tag.Track }, - ArtistTitleInfo = artistTitleInfo, - Title = file.Tag.Title, - CleanTitle = file.Tag.Title?.CleanTrackTitle(), - Country = IsoCountries.Find(file.Tag.MusicBrainzReleaseCountry) - }; - - // custom tags varying by format - if ((file.TagTypesOnDisk & TagTypes.Id3v2) == TagTypes.Id3v2) - { - var tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2); - result.CatalogNumber = UserTextInformationFrame.Get(tag, "CATALOGNUMBER", false)?.Text.ExclusiveOrDefault(); - // this one was invented for beets - result.Disambiguation = UserTextInformationFrame.Get(tag, "MusicBrainz Album Comment", false)?.Text.ExclusiveOrDefault(); - result.TrackMBId = UserTextInformationFrame.Get(tag, "MusicBrainz Release Track Id", false)?.Text.ExclusiveOrDefault(); - } - else if ((file.TagTypesOnDisk & TagTypes.Xiph) == TagTypes.Xiph) - { - var tag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph); - result.CatalogNumber = tag.GetField("CATALOGNUMBER").ExclusiveOrDefault(); - result.Disambiguation = tag.GetField("MUSICBRAINZ_ALBUMCOMMENT").ExclusiveOrDefault(); - result.TrackMBId = tag.GetField("MUSICBRAINZ_RELEASETRACKID").ExclusiveOrDefault(); - } - - Logger.Debug("File Tags Parsed: {0}", JsonConvert.SerializeObject(result, SerializerSettings)); - - foreach (ICodec codec in file.Properties.Codecs) - { - IAudioCodec acodec = codec as IAudioCodec; - - if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None) - { - Logger.Debug("Audio Properties : " + acodec.Description + ", Bitrate: " + acodec.AudioBitrate + ", Sample Size: " + - file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels); - - result.Quality = QualityParser.ParseQuality(file.Name, acodec.Description, acodec.AudioBitrate, file.Properties.BitsPerSample); - Logger.Debug("Quality parsed: {0}", result.Quality); - - result.MediaInfo = new MediaInfoModel { - AudioFormat = acodec.Description, - AudioBitrate = acodec.AudioBitrate, - AudioChannels = acodec.AudioChannels, - AudioBits = file.Properties.BitsPerSample, - AudioSampleRate = acodec.AudioSampleRate - }; - } - } - - return result; - } - } - private static ParsedTrackInfo ParseMatchMusicCollection(MatchCollection matchCollection) { var artistName = matchCollection[0].Groups["artist"].Value./*Removed for cases like Will.I.Am Replace('.', ' ').*/Replace('_', ' '); diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index 84554519a..56a151084 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Runtime.InteropServices.WindowsRuntime; using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Extensions; @@ -14,23 +13,6 @@ namespace NzbDrone.Core.Parser { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(QualityParser)); - private static readonly Regex SourceRegex = new Regex(@"\b(?: - (?BluRay|Blu-Ray|HDDVD|BD)| - (?WEB[-_. ]DL|WEBDL|WebRip|iTunesHD|WebHD|[. ]WEB[. ](?:[xh]26[45]|DD5[. ]1)|\d+0p[. ]WEB[. ])| - (?HDTV)| - (?BDRip)| - (?BRRip)| - (?DVD|DVDRip|NTSC|PAL|xvidvd)| - (?WS[-_. ]DSR|DSR)| - (?PDTV)| - (?SDTV)| - (?TVRip) - )\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); - - private static readonly Regex RawHDRegex = new Regex(@"\b(?RawHD|1080i[-_. ]HDTV|Raw[-_. ]HD|MPEG[-_. ]?2)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex ProperRegex = new Regex(@"\b(?proper|repack|rerip)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -54,7 +36,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex SampleSizeRegex = new Regex(@"\b(?:(?24[ ]bit|24bit|[\[\(].*24bit.*[\]\)]))"); - private static readonly Regex CodecRegex = new Regex(@"\b(?:(?MP3.*VBR|MPEG Version 1 Audio, Layer 3 vbr)|(?MP3|MPEG Version \d+ Audio, Layer 3)|(?flac)|(?wavpack|wv)|(?alac)|(?WMA\d?)|(?WAV|PCM)|(?M4A|AAC|mp4a)|(?OGG|Vorbis))\b|(?monkey's audio|[\[|\(].*ape.*[\]|\)])", + private static readonly Regex CodecRegex = new Regex(@"\b(?:(?MPEG Version \d(.5)? Audio, Layer 1|MP1)|(?MPEG Version \d(.5)? Audio, Layer 2|MP2)|(?MP3.*VBR|MPEG Version \d(.5)? Audio, Layer 3 vbr)|(?MP3|MPEG Version \d(.5)? Audio, Layer 3)|(?flac)|(?wavpack|wv)|(?alac)|(?WMA\d?)|(?WAV|PCM)|(?M4A|M4P|M4B|AAC|mp4a|MPEG-4 Audio(?!.*alac))|(?OGG|OGA|Vorbis))\b|(?monkey's audio|[\[|\(].*ape.*[\]|\)])|(?Opus)", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static QualityModel ParseQuality(string name, string desc, int fileBitrate, int fileSampleSize = 0) @@ -67,6 +49,7 @@ namespace NzbDrone.Core.Parser if (desc.IsNotNullOrWhiteSpace()) { var descCodec = ParseCodec(desc, ""); + Logger.Trace($"Got codec {descCodec}"); result.Quality = FindQuality(descCodec, fileBitrate, fileSampleSize); @@ -83,6 +66,10 @@ namespace NzbDrone.Core.Parser switch(codec) { + case Codec.MP1: + case Codec.MP2: + result.Quality = Quality.Unknown; + break; case Codec.MP3VBR: if (bitrate == BitRate.VBRV0) { result.Quality = Quality.MP3_VBR; } else if (bitrate == BitRate.VBRV2) { result.Quality = Quality.MP3_VBR_V2; } @@ -126,6 +113,7 @@ namespace NzbDrone.Core.Parser result.Quality = Quality.AAC_VBR; break; case Codec.OGG: + case Codec.OPUS: if (bitrate == BitRate.B160) { result.Quality = Quality.VORBIS_Q5; } else if (bitrate == BitRate.B192) { result.Quality = Quality.VORBIS_Q6; } else if (bitrate == BitRate.B224) { result.Quality = Quality.VORBIS_Q7; } @@ -175,6 +163,9 @@ namespace NzbDrone.Core.Parser if (match.Groups["WAV"].Success) { return Codec.WAV; } if (match.Groups["AAC"].Success) { return Codec.AAC; } if (match.Groups["OGG"].Success) { return Codec.OGG; } + if (match.Groups["OPUS"].Success) { return Codec.OPUS; } + if (match.Groups["MP1"].Success) { return Codec.MP1; } + if (match.Groups["MP2"].Success) { return Codec.MP2; } if (match.Groups["MP3VBR"].Success) { return Codec.MP3VBR; } if (match.Groups["MP3CBR"].Success) { return Codec.MP3CBR; } if (match.Groups["WAVPACK"].Success) { return Codec.WAVPACK; } @@ -218,6 +209,9 @@ namespace NzbDrone.Core.Parser { switch (codec) { + case Codec.MP1: + case Codec.MP2: + return Quality.Unknown; case Codec.MP3VBR: return Quality.MP3_VBR; case Codec.MP3CBR: @@ -265,6 +259,14 @@ namespace NzbDrone.Core.Parser if (bitrate == 320) { return Quality.VORBIS_Q9; } if (bitrate == 500) { return Quality.VORBIS_Q10; } return Quality.Unknown; + case Codec.OPUS: + if (bitrate < 130) { return Quality.Unknown; } + if (bitrate < 180) { return Quality.VORBIS_Q5; } + if (bitrate < 205) { return Quality.VORBIS_Q6; } + if (bitrate < 240) { return Quality.VORBIS_Q7; } + if (bitrate < 290) { return Quality.VORBIS_Q8; } + if (bitrate < 410) { return Quality.VORBIS_Q9; } + return Quality.VORBIS_Q10; default: return Quality.Unknown; } @@ -301,6 +303,8 @@ namespace NzbDrone.Core.Parser public enum Codec { + MP1, + MP2, MP3CBR, MP3VBR, FLAC, @@ -311,6 +315,7 @@ namespace NzbDrone.Core.Parser AAC, AACVBR, OGG, + OPUS, WAV, Unknown }