New: Write metadata to tags, with UI for previewing changes (#633)

pull/678/head
ta264 5 years ago committed by GitHub
parent 6548f4b1b7
commit 072f772dc8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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 (<Icon name={icons.BAN} size={12} />);
}
return value;
}
function formatChange(oldValue, newValue) {
return (
<div> {formatMissing(oldValue)} <Icon name={icons.ARROW_RIGHT_NO_CIRCLE} size={12} /> {formatMissing(newValue)} </div>
);
}
function HistoryDetails(props) {
const {
eventType,
@ -259,6 +274,37 @@ function HistoryDetails(props) {
);
}
if (eventType === 'trackFileRetagged') {
const {
diff,
tagsScrubbed
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Path"
data={sourceTitle}
/>
{
JSON.parse(diff).map(({ field, oldValue, newValue }) => {
return (
<DescriptionListItem
key={field}
title={field}
data={formatChange(oldValue, newValue)}
/>
);
})
}
<DescriptionListItem
title="Existing tags scrubbed"
data={tagsScrubbed === 'True' ? <Icon name={icons.CHECK} /> : <Icon name={icons.REMOVE} />}
/>
</DescriptionList>
);
}
if (eventType === 'albumImportIncomplete') {
const {
statusMessages

@ -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':

@ -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':

@ -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}
/>
<PageToolbarButton
label="Preview Retag"
iconName={icons.RETAG}
onPress={this.onRetagPress}
/>
<PageToolbarButton
label="Manage Tracks"
iconName={icons.TRACK_FILE}
@ -495,6 +512,13 @@ class AlbumDetails extends Component {
onModalClose={this.onOrganizeModalClose}
/>
<RetagPreviewModalConnector
isOpen={isRetagModalOpen}
artistId={artist.id}
albumId={id}
onModalClose={this.onRetagModalClose}
/>
<TrackFileEditorModal
isOpen={isManageTracksOpen}
artistId={artist.id}

@ -23,6 +23,7 @@ import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import ArtistPoster from 'Artist/ArtistPoster';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
@ -66,6 +67,7 @@ class ArtistDetails extends Component {
this.state = {
isOrganizeModalOpen: false,
isRetagModalOpen: false,
isManageTracksOpen: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false,
@ -89,6 +91,14 @@ class ArtistDetails extends Component {
this.setState({ isOrganizeModalOpen: false });
}
onRetagPress = () => {
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}
/>
<PageToolbarButton
label="Preview Retag"
iconName={icons.RETAG}
onPress={this.onRetagPress}
/>
<PageToolbarButton
label="Manage Tracks"
iconName={icons.TRACK_FILE}
@ -600,6 +617,12 @@ class ArtistDetails extends Component {
onModalClose={this.onOrganizeModalClose}
/>
<RetagPreviewModalConnector
isOpen={isRetagModalOpen}
artistId={id}
onModalClose={this.onRetagModalClose}
/>
<TrackFileEditorModal
isOpen={isManageTracksOpen}
artistId={id}

@ -14,6 +14,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import NoArtist from 'Artist/NoArtist';
import OrganizeArtistModal from './Organize/OrganizeArtistModal';
import RetagArtistModal from './AudioTags/RetagArtistModal';
import ArtistEditorRowConnector from './ArtistEditorRowConnector';
import ArtistEditorFooter from './ArtistEditorFooter';
import ArtistEditorFilterModalConnector from './ArtistEditorFilterModalConnector';
@ -84,6 +85,7 @@ class ArtistEditor extends Component {
lastToggled: null,
selectedState: {},
isOrganizingArtistModalOpen: false,
isRetaggingArtistModalOpen: false,
columns: getColumns(props.showLanguageProfile, props.showMetadataProfile)
};
}
@ -142,6 +144,18 @@ class ArtistEditor extends Component {
}
}
onRetagArtistPress = () => {
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}
/>
<OrganizeArtistModal
@ -261,6 +278,13 @@ class ArtistEditor extends Component {
artistIds={selectedArtistIds}
onModalClose={this.onOrganizeArtistModalClose}
/>
<RetagArtistModal
isOpen={this.state.isRetaggingArtistModalOpen}
artistIds={selectedArtistIds}
onModalClose={this.onRetagArtistModalClose}
/>
</PageContent>
);
}
@ -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,

@ -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

@ -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
</SpinnerButton>
<SpinnerButton
className={styles.organizeSelectedButton}
kind={kinds.WARNING}
isSpinning={isRetaggingArtist}
isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
onPress={onRetagArtistPress}
>
Write Metadata Tags
</SpinnerButton>
<SpinnerButton
className={styles.tagsButton}
isSpinning={isSaving && savingTags}
isDisabled={!selectedCount || isOrganizingArtist}
isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
onPress={this.onTagsPress}
>
Set Tags
Set Lidarr Tags
</SpinnerButton>
</div>
@ -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;

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<RetagArtistModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
RetagArtistModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RetagArtistModal;

@ -0,0 +1,8 @@
.retagIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Retag Selected Artist
</ModalHeader>
<ModalBody>
<Alert>
Tip: To preview the tags that will be written... select "Cancel" then click any artist name and use the
<Icon
className={styles.retagIcon}
name={icons.RETAG}
/>
</Alert>
<div className={styles.message}>
Are you sure you want to re-tag all files in the {artistNames.length} selected artist?
</div>
<ul>
{
artistNames.map((artistName) => {
return (
<li key={artistName}>
{artistName}
</li>
);
})
}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={onRetagArtistPress}
>
Retag
</Button>
</ModalFooter>
</ModalContent>
);
}
RetagArtistModalContent.propTypes = {
artistNames: PropTypes.arrayOf(PropTypes.string).isRequired,
onModalClose: PropTypes.func.isRequired,
onRetagArtistPress: PropTypes.func.isRequired
};
export default RetagArtistModalContent;

@ -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 (
<RetagArtistModalContent
{...this.props}
onRetagArtistPress={this.onRetagArtistPress}
/>
);
}
}
RetagArtistModalContentConnector.propTypes = {
artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onModalClose: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(RetagArtistModalContentConnector);

@ -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';

@ -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;

@ -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 &&
<div>
{
renameTracks ?
<div>Success! My work is done, no files to rename.</div> :
<div>Renaming is disabled, nothing to rename</div>
}
</div>
<div>Success! My work is done, no files to rename.</div>
}
{
@ -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

@ -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;

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
{
isOpen &&
<RetagPreviewModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
}
</Modal>
);
}
RetagPreviewModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RetagPreviewModal;

