mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-10 23:33:38 -07:00
New: Write metadata to tags, with UI for previewing changes (#633)
This commit is contained in:
parent
6548f4b1b7
commit
072f772dc8
82 changed files with 2938 additions and 358 deletions
|
@ -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;
|
||||||
|
|
31
frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js
Normal file
31
frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js
Normal file
|
@ -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;
|
||||||
|
|
||||||
|
|
34
frontend/src/Retag/RetagPreviewModal.js
Normal file
34
frontend/src/Retag/RetagPreviewModal.js
Normal file
|
@ -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;
|
39
frontend/src/Retag/RetagPreviewModalConnector.js
Normal file
39
frontend/src/Retag/RetagPreviewModalConnector.js
Normal file
|
@ -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);
|
24
frontend/src/Retag/RetagPreviewModalContent.css
Normal file
24
frontend/src/Retag/RetagPreviewModalContent.css
Normal file
|
@ -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;
|
||||||
|
}
|
186
frontend/src/Retag/RetagPreviewModalContent.js
Normal file
186
frontend/src/Retag/RetagPreviewModalContent.js
Normal file
|
@ -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;
|
86
frontend/src/Retag/RetagPreviewModalContentConnector.js
Normal file
86
frontend/src/Retag/RetagPreviewModalContentConnector.js
Normal file
|
@ -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);
|
26
frontend/src/Retag/RetagPreviewRow.css
Normal file
26
frontend/src/Retag/RetagPreviewRow.css
Normal file
|
@ -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;
|
||||||
|
}
|
101
frontend/src/Retag/RetagPreviewRow.js
Normal file
101
frontend/src/Retag/RetagPreviewRow.js
Normal file
|
@ -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,
|
||||||
|
|
51
frontend/src/Store/Actions/retagPreviewActions.js
Normal file
51
frontend/src/Store/Actions/retagPreviewActions.js
Normal file
|
@ -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" />
|
||||||
|
|
40
src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs
Normal file
40
src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs
Normal file
|
@ -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");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
53
src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs
Normal file
53
src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
11
src/NzbDrone.Core.Test/Files/Media/LICENSE
Normal file
11
src/NzbDrone.Core.Test/Files/Media/LICENSE
Normal file
|
@ -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/
|
BIN
src/NzbDrone.Core.Test/Files/Media/nin.ape
Normal file
BIN
src/NzbDrone.Core.Test/Files/Media/nin.ape
Normal file
Binary file not shown.
BIN
src/NzbDrone.Core.Test/Files/Media/nin.m4a
Normal file
BIN
src/NzbDrone.Core.Test/Files/Media/nin.m4a
Normal file
Binary file not shown.
BIN
src/NzbDrone.Core.Test/Files/Media/nin.mp2
Normal file
BIN
src/NzbDrone.Core.Test/Files/Media/nin.mp2
Normal file
Binary file not shown.
BIN
src/NzbDrone.Core.Test/Files/Media/nin.opus
Normal file
BIN
src/NzbDrone.Core.Test/Files/Media/nin.opus
Normal file
Binary file not shown.
BIN
src/NzbDrone.Core.Test/Files/Media/nin.wma
Normal file
BIN
src/NzbDrone.Core.Test/Files/Media/nin.wma
Normal file
Binary file not shown.
212
src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs
Normal file
212
src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs
Normal file
|
@ -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,12 +56,12 @@ 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>()))
|
||||||
|
@ -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,11 +46,7 @@ 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>());
|
|
||||||
|
|
||||||
Mocker.GetMock<IAlbumService>()
|
|
||||||
.Setup(s => s.FindById(It.IsAny<List<string>>()))
|
|
||||||
.Returns(new List<Album>());
|
.Returns(new List<Album>());
|
||||||
|
|
||||||
Mocker.GetMock<IProvideArtistInfo>()
|
Mocker.GetMock<IProvideArtistInfo>()
|
||||||
|
|
|
@ -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" />
|
||||||
|
<package id="TagLibSharp" version="2.2.0-beta" targetFramework="net461" />
|
||||||
</packages>
|
</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; }
|
||||||
|
|
10
src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs
Normal file
10
src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs
Normal file
|
@ -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);
|
||||||
|
|
590
src/NzbDrone.Core/MediaFiles/AudioTag.cs
Normal file
590
src/NzbDrone.Core/MediaFiles/AudioTag.cs
Normal file
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
363
src/NzbDrone.Core/MediaFiles/AudioTagService.cs
Normal file
363
src/NzbDrone.Core/MediaFiles/AudioTagService.cs
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs
Normal file
18
src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs
Normal file
|
@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs
Normal file
24
src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs
Normal file
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -48,6 +47,14 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
.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)
|
||||||
{
|
{
|
||||||
return Query
|
return Query
|
||||||
|
|
|
@ -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"}
|
||||||
|
|
15
src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs
Normal file
15
src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs
Normal file
|
@ -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,21 +53,19 @@ namespace NzbDrone.Core.Music
|
||||||
return Query.Where(s => s.ArtistMetadataId == artistMetadataId);
|
return Query.Where(s => s.ArtistMetadataId == artistMetadataId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> foreignIds)
|
||||||
|
{
|
||||||
|
return Query
|
||||||
|
.Where(a => a.ArtistMetadataId == artistMetadataId)
|
||||||
|
.OrWhere($"[ForeignAlbumId] IN ('{string.Join("', '", foreignIds)}')")
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public Album FindById(string foreignAlbumId)
|
public Album FindById(string foreignAlbumId)
|
||||||
{
|
{
|
||||||
return Query.Where(s => s.ForeignAlbumId == foreignAlbumId).SingleOrDefault();
|
return Query.Where(s => s.ForeignAlbumId == foreignAlbumId).SingleOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Album> FindById(List<string> ids)
|
|
||||||
{
|
|
||||||
string query = string.Format("SELECT Albums.* " +
|
|
||||||
"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)
|
||||||
{
|
{
|
||||||
var currentTime = DateTime.UtcNow;
|
var currentTime = DateTime.UtcNow;
|
||||||
|
|
|
@ -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,6 +23,21 @@ namespace NzbDrone.Core.Music
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AlbumRelease FindByForeignReleaseId(string foreignReleaseId)
|
||||||
|
{
|
||||||
|
return Query
|
||||||
|
.Where(x => x.ForeignReleaseId == foreignReleaseId)
|
||||||
|
.SingleOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds)
|
||||||
|
{
|
||||||
|
return Query
|
||||||
|
.Where(r => r.AlbumId == albumId)
|
||||||
|
.OrWhere($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')")
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public List<AlbumRelease> FindByAlbum(int id)
|
public List<AlbumRelease> FindByAlbum(int id)
|
||||||
{
|
{
|
||||||
// populate the albums and artist metadata also
|
// populate the albums and artist metadata also
|
||||||
|
@ -33,15 +49,6 @@ namespace NzbDrone.Core.Music
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AlbumRelease> FindByForeignReleaseId(List<string> foreignReleaseIds)
|
|
||||||
{
|
|
||||||
return Query
|
|
||||||
.Join<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id)
|
|
||||||
.Join<Album, ArtistMetadata>(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id)
|
|
||||||
.Where($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')")
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<AlbumRelease> FindByRecordingId(List<string> recordingIds)
|
public List<AlbumRelease> FindByRecordingId(List<string> recordingIds)
|
||||||
{
|
{
|
||||||
var query = "SELECT DISTINCT AlbumReleases.*" +
|
var query = "SELECT DISTINCT AlbumReleases.*" +
|
||||||
|
|
|
@ -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> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds)
|
||||||
|
{
|
||||||
|
return _releaseRepository.GetReleasesForRefresh(albumId, foreignReleaseIds);
|
||||||
|
}
|
||||||
|
|
||||||
public List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId)
|
public List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId)
|
||||||
{
|
{
|
||||||
return _releaseRepository.FindByAlbum(releaseGroupId);
|
return _releaseRepository.FindByAlbum(releaseGroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AlbumRelease> GetReleasesByForeignReleaseId(List<string> foreignReleaseIds)
|
|
||||||
{
|
|
||||||
return _releaseRepository.FindByForeignReleaseId(foreignReleaseIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
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…
Add table
Add a link
Reference in a new issue