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 DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import styles from './HistoryDetails.css'; import styles from './HistoryDetails.css';
function getDetailedList(statusMessages) { 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) { function HistoryDetails(props) {
const { const {
eventType, 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') { if (eventType === 'albumImportIncomplete') {
const { const {
statusMessages statusMessages

@ -23,6 +23,8 @@ function getHeaderTitle(eventType) {
return 'Track File Deleted'; return 'Track File Deleted';
case 'trackFileRenamed': case 'trackFileRenamed':
return 'Track File Renamed'; return 'Track File Renamed';
case 'trackFileRetagged':
return 'Track File Tags Updated';
case 'albumImportIncomplete': case 'albumImportIncomplete':
return 'Album Import Incomplete'; return 'Album Import Incomplete';
case 'downloadImported': case 'downloadImported':

@ -19,6 +19,8 @@ function getIconName(eventType) {
return icons.DELETE; return icons.DELETE;
case 'trackFileRenamed': case 'trackFileRenamed':
return icons.ORGANIZE; return icons.ORGANIZE;
case 'trackFileRetagged':
return icons.RETAG;
case 'albumImportIncomplete': case 'albumImportIncomplete':
return icons.DOWNLOADED; return icons.DOWNLOADED;
case 'downloadImported': case 'downloadImported':
@ -53,6 +55,8 @@ function getTooltip(eventType, data) {
return 'Track file deleted'; return 'Track file deleted';
case 'trackFileRenamed': case 'trackFileRenamed':
return 'Track file renamed'; return 'Track file renamed';
case 'trackFileRetagged':
return 'Track file tags updated';
case 'albumImportIncomplete': case 'albumImportIncomplete':
return 'Files downloaded but not all could be imported'; return 'Files downloaded but not all could be imported';
case 'downloadImported': case 'downloadImported':

@ -16,6 +16,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import Tooltip from 'Components/Tooltip/Tooltip'; import Tooltip from 'Components/Tooltip/Tooltip';
import AlbumCover from 'Album/AlbumCover'; import AlbumCover from 'Album/AlbumCover';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector'; import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
@ -82,6 +83,7 @@ class AlbumDetails extends Component {
this.state = { this.state = {
isOrganizeModalOpen: false, isOrganizeModalOpen: false,
isRetagModalOpen: false,
isArtistHistoryModalOpen: false, isArtistHistoryModalOpen: false,
isInteractiveSearchModalOpen: false, isInteractiveSearchModalOpen: false,
isManageTracksOpen: false, isManageTracksOpen: false,
@ -103,6 +105,14 @@ class AlbumDetails extends Component {
this.setState({ isOrganizeModalOpen: false }); this.setState({ isOrganizeModalOpen: false });
} }
onRetagPress = () => {
this.setState({ isRetagModalOpen: true });
}
onRetagModalClose = () => {
this.setState({ isRetagModalOpen: false });
}
onEditAlbumPress = () => { onEditAlbumPress = () => {
this.setState({ isEditAlbumModalOpen: true }); this.setState({ isEditAlbumModalOpen: true });
} }
@ -193,6 +203,7 @@ class AlbumDetails extends Component {
const { const {
isOrganizeModalOpen, isOrganizeModalOpen,
isRetagModalOpen,
isArtistHistoryModalOpen, isArtistHistoryModalOpen,
isInteractiveSearchModalOpen, isInteractiveSearchModalOpen,
isEditAlbumModalOpen, isEditAlbumModalOpen,
@ -235,6 +246,12 @@ class AlbumDetails extends Component {
onPress={this.onOrganizePress} onPress={this.onOrganizePress}
/> />
<PageToolbarButton
label="Preview Retag"
iconName={icons.RETAG}
onPress={this.onRetagPress}
/>
<PageToolbarButton <PageToolbarButton
label="Manage Tracks" label="Manage Tracks"
iconName={icons.TRACK_FILE} iconName={icons.TRACK_FILE}
@ -495,6 +512,13 @@ class AlbumDetails extends Component {
onModalClose={this.onOrganizeModalClose} onModalClose={this.onOrganizeModalClose}
/> />
<RetagPreviewModalConnector
isOpen={isRetagModalOpen}
artistId={artist.id}
albumId={id}
onModalClose={this.onRetagModalClose}
/>
<TrackFileEditorModal <TrackFileEditorModal
isOpen={isManageTracksOpen} isOpen={isManageTracksOpen}
artistId={artist.id} artistId={artist.id}

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

@ -14,6 +14,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import NoArtist from 'Artist/NoArtist'; import NoArtist from 'Artist/NoArtist';
import OrganizeArtistModal from './Organize/OrganizeArtistModal'; import OrganizeArtistModal from './Organize/OrganizeArtistModal';
import RetagArtistModal from './AudioTags/RetagArtistModal';
import ArtistEditorRowConnector from './ArtistEditorRowConnector'; import ArtistEditorRowConnector from './ArtistEditorRowConnector';
import ArtistEditorFooter from './ArtistEditorFooter'; import ArtistEditorFooter from './ArtistEditorFooter';
import ArtistEditorFilterModalConnector from './ArtistEditorFilterModalConnector'; import ArtistEditorFilterModalConnector from './ArtistEditorFilterModalConnector';
@ -84,6 +85,7 @@ class ArtistEditor extends Component {
lastToggled: null, lastToggled: null,
selectedState: {}, selectedState: {},
isOrganizingArtistModalOpen: false, isOrganizingArtistModalOpen: false,
isRetaggingArtistModalOpen: false,
columns: getColumns(props.showLanguageProfile, props.showMetadataProfile) 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 // Render
@ -162,6 +176,7 @@ class ArtistEditor extends Component {
isDeleting, isDeleting,
deleteError, deleteError,
isOrganizingArtist, isOrganizingArtist,
isRetaggingArtist,
showLanguageProfile, showLanguageProfile,
showMetadataProfile, showMetadataProfile,
onSortPress, onSortPress,
@ -250,10 +265,12 @@ class ArtistEditor extends Component {
isDeleting={isDeleting} isDeleting={isDeleting}
deleteError={deleteError} deleteError={deleteError}
isOrganizingArtist={isOrganizingArtist} isOrganizingArtist={isOrganizingArtist}
isRetaggingArtist={isRetaggingArtist}
showLanguageProfile={showLanguageProfile} showLanguageProfile={showLanguageProfile}
showMetadataProfile={showMetadataProfile} showMetadataProfile={showMetadataProfile}
onSaveSelected={this.onSaveSelected} onSaveSelected={this.onSaveSelected}
onOrganizeArtistPress={this.onOrganizeArtistPress} onOrganizeArtistPress={this.onOrganizeArtistPress}
onRetagArtistPress={this.onRetagArtistPress}
/> />
<OrganizeArtistModal <OrganizeArtistModal
@ -261,6 +278,13 @@ class ArtistEditor extends Component {
artistIds={selectedArtistIds} artistIds={selectedArtistIds}
onModalClose={this.onOrganizeArtistModalClose} onModalClose={this.onOrganizeArtistModalClose}
/> />
<RetagArtistModal
isOpen={this.state.isRetaggingArtistModalOpen}
artistIds={selectedArtistIds}
onModalClose={this.onRetagArtistModalClose}
/>
</PageContent> </PageContent>
); );
} }
@ -282,6 +306,7 @@ ArtistEditor.propTypes = {
isDeleting: PropTypes.bool.isRequired, isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object, deleteError: PropTypes.object,
isOrganizingArtist: PropTypes.bool.isRequired, isOrganizingArtist: PropTypes.bool.isRequired,
isRetaggingArtist: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired,
showMetadataProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired, onSortPress: PropTypes.func.isRequired,

@ -16,9 +16,11 @@ function createMapStateToProps() {
(state) => state.settings.metadataProfiles, (state) => state.settings.metadataProfiles,
createClientSideCollectionSelector('artist', 'artistEditor'), createClientSideCollectionSelector('artist', 'artistEditor'),
createCommandExecutingSelector(commandNames.RENAME_ARTIST), createCommandExecutingSelector(commandNames.RENAME_ARTIST),
(languageProfiles, metadataProfiles, artist, isOrganizingArtist) => { createCommandExecutingSelector(commandNames.RETAG_ARTIST),
(languageProfiles, metadataProfiles, artist, isOrganizingArtist, isRetaggingArtist) => {
return { return {
isOrganizingArtist, isOrganizingArtist,
isRetaggingArtist,
showLanguageProfile: languageProfiles.items.length > 1, showLanguageProfile: languageProfiles.items.length > 1,
showMetadataProfile: metadataProfiles.items.length > 1, showMetadataProfile: metadataProfiles.items.length > 1,
...artist ...artist

@ -145,9 +145,11 @@ class ArtistEditorFooter extends Component {
isSaving, isSaving,
isDeleting, isDeleting,
isOrganizingArtist, isOrganizingArtist,
isRetaggingArtist,
showLanguageProfile, showLanguageProfile,
showMetadataProfile, showMetadataProfile,
onOrganizeArtistPress onOrganizeArtistPress,
onRetagArtistPress
} = this.props; } = this.props;
const { const {
@ -288,19 +290,29 @@ class ArtistEditorFooter extends Component {
className={styles.organizeSelectedButton} className={styles.organizeSelectedButton}
kind={kinds.WARNING} kind={kinds.WARNING}
isSpinning={isOrganizingArtist} isSpinning={isOrganizingArtist}
isDisabled={!selectedCount || isOrganizingArtist} isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
onPress={onOrganizeArtistPress} onPress={onOrganizeArtistPress}
> >
Rename Files Rename Files
</SpinnerButton> </SpinnerButton>
<SpinnerButton
className={styles.organizeSelectedButton}
kind={kinds.WARNING}
isSpinning={isRetaggingArtist}
isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
onPress={onRetagArtistPress}
>
Write Metadata Tags
</SpinnerButton>
<SpinnerButton <SpinnerButton
className={styles.tagsButton} className={styles.tagsButton}
isSpinning={isSaving && savingTags} isSpinning={isSaving && savingTags}
isDisabled={!selectedCount || isOrganizingArtist} isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
onPress={this.onTagsPress} onPress={this.onTagsPress}
> >
Set Tags Set Lidarr Tags
</SpinnerButton> </SpinnerButton>
</div> </div>
@ -350,10 +362,12 @@ ArtistEditorFooter.propTypes = {
isDeleting: PropTypes.bool.isRequired, isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object, deleteError: PropTypes.object,
isOrganizingArtist: PropTypes.bool.isRequired, isOrganizingArtist: PropTypes.bool.isRequired,
isRetaggingArtist: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired,
showMetadataProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired,
onSaveSelected: PropTypes.func.isRequired, onSaveSelected: PropTypes.func.isRequired,
onOrganizeArtistPress: PropTypes.func.isRequired onOrganizeArtistPress: PropTypes.func.isRequired,
onRetagArtistPress: PropTypes.func.isRequired
}; };
export default ArtistEditorFooter; 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 REFRESH_ARTIST = 'RefreshArtist';
export const RENAME_FILES = 'RenameFiles'; export const RENAME_FILES = 'RenameFiles';
export const RENAME_ARTIST = 'RenameArtist'; export const RENAME_ARTIST = 'RenameArtist';
export const RETAG_FILES = 'RetagFiles';
export const RETAG_ARTIST = 'RetagArtist';
export const RESET_API_KEY = 'ResetApiKey'; export const RESET_API_KEY = 'ResetApiKey';
export const RSS_SYNC = 'RssSync'; export const RSS_SYNC = 'RssSync';
export const SEASON_SEARCH = 'AlbumSearch'; export const SEASON_SEARCH = 'AlbumSearch';

@ -25,7 +25,9 @@ import {
faArrowCircleLeft as fasArrowCircleLeft, faArrowCircleLeft as fasArrowCircleLeft,
faArrowCircleRight as fasArrowCircleRight, faArrowCircleRight as fasArrowCircleRight,
faArrowCircleUp as fasArrowCircleUp, faArrowCircleUp as fasArrowCircleUp,
faLongArrowAltRight as fasLongArrowAltRight,
faBackward as fasBackward, faBackward as fasBackward,
faBan as fasBan,
faBars as fasBars, faBars as fasBars,
faBolt as fasBolt, faBolt as fasBolt,
faBookmark as fasBookmark, faBookmark as fasBookmark,
@ -47,6 +49,7 @@ import {
faCopy as fasCopy, faCopy as fasCopy,
faDesktop as fasDesktop, faDesktop as fasDesktop,
faDownload as fasDownload, faDownload as fasDownload,
faEdit as fasEdit,
faEllipsisH as fasEllipsisH, faEllipsisH as fasEllipsisH,
faExclamationCircle as fasExclamationCircle, faExclamationCircle as fasExclamationCircle,
faExclamationTriangle as fasExclamationTriangle, faExclamationTriangle as fasExclamationTriangle,
@ -111,8 +114,10 @@ export const ALTERNATE_TITLES = farClone;
export const ADVANCED_SETTINGS = fasCog; export const ADVANCED_SETTINGS = fasCog;
export const ARROW_LEFT = fasArrowCircleLeft; export const ARROW_LEFT = fasArrowCircleLeft;
export const ARROW_RIGHT = fasArrowCircleRight; export const ARROW_RIGHT = fasArrowCircleRight;
export const ARROW_RIGHT_NO_CIRCLE = fasLongArrowAltRight;
export const ARROW_UP = fasArrowCircleUp; export const ARROW_UP = fasArrowCircleUp;
export const BACKUP = farFileArchive; export const BACKUP = farFileArchive;
export const BAN = fasBan;
export const BUG = fasBug; export const BUG = fasBug;
export const CALENDAR = fasCalendarAlt; export const CALENDAR = fasCalendarAlt;
export const CALENDAR_O = farCalendar; export const CALENDAR_O = farCalendar;
@ -176,9 +181,10 @@ export const QUEUED = fasCloud;
export const QUICK = fasRocket; export const QUICK = fasRocket;
export const REFRESH = fasSync; export const REFRESH = fasSync;
export const REMOVE = fasTimes; export const REMOVE = fasTimes;
export const REORDER = fasBars;
export const RESTART = fasRedoAlt; export const RESTART = fasRedoAlt;
export const RESTORE = fasHistory; export const RESTORE = fasHistory;
export const REORDER = fasBars; export const RETAG = fasEdit;
export const RSS = fasRss; export const RSS = fasRss;
export const SAVE = fasSave; export const SAVE = fasSave;
export const SCHEDULED = farClock; export const SCHEDULED = farClock;

@ -74,7 +74,6 @@ class OrganizePreviewModalContent extends Component {
isPopulated, isPopulated,
error, error,
items, items,
renameTracks,
trackFormat, trackFormat,
path, path,
onModalClose onModalClose
@ -107,13 +106,7 @@ class OrganizePreviewModalContent extends Component {
{ {
!isFetching && isPopulated && !items.length && !isFetching && isPopulated && !items.length &&
<div> <div>Success! My work is done, no files to rename.</div>
{
renameTracks ?
<div>Success! My work is done, no files to rename.</div> :
<div>Renaming is disabled, nothing to rename</div>
}
</div>
} }
{ {
@ -191,7 +184,6 @@ OrganizePreviewModalContent.propTypes = {
error: PropTypes.object, error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
path: PropTypes.string.isRequired, path: PropTypes.string.isRequired,
renameTracks: PropTypes.bool,
trackFormat: PropTypes.string, trackFormat: PropTypes.string,
onOrganizePress: PropTypes.func.isRequired, onOrganizePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired

@ -19,7 +19,6 @@ function createMapStateToProps() {
props.isFetching = organizePreview.isFetching || naming.isFetching; props.isFetching = organizePreview.isFetching || naming.isFetching;
props.isPopulated = organizePreview.isPopulated && naming.isPopulated; props.isPopulated = organizePreview.isPopulated && naming.isPopulated;
props.error = organizePreview.error || naming.error; props.error = organizePreview.error || naming.error;
props.renameTracks = naming.item.renameTracks;
props.trackFormat = naming.item.standardTrackFormat; props.trackFormat = naming.item.standardTrackFormat;
props.path = artist.path; 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 FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup'; 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) { function MetadataProvider(props) {
const { const {
advancedSettings, advancedSettings,
@ -54,6 +61,35 @@ function MetadataProvider(props) {
</FormGroup> </FormGroup>
</FieldSet> </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> </Form>
} }
</div> </div>

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

@ -173,6 +173,17 @@ export const defaultState = {
type: filterTypes.EQUAL 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 interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions'; import * as oAuth from './oAuthActions';
import * as organizePreview from './organizePreviewActions'; import * as organizePreview from './organizePreviewActions';
import * as retagPreview from './retagPreviewActions';
import * as paths from './pathActions'; import * as paths from './pathActions';
import * as queue from './queueActions'; import * as queue from './queueActions';
import * as releases from './releaseActions'; import * as releases from './releaseActions';
@ -46,6 +47,7 @@ export default [
interactiveImportActions, interactiveImportActions,
oAuth, oAuth,
organizePreview, organizePreview,
retagPreview,
paths, paths,
queue, queue,
releases, 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 public class MetadataProviderConfigResource : RestResource
{ {
//Calendar
public string MetadataSource { get; set; } public string MetadataSource { get; set; }
public WriteAudioTagsType WriteAudioTags { get; set; }
public bool ScrubAudioTags { get; set; }
} }
public static class MetadataProviderConfigResourceMapper public static class MetadataProviderConfigResourceMapper
@ -17,7 +17,8 @@ namespace Lidarr.Api.V1.Config
return new MetadataProviderConfigResource return new MetadataProviderConfigResource
{ {
MetadataSource = model.MetadataSource, MetadataSource = model.MetadataSource,
WriteAudioTags = model.WriteAudioTags,
ScrubAudioTags = model.ScrubAudioTags,
}; };
} }
} }

@ -160,6 +160,8 @@
<Compile Include="Tracks\TrackResource.cs" /> <Compile Include="Tracks\TrackResource.cs" />
<Compile Include="Tracks\RenameTrackModule.cs" /> <Compile Include="Tracks\RenameTrackModule.cs" />
<Compile Include="Tracks\RenameTrackResource.cs" /> <Compile Include="Tracks\RenameTrackResource.cs" />
<Compile Include="Tracks\RetagTrackModule.cs" />
<Compile Include="Tracks\RetagTrackResource.cs" />
<Compile Include="Health\HealthModule.cs" /> <Compile Include="Health\HealthModule.cs" />
<Compile Include="Health\HealthResource.cs" /> <Compile Include="Health\HealthResource.cs" />
<Compile Include="History\HistoryModule.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); var localAlbumRelease = new LocalAlbumRelease(localTracks);
Mocker.GetMock<IReleaseService>() Mocker.GetMock<IReleaseService>()
.Setup(x => x.GetReleasesByForeignReleaseId(new List<string>{ "xxx" })) .Setup(x => x.GetReleaseByForeignReleaseId("xxx"))
.Returns(new List<AlbumRelease> { release }); .Returns(release);
Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null).ShouldBeEquivalentTo(new List<AlbumRelease> { 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;
using NzbDrone.Core.Music.Commands; using NzbDrone.Core.Music.Commands;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
using FluentAssertions;
using NzbDrone.Common.Serializer;
namespace NzbDrone.Core.Test.MusicTests namespace NzbDrone.Core.Test.MusicTests
{ {
@ -54,13 +56,13 @@ namespace NzbDrone.Core.Test.MusicTests
.Returns(_artist); .Returns(_artist);
Mocker.GetMock<IReleaseService>() Mocker.GetMock<IReleaseService>()
.Setup(s => s.GetReleasesByAlbum(album1.Id)) .Setup(s => s.GetReleasesForRefresh(album1.Id, It.IsAny<IEnumerable<string>>()))
.Returns(new List<AlbumRelease> { release }); .Returns(new List<AlbumRelease> { release });
Mocker.GetMock<IReleaseService>() Mocker.GetMock<IArtistMetadataRepository>()
.Setup(s => s.GetReleasesByForeignReleaseId(It.IsAny<List<string>>())) .Setup(s => s.FindById(It.IsAny<List<string>>()))
.Returns(new List<AlbumRelease> { release }); .Returns(new List<ArtistMetadata>());
Mocker.GetMock<IProvideAlbumInfo>() Mocker.GetMock<IProvideAlbumInfo>()
.Setup(s => s.GetAlbumInfo(It.IsAny<string>())) .Setup(s => s.GetAlbumInfo(It.IsAny<string>()))
.Callback(() => { throw new AlbumNotFoundException(album1.ForeignAlbumId); }); .Callback(() => { throw new AlbumNotFoundException(album1.ForeignAlbumId); });
@ -80,7 +82,7 @@ namespace NzbDrone.Core.Test.MusicTests
[Test] [Test]
public void should_log_error_if_musicbrainz_id_not_found() public void should_log_error_if_musicbrainz_id_not_found()
{ {
Subject.RefreshAlbumInfo(_albums, false); Subject.RefreshAlbumInfo(_albums, false, false);
Mocker.GetMock<IAlbumService>() Mocker.GetMock<IAlbumService>()
.Verify(v => v.UpdateMany(It.IsAny<List<Album>>()), Times.Never()); .Verify(v => v.UpdateMany(It.IsAny<List<Album>>()), Times.Never());
@ -97,12 +99,56 @@ namespace NzbDrone.Core.Test.MusicTests
GivenNewAlbumInfo(newAlbumInfo); GivenNewAlbumInfo(newAlbumInfo);
Subject.RefreshAlbumInfo(_albums, false); Subject.RefreshAlbumInfo(_albums, false, false);
Mocker.GetMock<IAlbumService>() Mocker.GetMock<IAlbumService>()
.Verify(v => v.UpdateMany(It.Is<List<Album>>(s => s.First().ForeignAlbumId == newAlbumInfo.ForeignAlbumId))); .Verify(v => v.UpdateMany(It.Is<List<Album>>(s => s.First().ForeignAlbumId == newAlbumInfo.ForeignAlbumId)));
ExceptionVerification.ExpectedWarns(1); 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); .Returns(_artist);
Mocker.GetMock<IAlbumService>() 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>()); .Returns(new List<Album>());
Mocker.GetMock<IAlbumService>()
.Setup(s => s.FindById(It.IsAny<List<string>>()))
.Returns(new List<Album>());
Mocker.GetMock<IProvideArtistInfo>() Mocker.GetMock<IProvideArtistInfo>()
.Setup(s => s.GetArtistInfo(It.IsAny<string>(), It.IsAny<int>())) .Setup(s => s.GetArtistInfo(It.IsAny<string>(), It.IsAny<int>()))
.Callback(() => { throw new ArtistNotFoundException(_artist.ForeignArtistId); }); .Callback(() => { throw new ArtistNotFoundException(_artist.ForeignArtistId); });

@ -87,6 +87,10 @@
<Reference Include="Prowlin, Version=0.9.4456.26422, Culture=neutral, processorArchitecture=MSIL"> <Reference Include="Prowlin, Version=0.9.4456.26422, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll</HintPath> <HintPath>..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll</HintPath>
</Reference> </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" />
<Reference Include="System.Data" /> <Reference Include="System.Data" />
<Reference Include="System.Drawing" /> <Reference Include="System.Drawing" />
@ -281,6 +285,7 @@
<Compile Include="MediaCoverTests\CoverExistsSpecificationFixture.cs" /> <Compile Include="MediaCoverTests\CoverExistsSpecificationFixture.cs" />
<Compile Include="MediaCoverTests\ImageResizerFixture.cs" /> <Compile Include="MediaCoverTests\ImageResizerFixture.cs" />
<Compile Include="MediaCoverTests\MediaCoverServiceFixture.cs" /> <Compile Include="MediaCoverTests\MediaCoverServiceFixture.cs" />
<Compile Include="MediaFiles\AudioTagServiceFixture.cs" />
<Compile Include="MediaFiles\DiskScanServiceTests\ScanFixture.cs" /> <Compile Include="MediaFiles\DiskScanServiceTests\ScanFixture.cs" />
<Compile Include="MediaFiles\DownloadedAlbumsCommandServiceFixture.cs" /> <Compile Include="MediaFiles\DownloadedAlbumsCommandServiceFixture.cs" />
<Compile Include="MediaFiles\DownloadedTracksImportServiceFixture.cs" /> <Compile Include="MediaFiles\DownloadedTracksImportServiceFixture.cs" />
@ -502,7 +507,7 @@
<Content Include="Files\LongOverview.txt"> <Content Include="Files\LongOverview.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Files\Media\H264_sample.mp4"> <Content Include="Files\Media\nin.mp2">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Files\Media\nin.mp3"> <Content Include="Files\Media\nin.mp3">
@ -511,6 +516,18 @@
<Content Include="Files\Media\nin.flac"> <Content Include="Files\Media\nin.flac">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </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"> <Content Include="Files\Nzbget\JsonError.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>

@ -183,12 +183,14 @@ namespace NzbDrone.Core.Test.ParserTests
} }
[TestCase("", "MPEG-4 Audio (mp4a)", 320)] [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) public void should_parse_aac_320_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_320); 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) public void should_parse_aac_vbr_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_VBR); 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("Kirlian Camera - The Ice Curtain - Album 1998 - Ogg-Vorbis Q10", null, 0)]
[TestCase("", "Vorbis Version 0 Audio", 500)] [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) public void should_parse_vorbis_q10_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q10); ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q10);
} }
[TestCase("", "Vorbis Version 0 Audio", 320)] [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) public void should_parse_vorbis_q9_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q9); 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("Various Artists - No New York [1978/Ogg/q8]", null, 0)]
[TestCase("", "Vorbis Version 0 Audio", 256)] [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) public void should_parse_vorbis_q8_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q8); 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("Masters_At_Work-Nuyorican_Soul-.Talkin_Loud.-1997-OGG.Q7", null, 0)]
[TestCase("", "Vorbis Version 0 Audio", 224)] [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) public void should_parse_vorbis_q7_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q7); ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q7);
} }
[TestCase("", "Vorbis Version 0 Audio", 192)] [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) public void should_parse_vorbis_q6_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q6); ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q6);
} }
[TestCase("", "Vorbis Version 0 Audio", 160)] [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) public void should_parse_vorbis_q5_quality(string title, string desc, int bitrate)
{ {
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q5); 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> <packages>
<package id="AutoMoq" version="1.8.1.0" targetFramework="net461" /> <package id="AutoMoq" version="1.8.1.0" targetFramework="net461" />
<package id="CommonServiceLocator" version="1.3" 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="NUnit" version="3.11.0" targetFramework="net461" />
<package id="Prowlin" version="0.9.4456.26422" targetFramework="net461" /> <package id="Prowlin" version="0.9.4456.26422" targetFramework="net461" />
<package id="Unity" version="2.1.505.2" 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); } 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 public int FirstDayOfWeek
{ {
get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); } get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); }

@ -69,9 +69,10 @@ namespace NzbDrone.Core.Configuration
string PlexClientIdentifier { get; } string PlexClientIdentifier { get; }
//MetadataSource //Metadata
string MetadataSource { get; set; } string MetadataSource { get; set; }
WriteAudioTagsType WriteAudioTags { get; set; }
bool ScrubAudioTags { get; set; }
//Forms Auth //Forms Auth
string RijndaelPassphrase { get; } 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, TrackFileDeleted = 5,
TrackFileRenamed = 6, TrackFileRenamed = 6,
AlbumImportIncomplete = 7, AlbumImportIncomplete = 7,
DownloadImported = 8 DownloadImported = 8,
TrackFileRetagged = 9
} }
} }

@ -38,6 +38,7 @@ namespace NzbDrone.Core.History
IHandle<DownloadCompletedEvent>, IHandle<DownloadCompletedEvent>,
IHandle<TrackFileDeletedEvent>, IHandle<TrackFileDeletedEvent>,
IHandle<TrackFileRenamedEvent>, IHandle<TrackFileRenamedEvent>,
IHandle<TrackFileRetaggedEvent>,
IHandle<ArtistDeletedEvent> IHandle<ArtistDeletedEvent>
{ {
private readonly IHistoryRepository _historyRepository; 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) public void Handle(ArtistDeletedEvent message)
{ {
_historyRepository.DeleteForArtist(message.Artist.Id); _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) _fileExtensions = new Dictionary<string, Quality>(StringComparer.OrdinalIgnoreCase)
{ {
{ ".mp2", Quality.Unknown },
{ ".mp3", Quality.Unknown }, { ".mp3", Quality.Unknown },
{ ".m4a", Quality.Unknown }, { ".m4a", Quality.Unknown },
{ ".m4b", Quality.Unknown },
{ ".m4p", Quality.Unknown },
{ ".ogg", Quality.Unknown }, { ".ogg", Quality.Unknown },
{ ".oga", Quality.Unknown },
{ ".opus", Quality.Unknown },
{ ".wma", Quality.WMA }, { ".wma", Quality.WMA },
{ ".wav", Quality.WAV }, { ".wav", Quality.WAV },
{ ".wv" , Quality.WAVPACK }, { ".wv" , Quality.WAVPACK },

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using Marr.Data.QGen; using Marr.Data.QGen;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
@ -12,6 +10,7 @@ namespace NzbDrone.Core.MediaFiles
{ {
List<TrackFile> GetFilesByArtist(int artistId); List<TrackFile> GetFilesByArtist(int artistId);
List<TrackFile> GetFilesByAlbum(int albumId); List<TrackFile> GetFilesByAlbum(int albumId);
List<TrackFile> GetFilesByRelease(int releaseId);
List<TrackFile> GetFilesWithRelativePath(int artistId, string relativePath); 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 // needed more often than not so better to load it all now
protected override QueryBuilder<TrackFile> Query => protected override QueryBuilder<TrackFile> Query =>
DataMapper.Query<TrackFile>() DataMapper.Query<TrackFile>()
.Join<TrackFile, Track>(JoinType.Inner, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) .Join<TrackFile, Track>(JoinType.Left, 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, Album>(JoinType.Left, 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<TrackFile, Artist>(JoinType.Left, 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<Artist, ArtistMetadata>(JoinType.Left, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id);
public List<TrackFile> GetFilesByArtist(int artistId) public List<TrackFile> GetFilesByArtist(int artistId)
{ {
@ -47,6 +46,14 @@ namespace NzbDrone.Core.MediaFiles
.Where(f => f.AlbumId == albumId) .Where(f => f.AlbumId == albumId)
.ToList(); .ToList();
} }
public List<TrackFile> GetFilesByRelease(int releaseId)
{
return Query
.Where<Track>(x => x.AlbumReleaseId == releaseId)
.ToList();
}
public List<TrackFile> GetFilesWithRelativePath(int artistId, string relativePath) public List<TrackFile> GetFilesWithRelativePath(int artistId, string relativePath)
{ {

@ -20,6 +20,7 @@ namespace NzbDrone.Core.MediaFiles
void Delete(TrackFile trackFile, DeleteMediaFileReason reason); void Delete(TrackFile trackFile, DeleteMediaFileReason reason);
List<TrackFile> GetFilesByArtist(int artistId); List<TrackFile> GetFilesByArtist(int artistId);
List<TrackFile> GetFilesByAlbum(int albumId); List<TrackFile> GetFilesByAlbum(int albumId);
List<TrackFile> GetFilesByRelease(int releaseId);
List<string> FilterExistingFiles(List<string> files, Artist artist); List<string> FilterExistingFiles(List<string> files, Artist artist);
TrackFile Get(int id); TrackFile Get(int id);
List<TrackFile> Get(IEnumerable<int> ids); List<TrackFile> Get(IEnumerable<int> ids);
@ -115,6 +116,11 @@ namespace NzbDrone.Core.MediaFiles
return _mediaFileRepository.GetFilesByAlbum(albumId); return _mediaFileRepository.GetFilesByAlbum(albumId);
} }
public List<TrackFile> GetFilesByRelease(int releaseId)
{
return _mediaFileRepository.GetFilesByRelease(releaseId);
}
public void UpdateMediaInfo(List<TrackFile> trackFiles) public void UpdateMediaInfo(List<TrackFile> trackFiles)
{ {
_mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo); _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> { public static readonly Dictionary<Codec, string> CodecNames = new Dictionary<Codec, string> {
{Codec.MP1, "MP1"},
{Codec.MP2, "MP2"},
{Codec.AAC, "AAC"}, {Codec.AAC, "AAC"},
{Codec.AACVBR, "AAC"}, {Codec.AACVBR, "AAC"},
{Codec.ALAC, "ALAC"}, {Codec.ALAC, "ALAC"},
@ -41,6 +43,7 @@ namespace NzbDrone.Core.MediaFiles
{Codec.MP3CBR, "MP3"}, {Codec.MP3CBR, "MP3"},
{Codec.MP3VBR, "MP3"}, {Codec.MP3VBR, "MP3"},
{Codec.OGG, "OGG"}, {Codec.OGG, "OGG"},
{Codec.OPUS, "OPUS"},
{Codec.WAV, "PCM"}, {Codec.WAV, "PCM"},
{Codec.WAVPACK, "WavPack"}, {Codec.WAVPACK, "WavPack"},
{Codec.WMA, "WMA"} {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()); 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); GetBestRelease(localAlbumRelease, candidateReleases, allTracks);
@ -228,12 +228,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
List<AlbumRelease> candidateReleases; 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(); var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList();
if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace()) if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace())
{ {
_logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); var tagRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0]);
return _releaseService.GetReleasesByForeignReleaseId(releaseIds); if (tagRelease != null)
{
_logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]);
return new List<AlbumRelease> { tagRelease };
}
} }
if (release != null) if (release != null)

@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
{ {
private readonly IUpgradeMediaFiles _trackFileUpgrader; private readonly IUpgradeMediaFiles _trackFileUpgrader;
private readonly IMediaFileService _mediaFileService; private readonly IMediaFileService _mediaFileService;
private readonly IAudioTagService _audioTagService;
private readonly ITrackService _trackService; private readonly ITrackService _trackService;
private readonly IRecycleBinProvider _recycleBinProvider; private readonly IRecycleBinProvider _recycleBinProvider;
private readonly IExtraService _extraService; private readonly IExtraService _extraService;
@ -36,6 +37,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
public ImportApprovedTracks(IUpgradeMediaFiles trackFileUpgrader, public ImportApprovedTracks(IUpgradeMediaFiles trackFileUpgrader,
IMediaFileService mediaFileService, IMediaFileService mediaFileService,
IAudioTagService audioTagService,
ITrackService trackService, ITrackService trackService,
IRecycleBinProvider recycleBinProvider, IRecycleBinProvider recycleBinProvider,
IExtraService extraService, IExtraService extraService,
@ -46,6 +48,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
{ {
_trackFileUpgrader = trackFileUpgrader; _trackFileUpgrader = trackFileUpgrader;
_mediaFileService = mediaFileService; _mediaFileService = mediaFileService;
_audioTagService = audioTagService;
_trackService = trackService; _trackService = trackService;
_recycleBinProvider = recycleBinProvider; _recycleBinProvider = recycleBinProvider;
_extraService = extraService; _extraService = extraService;
@ -202,6 +205,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
_mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride); _mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride);
} }
_audioTagService.WriteTags(trackFile, newDownload);
} }
filesToAdd.Add(trackFile); filesToAdd.Add(trackFile);

@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications; private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications;
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumSpecifications; private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumSpecifications;
private readonly IMediaFileService _mediaFileService; private readonly IMediaFileService _mediaFileService;
private readonly IAudioTagService _audioTagService;
private readonly IAugmentingService _augmentingService; private readonly IAugmentingService _augmentingService;
private readonly IIdentificationService _identificationService; private readonly IIdentificationService _identificationService;
private readonly IAlbumService _albumService; private readonly IAlbumService _albumService;
@ -37,6 +38,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications, public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications,
IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> albumSpecifications, IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> albumSpecifications,
IMediaFileService mediaFileService, IMediaFileService mediaFileService,
IAudioTagService audioTagService,
IAugmentingService augmentingService, IAugmentingService augmentingService,
IIdentificationService identificationService, IIdentificationService identificationService,
IAlbumService albumService, IAlbumService albumService,
@ -48,6 +50,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
_trackSpecifications = trackSpecifications; _trackSpecifications = trackSpecifications;
_albumSpecifications = albumSpecifications; _albumSpecifications = albumSpecifications;
_mediaFileService = mediaFileService; _mediaFileService = mediaFileService;
_audioTagService = audioTagService;
_augmentingService = augmentingService; _augmentingService = augmentingService;
_identificationService = identificationService; _identificationService = identificationService;
_albumService = albumService; _albumService = albumService;
@ -95,7 +98,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
DownloadClientAlbumInfo = downloadClientItemInfo, DownloadClientAlbumInfo = downloadClientItemInfo,
FolderTrackInfo = folderInfo, FolderTrackInfo = folderInfo,
Path = file, Path = file,
FileTrackInfo = Parser.Parser.ParseMusicPath(file), FileTrackInfo = _audioTagService.ReadTags(file)
}; };
try try

@ -17,18 +17,21 @@ namespace NzbDrone.Core.MediaFiles
{ {
private readonly IRecycleBinProvider _recycleBinProvider; private readonly IRecycleBinProvider _recycleBinProvider;
private readonly IMediaFileService _mediaFileService; private readonly IMediaFileService _mediaFileService;
private readonly IAudioTagService _audioTagService;
private readonly IMoveTrackFiles _trackFileMover; private readonly IMoveTrackFiles _trackFileMover;
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
private readonly Logger _logger; private readonly Logger _logger;
public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider,
IMediaFileService mediaFileService, IMediaFileService mediaFileService,
IAudioTagService audioTagService,
IMoveTrackFiles trackFileMover, IMoveTrackFiles trackFileMover,
IDiskProvider diskProvider, IDiskProvider diskProvider,
Logger logger) Logger logger)
{ {
_recycleBinProvider = recycleBinProvider; _recycleBinProvider = recycleBinProvider;
_mediaFileService = mediaFileService; _mediaFileService = mediaFileService;
_audioTagService = audioTagService;
_trackFileMover = trackFileMover; _trackFileMover = trackFileMover;
_diskProvider = diskProvider; _diskProvider = diskProvider;
_logger = logger; _logger = logger;
@ -76,6 +79,8 @@ namespace NzbDrone.Core.MediaFiles
moveFileResult.TrackFile = _trackFileMover.MoveTrackFile(trackFile, localTrack); moveFileResult.TrackFile = _trackFileMover.MoveTrackFile(trackFile, localTrack);
} }
_audioTagService.WriteTags(trackFile, true);
return moveFileResult; return moveFileResult;
} }
} }

@ -47,7 +47,10 @@ namespace NzbDrone.Core.Music
_logger.ProgressInfo("Adding Album {0}", newAlbum.Title); _logger.ProgressInfo("Adding Album {0}", newAlbum.Title);
_artistMetadataRepository.UpsertMany(tuple.Item3); _artistMetadataRepository.UpsertMany(tuple.Item3);
_albumService.AddAlbum(newAlbum, tuple.Item1); _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; return newAlbum;
} }
@ -66,7 +69,10 @@ namespace NzbDrone.Core.Music
_logger.ProgressInfo("Adding Album {0}", newAlbum.Title); _logger.ProgressInfo("Adding Album {0}", newAlbum.Title);
_artistMetadataRepository.UpsertMany(tuple.Item3); _artistMetadataRepository.UpsertMany(tuple.Item3);
album = _albumService.AddAlbum(album, tuple.Item1); 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); albumsToAdd.Add(album);
} }