@ -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 (
<RetagPreviewModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
RetagPreviewModalConnector.propTypes = {
clearRetagPreview: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(RetagPreviewModalConnector);

@ -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;
}

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Write Metadata Tags
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && error &&
<div>Error loading previews</div>
}
{
!isFetching && ((isPopulated && !items.length)) &&
<div>Success! My work is done, no files to retag.</div>
}
{
!isFetching && isPopulated && !!items.length &&
<div>
<Alert>
<div>
All paths are relative to:
<span className={styles.path}>
{path}
</span>
</div>
<div>
MusicBrainz identifiers will also be added to the files; these are not shown below.
</div>
</Alert>
<div className={styles.previews}>
{
items.map((item) => {
return (
<RetagPreviewRow
key={item.trackFileId}
id={item.trackFileId}
path={item.relativePath}
changes={item.changes}
isSelected={selectedState[item.trackFileId]}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</div>
</div>
}
</ModalBody>
<ModalFooter>
{
isPopulated && !!items.length &&
<CheckInput
className={styles.selectAllInput}
containerClassName={styles.selectAllInputContainer}
name="selectAll"
value={selectAllValue}
onChange={this.onSelectAllChange}
/>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<Button
kind={kinds.PRIMARY}
onPress={this.onRetagPress}
>
Retag
</Button>
</ModalFooter>
</ModalContent>
);
}
}
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;

@ -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 (
<RetagPreviewModalContent
{...this.props}
onRetagPress={this.onRetagPress}
/>
);
}
}
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);

@ -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;
}

@ -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 (<Icon name={icons.BAN} size={12} />);
}
return value;
}
function formatChange(oldValue, newValue) {
return (
<div> {formatMissing(oldValue)} <Icon name={icons.ARROW_RIGHT_NO_CIRCLE} size={12} /> {formatMissing(newValue)} </div>
);
}
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 (
<div className={styles.row}>
<CheckInput
containerClassName={styles.selectedContainer}
name={id.toString()}
value={isSelected}
onChange={this.onSelectedChange}
/>
<div className={styles.column}>
<span className={styles.path}>
{path}
</span>
<DescriptionList>
{
changes.map(({ field, oldValue, newValue }) => {
return (
<DescriptionListItem
key={field}
title={field}
data={formatChange(oldValue, newValue)}
/>
);
})
}
</DescriptionList>
</div>
</div>
);
}
}
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;

@ -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) {
</FormGroup>
</FieldSet>
}
<FieldSet legend="Write Metadata to Audio Files">
<FormGroup>
<FormLabel>Tag Audio Files with Metadata</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="writeAudioTags"
helpTextWarning="Selecting 'All files' will alter existing files when they are imported."
helpLink="https://github.com/Lidarr/Lidarr/wiki/Write-Tags"
values={writeAudioTagOptions}
onChange={onInputChange}
{...settings.writeAudioTags}
/>
</FormGroup>
<FormGroup>
<FormLabel>Scrub Existing Tags</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="scrubAudioTags"
helpText="Remove existing tags from files, leaving only those added by Lidarr."
onChange={onInputChange}
{...settings.scrubAudioTags}
/>
</FormGroup>
</FieldSet>
</Form>
}
</div>

