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) {
}
+
+
}
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