@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music
{ {
List<Album> GetAlbums(int artistId); List<Album> GetAlbums(int artistId);
List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId); List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId);
List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds);
Album FindByTitle(int artistMetadataId, string title); Album FindByTitle(int artistMetadataId, string title);
Album FindById(string foreignId); Album FindById(string foreignId);
List<Album> FindById(List<string> foreignIds);
PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec); PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec);
PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, List<LanguagesBelowCutoff> languagesBelowCutoff); PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, List<LanguagesBelowCutoff> languagesBelowCutoff);
List<Album> AlbumsBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored); List<Album> AlbumsBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored);
@ -53,19 +53,17 @@ namespace NzbDrone.Core.Music
return Query.Where(s => s.ArtistMetadataId == artistMetadataId); 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.* " + return Query.Where(s => s.ForeignAlbumId == foreignAlbumId).SingleOrDefault();
"FROM Albums " +
"WHERE ForeignAlbumId IN ('{0}')",
string.Join("', '", ids));
return Query.QueryText(query).ToList();
} }
public PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec) public PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec)

@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music
List<Album> GetAlbums(IEnumerable<int> albumIds); List<Album> GetAlbums(IEnumerable<int> albumIds);
List<Album> GetAlbumsByArtist(int artistId); List<Album> GetAlbumsByArtist(int artistId);
List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId); List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId);
List<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> foreignIds);
Album AddAlbum(Album newAlbum, string albumArtistId); Album AddAlbum(Album newAlbum, string albumArtistId);
Album FindById(string foreignId); Album FindById(string foreignId);
List<Album> FindById(List<string> foreignIds);
Album FindByTitle(int artistId, string title); Album FindByTitle(int artistId, string title);
Album FindByTitleInexact(int artistId, string title); Album FindByTitleInexact(int artistId, string title);
List<Album> GetCandidates(int artistId, string title); List<Album> GetCandidates(int artistId, string title);
@ -41,7 +41,7 @@ namespace NzbDrone.Core.Music
} }
public class AlbumService : IAlbumService, public class AlbumService : IAlbumService,
IHandleAsync<ArtistDeletedEvent> IHandle<ArtistDeletedEvent>
{ {
private readonly IAlbumRepository _albumRepository; private readonly IAlbumRepository _albumRepository;
private readonly IReleaseRepository _releaseRepository; private readonly IReleaseRepository _releaseRepository;
@ -96,11 +96,6 @@ namespace NzbDrone.Core.Music
return _albumRepository.FindById(lidarrId); return _albumRepository.FindById(lidarrId);
} }
public List<Album> FindById(List<string> ids)
{
return _albumRepository.FindById(ids);
}
public Album FindByTitle(int artistId, string title) public Album FindByTitle(int artistId, string title)
{ {
return _albumRepository.FindByTitle(artistId, title); return _albumRepository.FindByTitle(artistId, title);
@ -200,6 +195,11 @@ namespace NzbDrone.Core.Music
return _albumRepository.GetAlbumsByArtistMetadataId(artistMetadataId).ToList(); return _albumRepository.GetAlbumsByArtistMetadataId(artistMetadataId).ToList();
} }
public List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds)
{
return _albumRepository.GetAlbumsForRefresh(artistId, foreignIds);
}
public Album FindAlbumByRelease(string albumReleaseId) public Album FindAlbumByRelease(string albumReleaseId)
{ {
return _albumRepository.FindAlbumByRelease(albumReleaseId); return _albumRepository.FindAlbumByRelease(albumReleaseId);
@ -300,7 +300,7 @@ namespace NzbDrone.Core.Music
return albums; return albums;
} }
public void HandleAsync(ArtistDeletedEvent message) public void Handle(ArtistDeletedEvent message)
{ {
var albums = GetAlbumsByArtistMetadataId(message.Artist.ArtistMetadataId); var albums = GetAlbumsByArtistMetadataId(message.Artist.ArtistMetadataId);
DeleteMany(albums); DeleteMany(albums);

@ -1,17 +1,13 @@
using Marr.Data;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Profiles.Metadata;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
namespace NzbDrone.Core.Music namespace NzbDrone.Core.Music
{ {
public class ArtistMetadata : ModelBase public class ArtistMetadata : ModelBase, IEquatable<ArtistMetadata>
{ {
public ArtistMetadata() public ArtistMetadata()
{ {
@ -52,5 +48,70 @@ namespace NzbDrone.Core.Music
Ratings = otherArtist.Ratings; Ratings = otherArtist.Ratings;
Members = otherArtist.Members; 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); Artist Upsert(Artist artist);
void UpdateMany(List<Artist> artists); void UpdateMany(List<Artist> artists);
ArtistMetadata FindById(string ArtistId); ArtistMetadata FindById(string ArtistId);
List<ArtistMetadata> FindById(List<string> foreignIds);
void UpsertMany(List<ArtistMetadata> artists); void UpsertMany(List<ArtistMetadata> artists);
void UpsertMany(List<Artist> artists); void UpsertMany(List<Artist> artists);
} }
@ -87,6 +88,11 @@ namespace NzbDrone.Core.Music
return Query.Where(a => a.ForeignArtistId == artistId).SingleOrDefault(); 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) public void UpsertMany(List<ArtistMetadata> artists)
{ {
foreach (var artist in artists) foreach (var artist in artists)

@ -4,23 +4,20 @@ using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music.Events; using NzbDrone.Core.Music.Events;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using NzbDrone.Core.Organizer;
using System.Linq; using System.Linq;
using System.Text;
using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Music.Commands; using NzbDrone.Core.Music.Commands;
using NzbDrone.Core.MediaFiles;
namespace NzbDrone.Core.Music namespace NzbDrone.Core.Music
{ {
public interface IRefreshAlbumService public interface IRefreshAlbumService
{ {
void RefreshAlbumInfo(Album album); void RefreshAlbumInfo(Album album, bool forceUpdateFileTags);
void RefreshAlbumInfo(List<Album> albums, bool forceAlbumRefresh); void RefreshAlbumInfo(List<Album> albums, bool forceAlbumRefresh, bool forceUpdateFileTags);
} }
public class RefreshAlbumService : IRefreshAlbumService, IExecute<RefreshAlbumCommand> public class RefreshAlbumService : IRefreshAlbumService, IExecute<RefreshAlbumCommand>
@ -31,6 +28,7 @@ namespace NzbDrone.Core.Music
private readonly IReleaseService _releaseService; private readonly IReleaseService _releaseService;
private readonly IProvideAlbumInfo _albumInfo; private readonly IProvideAlbumInfo _albumInfo;
private readonly IRefreshTrackService _refreshTrackService; private readonly IRefreshTrackService _refreshTrackService;
private readonly IAudioTagService _audioTagService;
private readonly IEventAggregator _eventAggregator; private readonly IEventAggregator _eventAggregator;
private readonly ICheckIfAlbumShouldBeRefreshed _checkIfAlbumShouldBeRefreshed; private readonly ICheckIfAlbumShouldBeRefreshed _checkIfAlbumShouldBeRefreshed;
private readonly Logger _logger; private readonly Logger _logger;
@ -41,6 +39,7 @@ namespace NzbDrone.Core.Music
IReleaseService releaseService, IReleaseService releaseService,
IProvideAlbumInfo albumInfo, IProvideAlbumInfo albumInfo,
IRefreshTrackService refreshTrackService, IRefreshTrackService refreshTrackService,
IAudioTagService audioTagService,
IEventAggregator eventAggregator, IEventAggregator eventAggregator,
ICheckIfAlbumShouldBeRefreshed checkIfAlbumShouldBeRefreshed, ICheckIfAlbumShouldBeRefreshed checkIfAlbumShouldBeRefreshed,
Logger logger) Logger logger)
@ -51,23 +50,24 @@ namespace NzbDrone.Core.Music
_releaseService = releaseService; _releaseService = releaseService;
_albumInfo = albumInfo; _albumInfo = albumInfo;
_refreshTrackService = refreshTrackService; _refreshTrackService = refreshTrackService;
_audioTagService = audioTagService;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_checkIfAlbumShouldBeRefreshed = checkIfAlbumShouldBeRefreshed; _checkIfAlbumShouldBeRefreshed = checkIfAlbumShouldBeRefreshed;
_logger = logger; _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) foreach (var album in albums)
{ {
if (forceAlbumRefresh || _checkIfAlbumShouldBeRefreshed.ShouldRefresh(album)) 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); _logger.ProgressInfo("Updating Info for {0}", album.Title);
@ -79,13 +79,43 @@ namespace NzbDrone.Core.Music
} }
catch (AlbumNotFoundException) catch (AlbumNotFoundException)
{ {
_logger.Error( _logger.Error($"{album} was not found, it may have been removed from Metadata sources.");
"Album '{0}' (LidarrAPI {1}) was not found, it may have been removed from Metadata sources.",
album.Title, album.ForeignAlbumId);
return; 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; var albumInfo = tuple.Item2;
@ -97,6 +127,9 @@ namespace NzbDrone.Core.Music
album.ForeignAlbumId = albumInfo.ForeignAlbumId; 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.LastInfoSync = DateTime.UtcNow;
album.CleanTitle = albumInfo.CleanTitle; album.CleanTitle = albumInfo.CleanTitle;
album.Title = albumInfo.Title ?? "Unknown"; album.Title = albumInfo.Title ?? "Unknown";
@ -112,28 +145,34 @@ namespace NzbDrone.Core.Music
album.AlbumReleases = new List<AlbumRelease>(); album.AlbumReleases = new List<AlbumRelease>();
var remoteReleases = albumInfo.AlbumReleases.Value.DistinctBy(m => m.ForeignReleaseId).ToList(); var remoteReleases = albumInfo.AlbumReleases.Value.DistinctBy(m => m.ForeignReleaseId).ToList();
var existingReleases = _releaseService.GetReleasesForRefresh(album.Id, remoteReleases.Select(x => x.ForeignReleaseId));
// 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 newReleaseList = new List<AlbumRelease>(); var newReleaseList = new List<AlbumRelease>();
var updateReleaseList = new List<AlbumRelease>(); var updateReleaseList = new List<AlbumRelease>();
var upToDateCount = 0;
foreach (var release in remoteReleases) foreach (var release in remoteReleases)
{ {
release.AlbumId = album.Id; release.AlbumId = album.Id;
release.Album = album;
var releaseToRefresh = existingReleases.SingleOrDefault(r => r.ForeignReleaseId == release.ForeignReleaseId); var releaseToRefresh = existingReleases.SingleOrDefault(r => r.ForeignReleaseId == release.ForeignReleaseId);
if (releaseToRefresh != null) if (releaseToRefresh != null)
{ {
existingReleases.Remove(releaseToRefresh); existingReleases.Remove(releaseToRefresh);
// copy across the db keys and check for equality
release.Id = releaseToRefresh.Id; release.Id = releaseToRefresh.Id;
release.AlbumId = releaseToRefresh.AlbumId;
release.Monitored = releaseToRefresh.Monitored; release.Monitored = releaseToRefresh.Monitored;
updateReleaseList.Add(release);
if (!releaseToRefresh.Equals(release))
{
updateReleaseList.Add(release);
}
else
{
upToDateCount++;
}
} }
else else
{ {
@ -143,10 +182,11 @@ namespace NzbDrone.Core.Music
album.AlbumReleases.Value.Add(release); album.AlbumReleases.Value.Add(release);
} }
_logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} releases", _logger.Debug($"{album} {upToDateCount} releases up to date; Deleting {existingReleases.Count}, Updating {updateReleaseList.Count}, Adding {newReleaseList.Count} releases.");
album, existingReleases.Count, updateReleaseList.Count, newReleaseList.Count);
// 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.DeleteMany(existingReleases);
_releaseService.UpdateMany(updateReleaseList); _releaseService.UpdateMany(updateReleaseList);
_releaseService.InsertMany(newReleaseList); _releaseService.InsertMany(newReleaseList);
@ -158,7 +198,10 @@ namespace NzbDrone.Core.Music
_releaseService.UpdateMany(new List<AlbumRelease> { toMonitor }); _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}); _albumService.UpdateMany(new List<Album>{album});
_logger.Debug("Finished album refresh for {0}", album.Title); _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 album = _albumService.GetAlbum(message.AlbumId.Value);
var artist = _artistService.GetArtistByMetadataId(album.ArtistMetadataId); var artist = _artistService.GetArtistByMetadataId(album.ArtistMetadataId);
RefreshAlbumInfo(album); RefreshAlbumInfo(album, false);
_eventAggregator.PublishEvent(new ArtistUpdatedEvent(artist)); _eventAggregator.PublishEvent(new ArtistUpdatedEvent(artist));
} }

@ -26,6 +26,7 @@ namespace NzbDrone.Core.Music
private readonly IAlbumService _albumService; private readonly IAlbumService _albumService;
private readonly IRefreshAlbumService _refreshAlbumService; private readonly IRefreshAlbumService _refreshAlbumService;
private readonly IRefreshTrackService _refreshTrackService; private readonly IRefreshTrackService _refreshTrackService;
private readonly IAudioTagService _audioTagService;
private readonly IEventAggregator _eventAggregator; private readonly IEventAggregator _eventAggregator;
private readonly IDiskScanService _diskScanService; private readonly IDiskScanService _diskScanService;
private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed; private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed;
@ -39,6 +40,7 @@ namespace NzbDrone.Core.Music
IAlbumService albumService, IAlbumService albumService,
IRefreshAlbumService refreshAlbumService, IRefreshAlbumService refreshAlbumService,
IRefreshTrackService refreshTrackService, IRefreshTrackService refreshTrackService,
IAudioTagService audioTagService,
IEventAggregator eventAggregator, IEventAggregator eventAggregator,
IDiskScanService diskScanService, IDiskScanService diskScanService,
ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed, ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed,
@ -52,6 +54,7 @@ namespace NzbDrone.Core.Music
_albumService = albumService; _albumService = albumService;
_refreshAlbumService = refreshAlbumService; _refreshAlbumService = refreshAlbumService;
_refreshTrackService = refreshTrackService; _refreshTrackService = refreshTrackService;
_audioTagService = audioTagService;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_diskScanService = diskScanService; _diskScanService = diskScanService;
_checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed; _checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed;
@ -72,13 +75,15 @@ namespace NzbDrone.Core.Music
} }
catch (ArtistNotFoundException) 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; return;
} }
var forceUpdateFileTags = artist.Name != artistInfo.Name;
if (artist.Metadata.Value.ForeignArtistId != artistInfo.Metadata.Value.ForeignArtistId) 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 // Update list exclusion if one exists
var importExclusion = _importListExclusionService.FindByForeignId(artist.Metadata.Value.ForeignArtistId); var importExclusion = _importListExclusionService.FindByForeignId(artist.Metadata.Value.ForeignArtistId);
@ -90,6 +95,7 @@ namespace NzbDrone.Core.Music
} }
artist.Metadata.Value.ForeignArtistId = artistInfo.Metadata.Value.ForeignArtistId; artist.Metadata.Value.ForeignArtistId = artistInfo.Metadata.Value.ForeignArtistId;
forceUpdateFileTags = true;
} }
artist.Metadata.Value.ApplyChanges(artistInfo.Metadata.Value); 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); _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 // Get list of DB current db albums for artist
var existingAlbumsByArtist = _albumService.GetAlbumsByArtist(artist.Id); var existingAlbums = _albumService.GetAlbumsForRefresh(artist.ArtistMetadataId, remoteAlbums.Select(x => x.ForeignAlbumId));
var existingAlbumsById = _albumService.FindById(remoteAlbums.Select(x => x.ForeignAlbumId).ToList());
var existingAlbums = existingAlbumsByArtist.Union(existingAlbumsById).DistinctBy(x => x.Id).ToList();
var newAlbumsList = new List<Album>(); var newAlbumsList = new List<Album>();
var updateAlbumsList = new List<Album>(); var updateAlbumsList = new List<Album>();
@ -121,15 +124,17 @@ namespace NzbDrone.Core.Music
foreach (var album in remoteAlbums) foreach (var album in remoteAlbums)
{ {
// Check for album in existing albums, if not set properties and add to new list // 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) if (albumToRefresh != null)
{ {
albumToRefresh.Artist = artist;
existingAlbums.Remove(albumToRefresh); existingAlbums.Remove(albumToRefresh);
updateAlbumsList.Add(albumToRefresh); updateAlbumsList.Add(albumToRefresh);
} }
else else
{ {
album.Artist = artist;
newAlbumsList.Add(album); newAlbumsList.Add(album);
} }
} }
@ -139,6 +144,9 @@ namespace NzbDrone.Core.Music
_logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} albums", _logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} albums",
artist, existingAlbums.Count, updateAlbumsList.Count, newAlbumsList.Count); 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 // 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 // end up trying to duplicate an existing release under a new album
_albumService.DeleteMany(existingAlbums); _albumService.DeleteMany(existingAlbums);
@ -147,7 +155,7 @@ namespace NzbDrone.Core.Music
newAlbumsList = UpdateAlbums(artist, newAlbumsList); newAlbumsList = UpdateAlbums(artist, newAlbumsList);
_addAlbumService.AddAlbums(newAlbumsList); _addAlbumService.AddAlbums(newAlbumsList);
_refreshAlbumService.RefreshAlbumInfo(updateAlbumsList, forceAlbumRefresh); _refreshAlbumService.RefreshAlbumInfo(updateAlbumsList, forceAlbumRefresh, forceUpdateFileTags);
_eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(artist, newAlbumsList, updateAlbumsList)); _eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(artist, newAlbumsList, updateAlbumsList));