@ -55,11 +55,11 @@ class MetadataSettings extends Component {
/>
<PageContentBodyConnector>
<MetadatasConnector />
<MetadataProviderConnector
onChildMounted={this.onChildMounted}
onChildStateChange={this.onChildStateChange}
/>
<MetadatasConnector />
</PageContentBodyConnector>
</PageContent>
);

@ -173,6 +173,17 @@ export const defaultState = {
type: filterTypes.EQUAL
}
]
},
{
key: 'retagged',
label: 'Retagged',
filters: [
{
key: 'eventType',
value: '9',
type: filterTypes.EQUAL
}
]
}
]

@ -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,

@ -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);

@ -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,
};
}
}

@ -160,6 +160,8 @@
<Compile Include="Tracks\TrackResource.cs" />
<Compile Include="Tracks\RenameTrackModule.cs" />
<Compile Include="Tracks\RenameTrackResource.cs" />
<Compile Include="Tracks\RetagTrackModule.cs" />
<Compile Include="Tracks\RetagTrackResource.cs" />
<Compile Include="Health\HealthModule.cs" />
<Compile Include="Health\HealthResource.cs" />
<Compile Include="History\HistoryModule.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<RetagTrackResource>
{
private readonly IAudioTagService _audioTagService;
public RetagTrackModule(IAudioTagService audioTagService)
: base("retag")
{
_audioTagService = audioTagService;
GetResourceAll = GetTracks;
}
private List<RetagTrackResource> 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");
}
}
}
}

@ -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<int> TrackNumbers { get; set; }
public int TrackFileId { get; set; }
public string RelativePath { get; set; }
public List<TagDifference> 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<RetagTrackResource> ToResource(this IEnumerable<NzbDrone.Core.MediaFiles.RetagTrackFilePreview> models)
{
return models.Select(ToResource).ToList();
}
}
}

@ -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/

@ -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<AudioTagService>
{
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<string, string[]> SkipPropertiesByFile = new Dictionary<string, string[]> {
{ "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<IConfigService>()
.Setup(x => x.WriteAudioTags)
.Returns(WriteAudioTagsType.Sync);
// have to manually set the arrays of string parameters and integers to values > 1
testTags = Builder<AudioTag>.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();
}
}
}

@ -146,8 +146,8 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification
var localAlbumRelease = new LocalAlbumRelease(localTracks);
Mocker.GetMock<IReleaseService>()
.Setup(x => x.GetReleasesByForeignReleaseId(new List<string>{ "xxx" }))
.Returns(new List<AlbumRelease> { release });
.Setup(x => x.GetReleaseByForeignReleaseId("xxx"))
.Returns(release);
Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null).ShouldBeEquivalentTo(new List<AlbumRelease> { release });
}

@ -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<IReleaseService>()
.Setup(s => s.GetReleasesByAlbum(album1.Id))
.Setup(s => s.GetReleasesForRefresh(album1.Id, It.IsAny<IEnumerable<string>>()))
.Returns(new List<AlbumRelease> { release });
Mocker.GetMock<IReleaseService>()
.Setup(s => s.GetReleasesByForeignReleaseId(It.IsAny<List<string>>()))
.Returns(new List<AlbumRelease> { release });
Mocker.GetMock<IArtistMetadataRepository>()
.Setup(s => s.FindById(It.IsAny<List<string>>()))
.Returns(new List<ArtistMetadata>());
Mocker.GetMock<IProvideAlbumInfo>()
.Setup(s => s.GetAlbumInfo(It.IsAny<string>()))
.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<IAlbumService>()
.Verify(v => v.UpdateMany(It.IsAny<List<Album>>()), Times.Never());
@ -97,12 +99,56 @@ namespace NzbDrone.Core.Test.MusicTests
GivenNewAlbumInfo(newAlbumInfo);
Subject.RefreshAlbumInfo(_albums, false);
Subject.RefreshAlbumInfo(_albums, false, false);
Mocker.GetMock<IAlbumService>()
.Verify(v => v.UpdateMany(It.Is<List<Album>>(s => s.First().ForeignAlbumId == newAlbumInfo.ForeignAlbumId)));
ExceptionVerification.ExpectedWarns(1);
}
[Test]
public void two_equivalent_releases_should_be_equal()
{
var release = Builder<AlbumRelease>.CreateNew().Build();
var release2 = Builder<AlbumRelease>.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<Track>.CreateNew().Build();
var track2 = Builder<Track>.CreateNew().Build();
ReferenceEquals(track, track2).Should().BeFalse();
track.Equals(track2).Should().BeTrue();
}
[Test]
public void two_equivalent_metadata_should_be_equal()
{
var meta = Builder<ArtistMetadata>.CreateNew().Build();
var meta2 = Builder<ArtistMetadata>.CreateNew().Build();
ReferenceEquals(meta, meta2).Should().BeFalse();
meta.Equals(meta2).Should().BeTrue();
}
[Test]
public void should_remove_items_from_list()
{
var releases = Builder<AlbumRelease>.CreateListOfSize(2).Build();
var release = releases[0];
releases.Remove(release);
releases.Should().HaveCount(1);
}
}
}

@ -46,13 +46,9 @@ namespace NzbDrone.Core.Test.MusicTests
.Returns(_artist);
Mocker.GetMock<IAlbumService>()
.Setup(s => s.GetAlbumsByArtist(It.IsAny<int>()))
.Setup(s => s.GetAlbumsForRefresh(It.IsAny<int>(), It.IsAny<IEnumerable<string>>()))
.Returns(new List<Album>());
Mocker.GetMock<IAlbumService>()
.Setup(s => s.FindById(It.IsAny<List<string>>()))
.Returns(new List<Album>());
Mocker.GetMock<IProvideArtistInfo>()
.Setup(s => s.GetArtistInfo(It.IsAny<string>(), It.IsAny<int>()))
.Callback(() => { throw new ArtistNotFoundException(_artist.ForeignArtistId); });

@ -87,6 +87,10 @@
<Reference Include="Prowlin, Version=0.9.4456.26422, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll</HintPath>
</Reference>
<Reference Include="taglib-sharp, Version=2.2.0.0, Culture=neutral, PublicKeyToken=db62eba44689b5b0, processorArchitecture=MSIL">
<HintPath>..\packages\TagLibSharp.2.2.0-beta\lib\netstandard2.0\taglib-sharp.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="System" />
<Reference Include="System.Data" />
<Reference Include="System.Drawing" />
@ -281,6 +285,7 @@
<Compile Include="MediaCoverTests\CoverExistsSpecificationFixture.cs" />
<Compile Include="MediaCoverTests\ImageResizerFixture.cs" />
<Compile Include="MediaCoverTests\MediaCoverServiceFixture.cs" />
<Compile Include="MediaFiles\AudioTagServiceFixture.cs" />
<Compile Include="MediaFiles\DiskScanServiceTests\ScanFixture.cs" />
<Compile Include="MediaFiles\DownloadedAlbumsCommandServiceFixture.cs" />
<Compile Include="MediaFiles\DownloadedTracksImportServiceFixture.cs" />
@ -502,7 +507,7 @@
<Content Include="Files\LongOverview.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Files\Media\H264_sample.mp4">
<Content Include="Files\Media\nin.mp2">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Files\Media\nin.mp3">
@ -511,6 +516,18 @@
<Content Include="Files\Media\nin.flac">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Files\Media\nin.m4a">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Files\Media\nin.wma">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Files\Media\nin.ape">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Files\Media\nin.opus">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Files\Nzbget\JsonError.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>

@ -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);

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="AutoMoq" version="1.8.1.0" targetFramework="net461" />
<package id="CommonServiceLocator" version="1.3" targetFramework="net461" />
@ -14,4 +14,5 @@
<package id="NUnit" version="3.11.0" targetFramework="net461" />
<package id="Prowlin" version="0.9.4456.26422" targetFramework="net461" />
<package id="Unity" version="2.1.505.2" targetFramework="net461" />
</packages>
<package id="TagLibSharp" version="2.2.0-beta" targetFramework="net461" />
</packages>

@ -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); }

@ -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; }

@ -0,0 +1,10 @@
namespace NzbDrone.Core.Configuration
{
public enum WriteAudioTagsType
{
No,
NewFiles,
AllFiles,
Sync
}
}

@ -43,6 +43,7 @@ namespace NzbDrone.Core.History
TrackFileDeleted = 5,
TrackFileRenamed = 6,
AlbumImportIncomplete = 7,
DownloadImported = 8
DownloadImported = 8,
TrackFileRetagged = 9
}
}

@ -38,6 +38,7 @@ namespace NzbDrone.Core.History
IHandle<DownloadCompletedEvent>,
IHandle<TrackFileDeletedEvent>,
IHandle<TrackFileRenamedEvent>,
IHandle<TrackFileRetaggedEvent>,
IHandle<ArtistDeletedEvent>
{
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);

@ -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<string, Tuple<string, string>> Diff(AudioTag other)
{
var output = new Dictionary<string, Tuple<string, string>>();
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
};
}
}
}