@ -1,35 +1,43 @@
using NLog; using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music.Events;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
namespace NzbDrone.Core.Music namespace NzbDrone.Core.Music
{ {
public interface IRefreshTrackService public interface IRefreshTrackService
{ {
void RefreshTrackInfo(Album rg); void RefreshTrackInfo(Album rg, bool forceUpdateFileTags);
} }
public class RefreshTrackService : IRefreshTrackService public class RefreshTrackService : IRefreshTrackService
{ {
private readonly ITrackService _trackService; private readonly ITrackService _trackService;
private readonly IAlbumService _albumService; private readonly IAlbumService _albumService;
private readonly IMediaFileService _mediaFileService;
private readonly IAudioTagService _audioTagService;
private readonly IEventAggregator _eventAggregator; private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger; 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; _trackService = trackService;
_albumService = albumService; _albumService = albumService;
_mediaFileService = mediaFileService;
_audioTagService = audioTagService;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_logger = logger; _logger = logger;
} }
public void RefreshTrackInfo(Album album) public void RefreshTrackInfo(Album album, bool forceUpdateFileTags)
{ {
_logger.Info("Starting track info refresh for: {0}", album); _logger.Info("Starting track info refresh for: {0}", album);
var successCount = 0; var successCount = 0;
@ -37,48 +45,50 @@ namespace NzbDrone.Core.Music
foreach (var release in album.AlbumReleases.Value) foreach (var release in album.AlbumReleases.Value)
{ {
var dupeFreeRemoteTracks = release.Tracks.Value.DistinctBy(m => new { m.ForeignTrackId, m.TrackNumber }).ToList(); var remoteTracks = release.Tracks.Value.DistinctBy(m => m.ForeignTrackId).ToList();
var existingTracks = _trackService.GetTracksForRefresh(release.Id, remoteTracks.Select(x => x.ForeignTrackId));
// 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 updateList = new List<Track>(); var updateList = new List<Track>();
var newList = 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 try
{ {
var trackToUpdate = GetTrackToUpdate(track, existingTracks); var trackToUpdate = existingTracks.SingleOrDefault(e => e.ForeignTrackId == track.ForeignTrackId);
if (trackToUpdate != null) if (trackToUpdate != null)
{ {
existingTracks.Remove(trackToUpdate); 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 else
{ {
trackToUpdate = new Track(); newList.Add(track);
trackToUpdate.Id = track.Id;
newList.Add(trackToUpdate);
} }
// 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++; successCount++;
} }
catch (Exception e) catch (Exception e)
@ -88,8 +98,19 @@ namespace NzbDrone.Core.Music
} }
} }
_logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} tracks", // if any tracks with files are deleted, strip out the MB tags from the metadata
release, existingTracks.Count, updateList.Count, newList.Count); // 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.DeleteMany(existingTracks);
_trackService.UpdateMany(updateList); _trackService.UpdateMany(updateList);
@ -106,17 +127,6 @@ namespace NzbDrone.Core.Music
_logger.Info("Finished track refresh for album: {0}.", album); _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.Collections.Generic;
using System.Linq; using System.Linq;
using Marr.Data; using Marr.Data;
using NzbDrone.Common.Serializer;
namespace NzbDrone.Core.Music namespace NzbDrone.Core.Music
{ {
public class AlbumRelease : ModelBase public class AlbumRelease : ModelBase, IEquatable<AlbumRelease>
{ {
// These correspond to columns in the AlbumReleases table // These correspond to columns in the AlbumReleases table
public int AlbumId { get; set; } public int AlbumId { get; set; }
@ -31,5 +32,72 @@ namespace NzbDrone.Core.Music
{ {
return string.Format("[{0}][{1}]", ForeignReleaseId, Title.NullSafe()); 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> public interface IReleaseRepository : IBasicRepository<AlbumRelease>
{ {
AlbumRelease FindByForeignReleaseId(string foreignReleaseId);
List<AlbumRelease> FindByAlbum(int id); List<AlbumRelease> FindByAlbum(int id);
List<AlbumRelease> FindByRecordingId(List<string> recordingIds); List<AlbumRelease> FindByRecordingId(List<string> recordingIds);
List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds);
List<AlbumRelease> SetMonitored(AlbumRelease release); List<AlbumRelease> SetMonitored(AlbumRelease release);
List<AlbumRelease> FindByForeignReleaseId(List<string> foreignReleaseIds);
} }
public class ReleaseRepository : BasicRepository<AlbumRelease>, IReleaseRepository 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 return Query
.Join<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id) .Where(x => x.ForeignReleaseId == foreignReleaseId)
.Join<Album, ArtistMetadata>(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id) .SingleOrDefault();
.Where<AlbumRelease>(r => r.AlbumId == id) }
public List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds)
{
return Query
.Where(r => r.AlbumId == albumId)
.OrWhere($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')")
.ToList(); .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 return Query
.Join<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id) .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) .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(); .ToList();
} }

@ -7,17 +7,18 @@ namespace NzbDrone.Core.Music
public interface IReleaseService public interface IReleaseService
{ {
AlbumRelease GetRelease(int id); AlbumRelease GetRelease(int id);
AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId);
void InsertMany(List<AlbumRelease> releases); void InsertMany(List<AlbumRelease> releases);
void UpdateMany(List<AlbumRelease> releases); void UpdateMany(List<AlbumRelease> releases);
void DeleteMany(List<AlbumRelease> releases); void DeleteMany(List<AlbumRelease> releases);
List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds);
List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId); List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId);
List<AlbumRelease> GetReleasesByForeignReleaseId(List<string> foreignReleaseIds);
List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds); List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds);
List<AlbumRelease> SetMonitored(AlbumRelease release); List<AlbumRelease> SetMonitored(AlbumRelease release);
} }
public class ReleaseService : IReleaseService, public class ReleaseService : IReleaseService,
IHandleAsync<AlbumDeletedEvent> IHandle<AlbumDeletedEvent>
{ {
private readonly IReleaseRepository _releaseRepository; private readonly IReleaseRepository _releaseRepository;
private readonly IEventAggregator _eventAggregator; private readonly IEventAggregator _eventAggregator;
@ -34,6 +35,11 @@ namespace NzbDrone.Core.Music
return _releaseRepository.Get(id); return _releaseRepository.Get(id);
} }
public AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId)
{
return _releaseRepository.FindByForeignReleaseId(foreignReleaseId);
}
public void InsertMany(List<AlbumRelease> releases) public void InsertMany(List<AlbumRelease> releases)
{ {
_releaseRepository.InsertMany(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) public List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds)
{ {
return _releaseRepository.FindByRecordingId(recordingIds); return _releaseRepository.FindByRecordingId(recordingIds);
@ -73,7 +79,7 @@ namespace NzbDrone.Core.Music
return _releaseRepository.SetMonitored(release); return _releaseRepository.SetMonitored(release);
} }
public void HandleAsync(AlbumDeletedEvent message) public void Handle(AlbumDeletedEvent message)
{ {
var releases = GetReleasesByAlbum(message.Album.Id); var releases = GetReleasesByAlbum(message.Album.Id);
DeleteMany(releases); DeleteMany(releases);

@ -2,10 +2,12 @@ using NzbDrone.Core.Datastore;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using Marr.Data; using Marr.Data;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using System;
using NzbDrone.Common.Serializer;
namespace NzbDrone.Core.Music namespace NzbDrone.Core.Music
{ {
public class Track : ModelBase public class Track : ModelBase, IEquatable<Track>
{ {
public Track() public Track()
{ {
@ -41,5 +43,72 @@ namespace NzbDrone.Core.Music
{ {
return string.Format("[{0}]{1}", ForeignTrackId, Title.NullSafe()); 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> GetTracksByAlbum(int albumId);
List<Track> GetTracksByRelease(int albumReleaseId); List<Track> GetTracksByRelease(int albumReleaseId);
List<Track> GetTracksByReleases(List<int> albumReleaseId); List<Track> GetTracksByReleases(List<int> albumReleaseId);
List<Track> GetTracksByForeignReleaseId(string foreignReleaseId); List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
List<Track> GetTracksByForeignTrackIds(List<string> foreignTrackId);
List<Track> GetTracksByFileId(int fileId); List<Track> GetTracksByFileId(int fileId);
List<Track> TracksWithFiles(int artistId); List<Track> TracksWithFiles(int artistId);
List<Track> TracksWithoutFiles(int albumId); List<Track> TracksWithoutFiles(int albumId);
@ -73,25 +72,12 @@ namespace NzbDrone.Core.Music
.ToList(); .ToList();
} }
public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId) public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds)
{ {
string query = string.Format("SELECT Tracks.* " + return Query
"FROM AlbumReleases " + .Where(t => t.AlbumReleaseId == albumReleaseId)
"JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + .OrWhere($"[ForeignTrackId] IN ('{string.Join("', '", foreignTrackIds)}')")
"WHERE AlbumReleases.ForeignReleaseId = '{0}'", .ToList();
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();
} }
public List<Track> GetTracksByFileId(int fileId) public List<Track> GetTracksByFileId(int fileId)

@ -16,8 +16,7 @@ namespace NzbDrone.Core.Music
List<Track> GetTracksByAlbum(int albumId); List<Track> GetTracksByAlbum(int albumId);
List<Track> GetTracksByRelease(int albumReleaseId); List<Track> GetTracksByRelease(int albumReleaseId);
List<Track> GetTracksByReleases(List<int> albumReleaseIds); List<Track> GetTracksByReleases(List<int> albumReleaseIds);
List<Track> GetTracksByForeignReleaseId(string foreignReleaseId); List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
List<Track> GetTracksByForeignTrackIds(List<string> ids);
List<Track> TracksWithFiles(int artistId); List<Track> TracksWithFiles(int artistId);
List<Track> TracksWithoutFiles(int albumId); List<Track> TracksWithoutFiles(int albumId);
List<Track> GetTracksByFileId(int trackFileId); List<Track> GetTracksByFileId(int trackFileId);
@ -29,7 +28,7 @@ namespace NzbDrone.Core.Music
} }
public class TrackService : ITrackService, public class TrackService : ITrackService,
IHandleAsync<ReleaseDeletedEvent>, IHandle<ReleaseDeletedEvent>,
IHandle<TrackFileDeletedEvent> IHandle<TrackFileDeletedEvent>
{ {
private readonly ITrackRepository _trackRepository; private readonly ITrackRepository _trackRepository;
@ -74,14 +73,9 @@ namespace NzbDrone.Core.Music
return _trackRepository.GetTracksByReleases(albumReleaseIds); return _trackRepository.GetTracksByReleases(albumReleaseIds);
} }
public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId) public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds)
{ {
return _trackRepository.GetTracksByForeignReleaseId(foreignReleaseId); return _trackRepository.GetTracksForRefresh(albumReleaseId, foreignTrackIds);
}
public List<Track> GetTracksByForeignTrackIds(List<string> ids)
{
return _trackRepository.GetTracksByForeignTrackIds(ids);
} }
public List<Track> TracksWithFiles(int artistId) public List<Track> TracksWithFiles(int artistId)
@ -124,7 +118,7 @@ namespace NzbDrone.Core.Music
_trackRepository.SetFileId(tracks); _trackRepository.SetFileId(tracks);
} }
public void HandleAsync(ReleaseDeletedEvent message) public void Handle(ReleaseDeletedEvent message)
{ {
var tracks = GetTracksByRelease(message.Release.Id); var tracks = GetTracksByRelease(message.Release.Id);
_trackRepository.DeleteMany(tracks); _trackRepository.DeleteMany(tracks);

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

@ -8,11 +8,6 @@ using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation; using NzbDrone.Common.Instrumentation;
using NzbDrone.Core.Music; using NzbDrone.Core.Music;
using NzbDrone.Core.Parser.Model; 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 namespace NzbDrone.Core.Parser
{ {
@ -20,14 +15,6 @@ namespace NzbDrone.Core.Parser
{ {
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(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[] private static readonly Regex[] ReportMusicTitleRegex = new[]
{ {
// Track with artist (01 - artist - trackName) // Track with artist (01 - artist - trackName)
@ -229,34 +216,10 @@ namespace NzbDrone.Core.Parser
{ {
var fileInfo = new FileInfo(path); var fileInfo = new FileInfo(path);
ParsedTrackInfo result; ParsedTrackInfo result = null;
if (MediaFiles.MediaFileExtensions.Extensions.Contains(fileInfo.Extension)) Logger.Debug("Attempting to parse track info using directory and file names. {0}", fileInfo.Directory.Name);
{ result = ParseMusicTitle(fileInfo.Directory.Name + " " + fileInfo.Name);
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);
}
if (result == null) if (result == null)
{ {
@ -619,93 +582,6 @@ namespace NzbDrone.Core.Parser
return intermediateTitle; 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) private static ParsedTrackInfo ParseMatchMusicCollection(MatchCollection matchCollection)
{ {
var artistName = matchCollection[0].Groups["artist"].Value./*Removed for cases like Will.I.Am Replace('.', ' ').*/Replace('_', ' '); var artistName = matchCollection[0].Groups["artist"].Value./*Removed for cases like Will.I.Am Replace('.', ' ').*/Replace('_', ' ');

@ -1,6 +1,5 @@
using System; using System;
using System.IO; using System.IO;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using NLog; using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
@ -14,23 +13,6 @@ namespace NzbDrone.Core.Parser
{ {
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(QualityParser)); 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", private static readonly Regex ProperRegex = new Regex(@"\b(?<proper>proper|repack|rerip)\b",
RegexOptions.Compiled | RegexOptions.IgnoreCase); 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 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); RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static QualityModel ParseQuality(string name, string desc, int fileBitrate, int fileSampleSize = 0) public static QualityModel ParseQuality(string name, string desc, int fileBitrate, int fileSampleSize = 0)
@ -67,6 +49,7 @@ namespace NzbDrone.Core.Parser
if (desc.IsNotNullOrWhiteSpace()) if (desc.IsNotNullOrWhiteSpace())
{ {
var descCodec = ParseCodec(desc, ""); var descCodec = ParseCodec(desc, "");
Logger.Trace($"Got codec {descCodec}");
result.Quality = FindQuality(descCodec, fileBitrate, fileSampleSize); result.Quality = FindQuality(descCodec, fileBitrate, fileSampleSize);
@ -83,6 +66,10 @@ namespace NzbDrone.Core.Parser
switch(codec) switch(codec)
{ {
case Codec.MP1:
case Codec.MP2:
result.Quality = Quality.Unknown;
break;
case Codec.MP3VBR: case Codec.MP3VBR:
if (bitrate == BitRate.VBRV0) { result.Quality = Quality.MP3_VBR; } if (bitrate == BitRate.VBRV0) { result.Quality = Quality.MP3_VBR; }
else if (bitrate == BitRate.VBRV2) { result.Quality = Quality.MP3_VBR_V2; } else if (bitrate == BitRate.VBRV2) { result.Quality = Quality.MP3_VBR_V2; }
@ -126,6 +113,7 @@ namespace NzbDrone.Core.Parser
result.Quality = Quality.AAC_VBR; result.Quality = Quality.AAC_VBR;
break; break;
case Codec.OGG: case Codec.OGG:
case Codec.OPUS:
if (bitrate == BitRate.B160) { result.Quality = Quality.VORBIS_Q5; } if (bitrate == BitRate.B160) { result.Quality = Quality.VORBIS_Q5; }
else if (bitrate == BitRate.B192) { result.Quality = Quality.VORBIS_Q6; } else if (bitrate == BitRate.B192) { result.Quality = Quality.VORBIS_Q6; }
else if (bitrate == BitRate.B224) { result.Quality = Quality.VORBIS_Q7; } 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["WAV"].Success) { return Codec.WAV; }
if (match.Groups["AAC"].Success) { return Codec.AAC; } if (match.Groups["AAC"].Success) { return Codec.AAC; }
if (match.Groups["OGG"].Success) { return Codec.OGG; } 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["MP3VBR"].Success) { return Codec.MP3VBR; }
if (match.Groups["MP3CBR"].Success) { return Codec.MP3CBR; } if (match.Groups["MP3CBR"].Success) { return Codec.MP3CBR; }
if (match.Groups["WAVPACK"].Success) { return Codec.WAVPACK; } if (match.Groups["WAVPACK"].Success) { return Codec.WAVPACK; }
@ -218,6 +209,9 @@ namespace NzbDrone.Core.Parser
{ {
switch (codec) switch (codec)
{ {
case Codec.MP1:
case Codec.MP2:
return Quality.Unknown;
case Codec.MP3VBR: case Codec.MP3VBR:
return Quality.MP3_VBR; return Quality.MP3_VBR;
case Codec.MP3CBR: case Codec.MP3CBR:
@ -265,6 +259,14 @@ namespace NzbDrone.Core.Parser
if (bitrate == 320) { return Quality.VORBIS_Q9; } if (bitrate == 320) { return Quality.VORBIS_Q9; }
if (bitrate == 500) { return Quality.VORBIS_Q10; } if (bitrate == 500) { return Quality.VORBIS_Q10; }
return Quality.Unknown; 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: default:
return Quality.Unknown; return Quality.Unknown;
} }
@ -301,6 +303,8 @@ namespace NzbDrone.Core.Parser
public enum Codec public enum Codec
{ {
MP1,
MP2,
MP3CBR, MP3CBR,
MP3VBR, MP3VBR,
FLAC, FLAC,
@ -311,6 +315,7 @@ namespace NzbDrone.Core.Parser
AAC, AAC,
AACVBR, AACVBR,
OGG, OGG,
OPUS,
WAV, WAV,
Unknown Unknown
} }

Loading…
Cancel
Save