@ -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<Track> tracks);
void RemoveMusicBrainzTags(IEnumerable<Album> album);
void RemoveMusicBrainzTags(IEnumerable<AlbumRelease> albumRelease);
void RemoveMusicBrainzTags(IEnumerable<Track> tracks);
void RemoveMusicBrainzTags(TrackFile trackfile);
List<RetagTrackFilePreview> GetRetagPreviewsByArtist(int artistId);
List<RetagTrackFilePreview> GetRetagPreviewsByAlbum(int artistId);
}
public class AudioTagService : IAudioTagService,
IExecute<RetagArtistCommand>,
IExecute<RetagFilesCommand>
{
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<Track> 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<Album> 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<AlbumRelease> 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<Track> 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<RetagTrackFilePreview> GetRetagPreviewsByArtist(int artistId)
{
var files = _mediaFileService.GetFilesByArtist(artistId);
return GetPreviews(files).ToList();
}
public List<RetagTrackFilePreview> GetRetagPreviewsByAlbum(int albumId)
{
var files = _mediaFileService.GetFilesByAlbum(albumId);
return GetPreviews(files).ToList();
}
private IEnumerable<RetagTrackFilePreview> GetPreviews(List<TrackFile> 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);
}
}
}
}

@ -0,0 +1,18 @@
using System.Collections.Generic;
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.MediaFiles.Commands
{
public class RetagArtistCommand : Command
{
public List<int> ArtistIds { get; set; }
public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
public RetagArtistCommand()
{
ArtistIds = new List<int>();
}
}
}

@ -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<int> Files { get; set; }
public override bool SendUpdatesToClient => true;
public override bool RequiresDiskAccess => true;
public RetagFilesCommand()
{
}
public RetagFilesCommand(int artistId, List<int> files)
{
ArtistId = artistId;
Files = files;
}
}
}

@ -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<string, Tuple<string, string>> Diff { get; private set; }
public bool Scrubbed { get; private set; }
public TrackFileRetaggedEvent(Artist artist,
TrackFile trackFile,
Dictionary<string, Tuple<string, string>> diff,
bool scrubbed)
{
Artist = artist;
TrackFile = trackFile;
Diff = diff;
Scrubbed = scrubbed;
}
}
}

@ -13,9 +13,14 @@ namespace NzbDrone.Core.MediaFiles
{
_fileExtensions = new Dictionary<string, Quality>(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 },

@ -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<TrackFile> GetFilesByArtist(int artistId);
List<TrackFile> GetFilesByAlbum(int albumId);
List<TrackFile> GetFilesByRelease(int releaseId);
List<TrackFile> 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<TrackFile> Query =>
DataMapper.Query<TrackFile>()
.Join<TrackFile, Track>(JoinType.Inner, t => t.Tracks, (t, x) => t.Id == x.TrackFileId)
.Join<TrackFile, Album>(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id)
.Join<TrackFile, Artist>(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId)
.Join<Artist, ArtistMetadata>(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id);
.Join<TrackFile, Track>(JoinType.Left, t => t.Tracks, (t, x) => t.Id == x.TrackFileId)
.Join<TrackFile, Album>(JoinType.Left, t => t.Album, (t, a) => t.AlbumId == a.Id)
.Join<TrackFile, Artist>(JoinType.Left, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId)
.Join<Artist, ArtistMetadata>(JoinType.Left, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id);
public List<TrackFile> GetFilesByArtist(int artistId)
{
@ -47,6 +46,14 @@ namespace NzbDrone.Core.MediaFiles
.Where(f => f.AlbumId == albumId)
.ToList();
}
public List<TrackFile> GetFilesByRelease(int releaseId)
{
return Query
.Where<Track>(x => x.AlbumReleaseId == releaseId)
.ToList();
}
public List<TrackFile> GetFilesWithRelativePath(int artistId, string relativePath)
{

@ -20,6 +20,7 @@ namespace NzbDrone.Core.MediaFiles
void Delete(TrackFile trackFile, DeleteMediaFileReason reason);
List<TrackFile> GetFilesByArtist(int artistId);
List<TrackFile> GetFilesByAlbum(int albumId);
List<TrackFile> GetFilesByRelease(int releaseId);
List<string> FilterExistingFiles(List<string> files, Artist artist);
TrackFile Get(int id);
List<TrackFile> Get(IEnumerable<int> ids);
@ -115,6 +116,11 @@ namespace NzbDrone.Core.MediaFiles
return _mediaFileRepository.GetFilesByAlbum(albumId);
}
public List<TrackFile> GetFilesByRelease(int releaseId)
{
return _mediaFileRepository.GetFilesByRelease(releaseId);
}
public void UpdateMediaInfo(List<TrackFile> trackFiles)
{
_mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo);

@ -33,6 +33,8 @@ namespace NzbDrone.Core.MediaFiles
}
public static readonly Dictionary<Codec, string> CodecNames = new Dictionary<Codec, string> {
{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"}

@ -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<int> TrackNumbers { get; set; }
public int TrackFileId { get; set; }
public string RelativePath { get; set; }
public Dictionary<string, Tuple<string, string>> Changes { get; set; }
}
}

@ -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<AlbumRelease> 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<AlbumRelease> { tagRelease };
}
}
if (release != null)

@ -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);

@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications;
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _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<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications,
IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> 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

@ -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;
}
}

@ -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);
}

@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music
{
List<Album> GetAlbums(int artistId);
List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId);
List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds);
Album FindByTitle(int artistMetadataId, string title);
Album FindById(string foreignId);
List<Album> FindById(List<string> foreignIds);
PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec);
PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, List<LanguagesBelowCutoff> languagesBelowCutoff);
List<Album> 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<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> 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<Album> FindById(List<string> 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<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec)

@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music
List<Album> GetAlbums(IEnumerable<int> albumIds);
List<Album> GetAlbumsByArtist(int artistId);
List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId);
List<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> foreignIds);
Album AddAlbum(Album newAlbum, string albumArtistId);
Album FindById(string foreignId);
List<Album> FindById(List<string> foreignIds);
Album FindByTitle(int artistId, string title);
Album FindByTitleInexact(int artistId, string title);
List<Album> GetCandidates(int artistId, string title);
@ -41,7 +41,7 @@ namespace NzbDrone.Core.Music
}
public class AlbumService : IAlbumService,
IHandleAsync<ArtistDeletedEvent>
IHandle<ArtistDeletedEvent>
{
private readonly IAlbumRepository _albumRepository;
private readonly IReleaseRepository _releaseRepository;
@ -96,11 +96,6 @@ namespace NzbDrone.Core.Music
return _albumRepository.FindById(lidarrId);
}
public List<Album> FindById(List<string> 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<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> 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);

@ -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<ArtistMetadata>
{
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;
}
}
}
}

@ -15,6 +15,7 @@ namespace NzbDrone.Core.Music
Artist Upsert(Artist artist);
void UpdateMany(List<Artist> artists);
ArtistMetadata FindById(string ArtistId);
List<ArtistMetadata> FindById(List<string> foreignIds);
void UpsertMany(List<ArtistMetadata> artists);
void UpsertMany(List<Artist> artists);
}
@ -87,6 +88,11 @@ namespace NzbDrone.Core.Music
return Query.Where(a => a.ForeignArtistId == artistId).SingleOrDefault();
}
public List<ArtistMetadata> FindById(List<string> foreignIds)
{
return Query.Where($"[ForeignArtistId] IN ('{string.Join("','", foreignIds)}')").ToList();
}
public void UpsertMany(List<ArtistMetadata> artists)
{
foreach (var artist in artists)

@ -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<Album> albums, bool forceAlbumRefresh);
void RefreshAlbumInfo(Album album, bool forceUpdateFileTags);
void RefreshAlbumInfo(List<Album> albums, bool forceAlbumRefresh, bool forceUpdateFileTags);
}
public class RefreshAlbumService : IRefreshAlbumService, IExecute<RefreshAlbumCommand>
@ -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<Album> albums, bool forceAlbumRefresh)
public void RefreshAlbumInfo(List<Album> 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<ArtistMetadata>();
var addMetadataList = new List<ArtistMetadata>();
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<AlbumRelease>();
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<AlbumRelease>();
var updateReleaseList = new List<AlbumRelease>();
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<AlbumRelease> { 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>{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));
}

@ -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<Album>();
var updateAlbumsList = new List<Album>();
@ -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));

@ -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<Track>();
var newList = new List<Track>();
var upToDateList = new List<Track>();
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<Track> existingTracks)
{
var result = existingTracks.FirstOrDefault(e => e.ForeignTrackId == track.ForeignTrackId && e.TrackNumber == track.TrackNumber);
return result;
}
private IEnumerable<Track> OrderTracks(List<Track> tracks)
{
return tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber);
}
}
}

@ -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<AlbumRelease>
{
// 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;
}
}
}
}

@ -9,10 +9,11 @@ namespace NzbDrone.Core.Music
{
public interface IReleaseRepository : IBasicRepository<AlbumRelease>
{
AlbumRelease FindByForeignReleaseId(string foreignReleaseId);
List<AlbumRelease> FindByAlbum(int id);
List<AlbumRelease> FindByRecordingId(List<string> recordingIds);
List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds);
List<AlbumRelease> SetMonitored(AlbumRelease release);
List<AlbumRelease> FindByForeignReleaseId(List<string> foreignReleaseIds);
}
public class ReleaseRepository : BasicRepository<AlbumRelease>, IReleaseRepository
@ -22,23 +23,29 @@ namespace NzbDrone.Core.Music
{
}
public List<AlbumRelease> 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<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id)
.Join<Album, ArtistMetadata>(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id)
.Where<AlbumRelease>(r => r.AlbumId == id)
.Where(x => x.ForeignReleaseId == foreignReleaseId)
.SingleOrDefault();
}
public List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds)
{
return Query
.Where(r => r.AlbumId == albumId)
.OrWhere($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')")
.ToList();
}
public List<AlbumRelease> FindByForeignReleaseId(List<string> foreignReleaseIds)
public List<AlbumRelease> FindByAlbum(int id)
{
// populate the albums and artist metadata also
// this hopefully speeds up the track matching a lot
return Query
.Join<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id)
.Join<Album, ArtistMetadata>(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id)
.Where($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')")
.Where<AlbumRelease>(r => r.AlbumId == id)
.ToList();
}

@ -7,17 +7,18 @@ namespace NzbDrone.Core.Music
public interface IReleaseService
{
AlbumRelease GetRelease(int id);
AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId);
void InsertMany(List<AlbumRelease> releases);
void UpdateMany(List<AlbumRelease> releases);
void DeleteMany(List<AlbumRelease> releases);
List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds);
List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId);
List<AlbumRelease> GetReleasesByForeignReleaseId(List<string> foreignReleaseIds);
List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds);
List<AlbumRelease> SetMonitored(AlbumRelease release);
}
public class ReleaseService : IReleaseService,
IHandleAsync<AlbumDeletedEvent>
IHandle<AlbumDeletedEvent>
{
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<AlbumRelease> releases)
{
_releaseRepository.InsertMany(releases);
@ -53,16 +59,16 @@ namespace NzbDrone.Core.Music
}
}
public List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId)
public List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds)
{
return _releaseRepository.FindByAlbum(releaseGroupId);
return _releaseRepository.GetReleasesForRefresh(albumId, foreignReleaseIds);
}
public List<AlbumRelease> GetReleasesByForeignReleaseId(List<string> foreignReleaseIds)
public List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId)
{
return _releaseRepository.FindByForeignReleaseId(foreignReleaseIds);
return _releaseRepository.FindByAlbum(releaseGroupId);
}
public List<AlbumRelease> GetReleasesByRecordingIds(List<string> 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);

@ -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<Track>
{
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;
}
}
}
}

@ -11,8 +11,7 @@ namespace NzbDrone.Core.Music
List<Track> GetTracksByAlbum(int albumId);
List<Track> GetTracksByRelease(int albumReleaseId);
List<Track> GetTracksByReleases(List<int> albumReleaseId);
List<Track> GetTracksByForeignReleaseId(string foreignReleaseId);
List<Track> GetTracksByForeignTrackIds(List<string> foreignTrackId);
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
List<Track> GetTracksByFileId(int fileId);
List<Track> TracksWithFiles(int artistId);
List<Track> TracksWithoutFiles(int albumId);
@ -73,25 +72,12 @@ namespace NzbDrone.Core.Music
.ToList();
}
public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId)
public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> 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<Track> GetTracksByForeignTrackIds(List<string> 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<Track> GetTracksByFileId(int fileId)

@ -16,8 +16,7 @@ namespace NzbDrone.Core.Music
List<Track> GetTracksByAlbum(int albumId);
List<Track> GetTracksByRelease(int albumReleaseId);
List<Track> GetTracksByReleases(List<int> albumReleaseIds);
List<Track> GetTracksByForeignReleaseId(string foreignReleaseId);
List<Track> GetTracksByForeignTrackIds(List<string> ids);
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
List<Track> TracksWithFiles(int artistId);
List<Track> TracksWithoutFiles(int albumId);
List<Track> GetTracksByFileId(int trackFileId);
@ -29,7 +28,7 @@ namespace NzbDrone.Core.Music
}
public class TrackService : ITrackService,
IHandleAsync<ReleaseDeletedEvent>,
IHandle<ReleaseDeletedEvent>,
IHandle<TrackFileDeletedEvent>
{
private readonly ITrackRepository _trackRepository;
@ -74,14 +73,9 @@ namespace NzbDrone.Core.Music
return _trackRepository.GetTracksByReleases(albumReleaseIds);
}
public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId)
public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds)
{
return _trackRepository.GetTracksByForeignReleaseId(foreignReleaseId);
}
public List<Track> GetTracksByForeignTrackIds(List<string> ids)
{
return _trackRepository.GetTracksByForeignTrackIds(ids);
return _trackRepository.GetTracksForRefresh(albumReleaseId, foreignTrackIds);
}
public List<Track> 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);

@ -141,6 +141,7 @@
<Compile Include="Configuration\InvalidConfigFileException.cs" />
<Compile Include="Configuration\RescanAfterRefreshType.cs" />
<Compile Include="Configuration\AllowFingerprinting.cs" />
<Compile Include="Configuration\WriteAudioTagsType.cs" />
<Compile Include="Configuration\ResetApiKeyCommand.cs" />
<Compile Include="CustomFilters\CustomFilter.cs" />
<Compile Include="CustomFilters\CustomFilterRepository.cs" />
@ -723,14 +724,19 @@
<Compile Include="MediaFiles\Commands\CleanUpRecycleBinCommand.cs" />
<Compile Include="MediaFiles\Commands\DownloadedAlbumsScanCommand.cs" />
<Compile Include="MediaFiles\Commands\RenameArtistCommand.cs" />
<Compile Include="MediaFiles\Commands\RetagArtistCommand.cs" />
<Compile Include="MediaFiles\Events\AlbumImportedEvent.cs" />
<Compile Include="MediaFiles\Events\AlbumImportIncompleteEvent.cs" />
<Compile Include="MediaFiles\Events\TrackFileRenamedEvent.cs" />
<Compile Include="MediaFiles\Events\TrackFileRetaggedEvent.cs" />
<Compile Include="MediaFiles\Events\TrackFolderCreatedEvent.cs" />
<Compile Include="MediaFiles\Events\TrackImportFailedEvent.cs" />
<Compile Include="MediaFiles\AudioTag.cs" />
<Compile Include="MediaFiles\AudioTagService.cs" />
<Compile Include="MediaFiles\MediaFileDeletionService.cs" />
<Compile Include="MediaFiles\MediaInfoFormatter.cs" />
<Compile Include="MediaFiles\RenameTrackFilePreview.cs" />
<Compile Include="MediaFiles\RetagTrackFilePreview.cs" />
<Compile Include="MediaFiles\RenameTrackFileService.cs" />
<Compile Include="MediaFiles\TrackFileMovingService.cs" />
<Compile Include="MediaFiles\TrackFileMoveResult.cs" />
@ -742,6 +748,7 @@
<Compile Include="MediaFiles\TrackImport\Aggregation\AggregationService.cs" />
<Compile Include="MediaFiles\TrackImport\Aggregation\AggregationFailedException.cs" />
<Compile Include="MediaFiles\Commands\RenameFilesCommand.cs" />
<Compile Include="MediaFiles\Commands\RetagFilesCommand.cs" />
<Compile Include="MediaFiles\DeleteMediaFileReason.cs" />
<Compile Include="MediaFiles\DiskScanService.cs">
<SubType>Code</SubType>

@ -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('_', ' ');

@ -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>BluRay|Blu-Ray|HDDVD|BD)|
(?<webdl>WEB[-_. ]DL|WEBDL|WebRip|iTunesHD|WebHD|[. ]WEB[. ](?:[xh]26[45]|DD5[. ]1)|\d+0p[. ]WEB[. ])|
(?<hdtv>HDTV)|
(?<bdrip>BDRip)|
(?<brrip>BRRip)|
(?<dvd>DVD|DVDRip|NTSC|PAL|xvidvd)|
(?<dsr>WS[-_. ]DSR|DSR)|
(?<pdtv>PDTV)|
(?<sdtv>SDTV)|
(?<tvrip>TVRip)
)\b",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
private static readonly Regex RawHDRegex = new Regex(@"\b(?<rawhd>RawHD|1080i[-_. ]HDTV|Raw[-_. ]HD|MPEG[-_. ]?2)\b",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex ProperRegex = new Regex(@"\b(?<proper>proper|repack|rerip)\b",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
@ -54,7 +36,7 @@ namespace NzbDrone.Core.Parser
private static readonly Regex SampleSizeRegex = new Regex(@"\b(?:(?<S24>24[ ]bit|24bit|[\[\(].*24bit.*[\]\)]))");
private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<MP3VBR>MP3.*VBR|MPEG Version 1 Audio, Layer 3 vbr)|(?<MP3CBR>MP3|MPEG Version \d+ Audio, Layer 3)|(?<FLAC>flac)|(?<WAVPACK>wavpack|wv)|(?<ALAC>alac)|(?<WMA>WMA\d?)|(?<WAV>WAV|PCM)|(?<AAC>M4A|AAC|mp4a)|(?<OGG>OGG|Vorbis))\b|(?<APE>monkey's audio|[\[|\(].*ape.*[\]|\)])",
private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<MP1>MPEG Version \d(.5)? Audio, Layer 1|MP1)|(?<MP2>MPEG Version \d(.5)? Audio, Layer 2|MP2)|(?<MP3VBR>MP3.*VBR|MPEG Version \d(.5)? Audio, Layer 3 vbr)|(?<MP3CBR>MP3|MPEG Version \d(.5)? Audio, Layer 3)|(?<FLAC>flac)|(?<WAVPACK>wavpack|wv)|(?<ALAC>alac)|(?<WMA>WMA\d?)|(?<WAV>WAV|PCM)|(?<AAC>M4A|M4P|M4B|AAC|mp4a|MPEG-4 Audio(?!.*alac))|(?<OGG>OGG|OGA|Vorbis))\b|(?<APE>monkey's audio|[\[|\(].*ape.*[\]|\)])|(?<OPUS>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
}

Loading…
Cancel
Save