mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-10 15:23:40 -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 DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
||||
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import styles from './HistoryDetails.css';
|
||||
|
||||
function getDetailedList(statusMessages) {
|
||||
|
@ -36,6 +38,19 @@ function getDetailedList(statusMessages) {
|
|||
);
|
||||
}
|
||||
|
||||
function formatMissing(value) {
|
||||
if (value === undefined || value === 0 || value === '0') {
|
||||
return (<Icon name={icons.BAN} size={12} />);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatChange(oldValue, newValue) {
|
||||
return (
|
||||
<div> {formatMissing(oldValue)} <Icon name={icons.ARROW_RIGHT_NO_CIRCLE} size={12} /> {formatMissing(newValue)} </div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryDetails(props) {
|
||||
const {
|
||||
eventType,
|
||||
|
@ -259,6 +274,37 @@ function HistoryDetails(props) {
|
|||
);
|
||||
}
|
||||
|
||||
if (eventType === 'trackFileRetagged') {
|
||||
const {
|
||||
diff,
|
||||
tagsScrubbed
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
title="Path"
|
||||
data={sourceTitle}
|
||||
/>
|
||||
{
|
||||
JSON.parse(diff).map(({ field, oldValue, newValue }) => {
|
||||
return (
|
||||
<DescriptionListItem
|
||||
key={field}
|
||||
title={field}
|
||||
data={formatChange(oldValue, newValue)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
<DescriptionListItem
|
||||
title="Existing tags scrubbed"
|
||||
data={tagsScrubbed === 'True' ? <Icon name={icons.CHECK} /> : <Icon name={icons.REMOVE} />}
|
||||
/>
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'albumImportIncomplete') {
|
||||
const {
|
||||
statusMessages
|
||||
|
|
|
@ -23,6 +23,8 @@ function getHeaderTitle(eventType) {
|
|||
return 'Track File Deleted';
|
||||
case 'trackFileRenamed':
|
||||
return 'Track File Renamed';
|
||||
case 'trackFileRetagged':
|
||||
return 'Track File Tags Updated';
|
||||
case 'albumImportIncomplete':
|
||||
return 'Album Import Incomplete';
|
||||
case 'downloadImported':
|
||||
|
|
|
@ -19,6 +19,8 @@ function getIconName(eventType) {
|
|||
return icons.DELETE;
|
||||
case 'trackFileRenamed':
|
||||
return icons.ORGANIZE;
|
||||
case 'trackFileRetagged':
|
||||
return icons.RETAG;
|
||||
case 'albumImportIncomplete':
|
||||
return icons.DOWNLOADED;
|
||||
case 'downloadImported':
|
||||
|
@ -53,6 +55,8 @@ function getTooltip(eventType, data) {
|
|||
return 'Track file deleted';
|
||||
case 'trackFileRenamed':
|
||||
return 'Track file renamed';
|
||||
case 'trackFileRetagged':
|
||||
return 'Track file tags updated';
|
||||
case 'albumImportIncomplete':
|
||||
return 'Files downloaded but not all could be imported';
|
||||
case 'downloadImported':
|
||||
|
|
|
@ -16,6 +16,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
|
|||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import AlbumCover from 'Album/AlbumCover';
|
||||
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
||||
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
|
||||
import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
|
@ -82,6 +83,7 @@ class AlbumDetails extends Component {
|
|||
|
||||
this.state = {
|
||||
isOrganizeModalOpen: false,
|
||||
isRetagModalOpen: false,
|
||||
isArtistHistoryModalOpen: false,
|
||||
isInteractiveSearchModalOpen: false,
|
||||
isManageTracksOpen: false,
|
||||
|
@ -103,6 +105,14 @@ class AlbumDetails extends Component {
|
|||
this.setState({ isOrganizeModalOpen: false });
|
||||
}
|
||||
|
||||
onRetagPress = () => {
|
||||
this.setState({ isRetagModalOpen: true });
|
||||
}
|
||||
|
||||
onRetagModalClose = () => {
|
||||
this.setState({ isRetagModalOpen: false });
|
||||
}
|
||||
|
||||
onEditAlbumPress = () => {
|
||||
this.setState({ isEditAlbumModalOpen: true });
|
||||
}
|
||||
|
@ -193,6 +203,7 @@ class AlbumDetails extends Component {
|
|||
|
||||
const {
|
||||
isOrganizeModalOpen,
|
||||
isRetagModalOpen,
|
||||
isArtistHistoryModalOpen,
|
||||
isInteractiveSearchModalOpen,
|
||||
isEditAlbumModalOpen,
|
||||
|
@ -235,6 +246,12 @@ class AlbumDetails extends Component {
|
|||
onPress={this.onOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label="Preview Retag"
|
||||
iconName={icons.RETAG}
|
||||
onPress={this.onRetagPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label="Manage Tracks"
|
||||
iconName={icons.TRACK_FILE}
|
||||
|
@ -495,6 +512,13 @@ class AlbumDetails extends Component {
|
|||
onModalClose={this.onOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<RetagPreviewModalConnector
|
||||
isOpen={isRetagModalOpen}
|
||||
artistId={artist.id}
|
||||
albumId={id}
|
||||
onModalClose={this.onRetagModalClose}
|
||||
/>
|
||||
|
||||
<TrackFileEditorModal
|
||||
isOpen={isManageTracksOpen}
|
||||
artistId={artist.id}
|
||||
|
|
|
@ -23,6 +23,7 @@ import Popover from 'Components/Tooltip/Popover';
|
|||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
|
||||
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
||||
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import ArtistPoster from 'Artist/ArtistPoster';
|
||||
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
|
||||
|
@ -66,6 +67,7 @@ class ArtistDetails extends Component {
|
|||
|
||||
this.state = {
|
||||
isOrganizeModalOpen: false,
|
||||
isRetagModalOpen: false,
|
||||
isManageTracksOpen: false,
|
||||
isEditArtistModalOpen: false,
|
||||
isDeleteArtistModalOpen: false,
|
||||
|
@ -89,6 +91,14 @@ class ArtistDetails extends Component {
|
|||
this.setState({ isOrganizeModalOpen: false });
|
||||
}
|
||||
|
||||
onRetagPress = () => {
|
||||
this.setState({ isRetagModalOpen: true });
|
||||
}
|
||||
|
||||
onRetagModalClose = () => {
|
||||
this.setState({ isRetagModalOpen: false });
|
||||
}
|
||||
|
||||
onManageTracksPress = () => {
|
||||
this.setState({ isManageTracksOpen: true });
|
||||
}
|
||||
|
@ -207,6 +217,7 @@ class ArtistDetails extends Component {
|
|||
|
||||
const {
|
||||
isOrganizeModalOpen,
|
||||
isRetagModalOpen,
|
||||
isManageTracksOpen,
|
||||
isEditArtistModalOpen,
|
||||
isDeleteArtistModalOpen,
|
||||
|
@ -276,6 +287,12 @@ class ArtistDetails extends Component {
|
|||
onPress={this.onOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label="Preview Retag"
|
||||
iconName={icons.RETAG}
|
||||
onPress={this.onRetagPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label="Manage Tracks"
|
||||
iconName={icons.TRACK_FILE}
|
||||
|
@ -600,6 +617,12 @@ class ArtistDetails extends Component {
|
|||
onModalClose={this.onOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<RetagPreviewModalConnector
|
||||
isOpen={isRetagModalOpen}
|
||||
artistId={id}
|
||||
onModalClose={this.onRetagModalClose}
|
||||
/>
|
||||
|
||||
<TrackFileEditorModal
|
||||
isOpen={isManageTracksOpen}
|
||||
artistId={id}
|
||||
|
|
|
@ -14,6 +14,7 @@ import Table from 'Components/Table/Table';
|
|||
import TableBody from 'Components/Table/TableBody';
|
||||
import NoArtist from 'Artist/NoArtist';
|
||||
import OrganizeArtistModal from './Organize/OrganizeArtistModal';
|
||||
import RetagArtistModal from './AudioTags/RetagArtistModal';
|
||||
import ArtistEditorRowConnector from './ArtistEditorRowConnector';
|
||||
import ArtistEditorFooter from './ArtistEditorFooter';
|
||||
import ArtistEditorFilterModalConnector from './ArtistEditorFilterModalConnector';
|
||||
|
@ -84,6 +85,7 @@ class ArtistEditor extends Component {
|
|||
lastToggled: null,
|
||||
selectedState: {},
|
||||
isOrganizingArtistModalOpen: false,
|
||||
isRetaggingArtistModalOpen: false,
|
||||
columns: getColumns(props.showLanguageProfile, props.showMetadataProfile)
|
||||
};
|
||||
}
|
||||
|
@ -142,6 +144,18 @@ class ArtistEditor extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
onRetagArtistPress = () => {
|
||||
this.setState({ isRetaggingArtistModalOpen: true });
|
||||
}
|
||||
|
||||
onRetagArtistModalClose = (organized) => {
|
||||
this.setState({ isRetaggingArtistModalOpen: false });
|
||||
|
||||
if (organized === true) {
|
||||
this.onSelectAllChange({ value: false });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
|
@ -162,6 +176,7 @@ class ArtistEditor extends Component {
|
|||
isDeleting,
|
||||
deleteError,
|
||||
isOrganizingArtist,
|
||||
isRetaggingArtist,
|
||||
showLanguageProfile,
|
||||
showMetadataProfile,
|
||||
onSortPress,
|
||||
|
@ -250,10 +265,12 @@ class ArtistEditor extends Component {
|
|||
isDeleting={isDeleting}
|
||||
deleteError={deleteError}
|
||||
isOrganizingArtist={isOrganizingArtist}
|
||||
isRetaggingArtist={isRetaggingArtist}
|
||||
showLanguageProfile={showLanguageProfile}
|
||||
showMetadataProfile={showMetadataProfile}
|
||||
onSaveSelected={this.onSaveSelected}
|
||||
onOrganizeArtistPress={this.onOrganizeArtistPress}
|
||||
onRetagArtistPress={this.onRetagArtistPress}
|
||||
/>
|
||||
|
||||
<OrganizeArtistModal
|
||||
|
@ -261,6 +278,13 @@ class ArtistEditor extends Component {
|
|||
artistIds={selectedArtistIds}
|
||||
onModalClose={this.onOrganizeArtistModalClose}
|
||||
/>
|
||||
|
||||
<RetagArtistModal
|
||||
isOpen={this.state.isRetaggingArtistModalOpen}
|
||||
artistIds={selectedArtistIds}
|
||||
onModalClose={this.onRetagArtistModalClose}
|
||||
/>
|
||||
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
@ -282,6 +306,7 @@ ArtistEditor.propTypes = {
|
|||
isDeleting: PropTypes.bool.isRequired,
|
||||
deleteError: PropTypes.object,
|
||||
isOrganizingArtist: PropTypes.bool.isRequired,
|
||||
isRetaggingArtist: PropTypes.bool.isRequired,
|
||||
showLanguageProfile: PropTypes.bool.isRequired,
|
||||
showMetadataProfile: PropTypes.bool.isRequired,
|
||||
onSortPress: PropTypes.func.isRequired,
|
||||
|
|
|
@ -16,9 +16,11 @@ function createMapStateToProps() {
|
|||
(state) => state.settings.metadataProfiles,
|
||||
createClientSideCollectionSelector('artist', 'artistEditor'),
|
||||
createCommandExecutingSelector(commandNames.RENAME_ARTIST),
|
||||
(languageProfiles, metadataProfiles, artist, isOrganizingArtist) => {
|
||||
createCommandExecutingSelector(commandNames.RETAG_ARTIST),
|
||||
(languageProfiles, metadataProfiles, artist, isOrganizingArtist, isRetaggingArtist) => {
|
||||
return {
|
||||
isOrganizingArtist,
|
||||
isRetaggingArtist,
|
||||
showLanguageProfile: languageProfiles.items.length > 1,
|
||||
showMetadataProfile: metadataProfiles.items.length > 1,
|
||||
...artist
|
||||
|
|
|
@ -145,9 +145,11 @@ class ArtistEditorFooter extends Component {
|
|||
isSaving,
|
||||
isDeleting,
|
||||
isOrganizingArtist,
|
||||
isRetaggingArtist,
|
||||
showLanguageProfile,
|
||||
showMetadataProfile,
|
||||
onOrganizeArtistPress
|
||||
onOrganizeArtistPress,
|
||||
onRetagArtistPress
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
|
@ -288,19 +290,29 @@ class ArtistEditorFooter extends Component {
|
|||
className={styles.organizeSelectedButton}
|
||||
kind={kinds.WARNING}
|
||||
isSpinning={isOrganizingArtist}
|
||||
isDisabled={!selectedCount || isOrganizingArtist}
|
||||
isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
|
||||
onPress={onOrganizeArtistPress}
|
||||
>
|
||||
Rename Files
|
||||
</SpinnerButton>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.organizeSelectedButton}
|
||||
kind={kinds.WARNING}
|
||||
isSpinning={isRetaggingArtist}
|
||||
isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
|
||||
onPress={onRetagArtistPress}
|
||||
>
|
||||
Write Metadata Tags
|
||||
</SpinnerButton>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.tagsButton}
|
||||
isSpinning={isSaving && savingTags}
|
||||
isDisabled={!selectedCount || isOrganizingArtist}
|
||||
isDisabled={!selectedCount || isOrganizingArtist || isRetaggingArtist}
|
||||
onPress={this.onTagsPress}
|
||||
>
|
||||
Set Tags
|
||||
Set Lidarr Tags
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
|
||||
|
@ -350,10 +362,12 @@ ArtistEditorFooter.propTypes = {
|
|||
isDeleting: PropTypes.bool.isRequired,
|
||||
deleteError: PropTypes.object,
|
||||
isOrganizingArtist: PropTypes.bool.isRequired,
|
||||
isRetaggingArtist: PropTypes.bool.isRequired,
|
||||
showLanguageProfile: PropTypes.bool.isRequired,
|
||||
showMetadataProfile: PropTypes.bool.isRequired,
|
||||
onSaveSelected: PropTypes.func.isRequired,
|
||||
onOrganizeArtistPress: PropTypes.func.isRequired
|
||||
onOrganizeArtistPress: PropTypes.func.isRequired,
|
||||
onRetagArtistPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ArtistEditorFooter;
|
||||
|
|
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 RENAME_FILES = 'RenameFiles';
|
||||
export const RENAME_ARTIST = 'RenameArtist';
|
||||
export const RETAG_FILES = 'RetagFiles';
|
||||
export const RETAG_ARTIST = 'RetagArtist';
|
||||
export const RESET_API_KEY = 'ResetApiKey';
|
||||
export const RSS_SYNC = 'RssSync';
|
||||
export const SEASON_SEARCH = 'AlbumSearch';
|
||||
|
|
|
@ -25,7 +25,9 @@ import {
|
|||
faArrowCircleLeft as fasArrowCircleLeft,
|
||||
faArrowCircleRight as fasArrowCircleRight,
|
||||
faArrowCircleUp as fasArrowCircleUp,
|
||||
faLongArrowAltRight as fasLongArrowAltRight,
|
||||
faBackward as fasBackward,
|
||||
faBan as fasBan,
|
||||
faBars as fasBars,
|
||||
faBolt as fasBolt,
|
||||
faBookmark as fasBookmark,
|
||||
|
@ -47,6 +49,7 @@ import {
|
|||
faCopy as fasCopy,
|
||||
faDesktop as fasDesktop,
|
||||
faDownload as fasDownload,
|
||||
faEdit as fasEdit,
|
||||
faEllipsisH as fasEllipsisH,
|
||||
faExclamationCircle as fasExclamationCircle,
|
||||
faExclamationTriangle as fasExclamationTriangle,
|
||||
|
@ -111,8 +114,10 @@ export const ALTERNATE_TITLES = farClone;
|
|||
export const ADVANCED_SETTINGS = fasCog;
|
||||
export const ARROW_LEFT = fasArrowCircleLeft;
|
||||
export const ARROW_RIGHT = fasArrowCircleRight;
|
||||
export const ARROW_RIGHT_NO_CIRCLE = fasLongArrowAltRight;
|
||||
export const ARROW_UP = fasArrowCircleUp;
|
||||
export const BACKUP = farFileArchive;
|
||||
export const BAN = fasBan;
|
||||
export const BUG = fasBug;
|
||||
export const CALENDAR = fasCalendarAlt;
|
||||
export const CALENDAR_O = farCalendar;
|
||||
|
@ -176,9 +181,10 @@ export const QUEUED = fasCloud;
|
|||
export const QUICK = fasRocket;
|
||||
export const REFRESH = fasSync;
|
||||
export const REMOVE = fasTimes;
|
||||
export const REORDER = fasBars;
|
||||
export const RESTART = fasRedoAlt;
|
||||
export const RESTORE = fasHistory;
|
||||
export const REORDER = fasBars;
|
||||
export const RETAG = fasEdit;
|
||||
export const RSS = fasRss;
|
||||
export const SAVE = fasSave;
|
||||
export const SCHEDULED = farClock;
|
||||
|
|
|
@ -74,7 +74,6 @@ class OrganizePreviewModalContent extends Component {
|
|||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
renameTracks,
|
||||
trackFormat,
|
||||
path,
|
||||
onModalClose
|
||||
|
@ -107,13 +106,7 @@ class OrganizePreviewModalContent extends Component {
|
|||
|
||||
{
|
||||
!isFetching && isPopulated && !items.length &&
|
||||
<div>
|
||||
{
|
||||
renameTracks ?
|
||||
<div>Success! My work is done, no files to rename.</div> :
|
||||
<div>Renaming is disabled, nothing to rename</div>
|
||||
}
|
||||
</div>
|
||||
<div>Success! My work is done, no files to rename.</div>
|
||||
}
|
||||
|
||||
{
|
||||
|
@ -191,7 +184,6 @@ OrganizePreviewModalContent.propTypes = {
|
|||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
renameTracks: PropTypes.bool,
|
||||
trackFormat: PropTypes.string,
|
||||
onOrganizePress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
|
|
|
@ -19,7 +19,6 @@ function createMapStateToProps() {
|
|||
props.isFetching = organizePreview.isFetching || naming.isFetching;
|
||||
props.isPopulated = organizePreview.isPopulated && naming.isPopulated;
|
||||
props.error = organizePreview.error || naming.error;
|
||||
props.renameTracks = naming.item.renameTracks;
|
||||
props.trackFormat = naming.item.standardTrackFormat;
|
||||
props.path = artist.path;
|
||||
|
||||
|
|
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 FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
||||
const writeAudioTagOptions = [
|
||||
{ key: 'sync', value: 'All files; keep in sync with MusicBrainz' },
|
||||
{ key: 'allFiles', value: 'All files; initial import only' },
|
||||
{ key: 'newFiles', value: 'For new downloads only' },
|
||||
{ key: 'no', value: 'Never' }
|
||||
];
|
||||
|
||||
function MetadataProvider(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
|
@ -54,6 +61,35 @@ function MetadataProvider(props) {
|
|||
</FormGroup>
|
||||
</FieldSet>
|
||||
}
|
||||
|
||||
<FieldSet legend="Write Metadata to Audio Files">
|
||||
<FormGroup>
|
||||
<FormLabel>Tag Audio Files with Metadata</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="writeAudioTags"
|
||||
helpTextWarning="Selecting 'All files' will alter existing files when they are imported."
|
||||
helpLink="https://github.com/Lidarr/Lidarr/wiki/Write-Tags"
|
||||
values={writeAudioTagOptions}
|
||||
onChange={onInputChange}
|
||||
{...settings.writeAudioTags}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Scrub Existing Tags</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="scrubAudioTags"
|
||||
helpText="Remove existing tags from files, leaving only those added by Lidarr."
|
||||
onChange={onInputChange}
|
||||
{...settings.scrubAudioTags}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
</FieldSet>
|
||||
</Form>
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -55,11 +55,11 @@ class MetadataSettings extends Component {
|
|||
/>
|
||||
|
||||
<PageContentBodyConnector>
|
||||
<MetadatasConnector />
|
||||
<MetadataProviderConnector
|
||||
onChildMounted={this.onChildMounted}
|
||||
onChildStateChange={this.onChildStateChange}
|
||||
/>
|
||||
<MetadatasConnector />
|
||||
</PageContentBodyConnector>
|
||||
</PageContent>
|
||||
);
|
||||
|
|
|
@ -173,6 +173,17 @@ export const defaultState = {
|
|||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'retagged',
|
||||
label: 'Retagged',
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
value: '9',
|
||||
type: filterTypes.EQUAL
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import * as importArtist from './importArtistActions';
|
|||
import * as interactiveImportActions from './interactiveImportActions';
|
||||
import * as oAuth from './oAuthActions';
|
||||
import * as organizePreview from './organizePreviewActions';
|
||||
import * as retagPreview from './retagPreviewActions';
|
||||
import * as paths from './pathActions';
|
||||
import * as queue from './queueActions';
|
||||
import * as releases from './releaseActions';
|
||||
|
@ -46,6 +47,7 @@ export default [
|
|||
interactiveImportActions,
|
||||
oAuth,
|
||||
organizePreview,
|
||||
retagPreview,
|
||||
paths,
|
||||
queue,
|
||||
releases,
|
||||
|
|
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
|
||||
{
|
||||
//Calendar
|
||||
public string MetadataSource { get; set; }
|
||||
|
||||
public WriteAudioTagsType WriteAudioTags { get; set; }
|
||||
public bool ScrubAudioTags { get; set; }
|
||||
}
|
||||
|
||||
public static class MetadataProviderConfigResourceMapper
|
||||
|
@ -17,7 +17,8 @@ namespace Lidarr.Api.V1.Config
|
|||
return new MetadataProviderConfigResource
|
||||
{
|
||||
MetadataSource = model.MetadataSource,
|
||||
|
||||
WriteAudioTags = model.WriteAudioTags,
|
||||
ScrubAudioTags = model.ScrubAudioTags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -160,6 +160,8 @@
|
|||
<Compile Include="Tracks\TrackResource.cs" />
|
||||
<Compile Include="Tracks\RenameTrackModule.cs" />
|
||||
<Compile Include="Tracks\RenameTrackResource.cs" />
|
||||
<Compile Include="Tracks\RetagTrackModule.cs" />
|
||||
<Compile Include="Tracks\RetagTrackResource.cs" />
|
||||
<Compile Include="Health\HealthModule.cs" />
|
||||
<Compile Include="Health\HealthResource.cs" />
|
||||
<Compile Include="History\HistoryModule.cs" />
|
||||
|
|
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);
|
||||
|
||||
Mocker.GetMock<IReleaseService>()
|
||||
.Setup(x => x.GetReleasesByForeignReleaseId(new List<string>{ "xxx" }))
|
||||
.Returns(new List<AlbumRelease> { release });
|
||||
.Setup(x => x.GetReleaseByForeignReleaseId("xxx"))
|
||||
.Returns(release);
|
||||
|
||||
Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null).ShouldBeEquivalentTo(new List<AlbumRelease> { release });
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ using NzbDrone.Core.Test.Framework;
|
|||
using NzbDrone.Core.Music;
|
||||
using NzbDrone.Core.Music.Commands;
|
||||
using NzbDrone.Test.Common;
|
||||
using FluentAssertions;
|
||||
using NzbDrone.Common.Serializer;
|
||||
|
||||
namespace NzbDrone.Core.Test.MusicTests
|
||||
{
|
||||
|
@ -54,12 +56,12 @@ namespace NzbDrone.Core.Test.MusicTests
|
|||
.Returns(_artist);
|
||||
|
||||
Mocker.GetMock<IReleaseService>()
|
||||
.Setup(s => s.GetReleasesByAlbum(album1.Id))
|
||||
.Setup(s => s.GetReleasesForRefresh(album1.Id, It.IsAny<IEnumerable<string>>()))
|
||||
.Returns(new List<AlbumRelease> { release });
|
||||
|
||||
Mocker.GetMock<IReleaseService>()
|
||||
.Setup(s => s.GetReleasesByForeignReleaseId(It.IsAny<List<string>>()))
|
||||
.Returns(new List<AlbumRelease> { release });
|
||||
Mocker.GetMock<IArtistMetadataRepository>()
|
||||
.Setup(s => s.FindById(It.IsAny<List<string>>()))
|
||||
.Returns(new List<ArtistMetadata>());
|
||||
|
||||
Mocker.GetMock<IProvideAlbumInfo>()
|
||||
.Setup(s => s.GetAlbumInfo(It.IsAny<string>()))
|
||||
|
@ -80,7 +82,7 @@ namespace NzbDrone.Core.Test.MusicTests
|
|||
[Test]
|
||||
public void should_log_error_if_musicbrainz_id_not_found()
|
||||
{
|
||||
Subject.RefreshAlbumInfo(_albums, false);
|
||||
Subject.RefreshAlbumInfo(_albums, false, false);
|
||||
|
||||
Mocker.GetMock<IAlbumService>()
|
||||
.Verify(v => v.UpdateMany(It.IsAny<List<Album>>()), Times.Never());
|
||||
|
@ -97,12 +99,56 @@ namespace NzbDrone.Core.Test.MusicTests
|
|||
|
||||
GivenNewAlbumInfo(newAlbumInfo);
|
||||
|
||||
Subject.RefreshAlbumInfo(_albums, false);
|
||||
Subject.RefreshAlbumInfo(_albums, false, false);
|
||||
|
||||
Mocker.GetMock<IAlbumService>()
|
||||
.Verify(v => v.UpdateMany(It.Is<List<Album>>(s => s.First().ForeignAlbumId == newAlbumInfo.ForeignAlbumId)));
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void two_equivalent_releases_should_be_equal()
|
||||
{
|
||||
var release = Builder<AlbumRelease>.CreateNew().Build();
|
||||
var release2 = Builder<AlbumRelease>.CreateNew().Build();
|
||||
|
||||
ReferenceEquals(release, release2).Should().BeFalse();
|
||||
release.Equals(release2).Should().BeTrue();
|
||||
|
||||
release.Label?.ToJson().Should().Be(release2.Label?.ToJson());
|
||||
release.Country?.ToJson().Should().Be(release2.Country?.ToJson());
|
||||
release.Media?.ToJson().Should().Be(release2.Media?.ToJson());
|
||||
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void two_equivalent_tracks_should_be_equal()
|
||||
{
|
||||
var track = Builder<Track>.CreateNew().Build();
|
||||
var track2 = Builder<Track>.CreateNew().Build();
|
||||
|
||||
ReferenceEquals(track, track2).Should().BeFalse();
|
||||
track.Equals(track2).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void two_equivalent_metadata_should_be_equal()
|
||||
{
|
||||
var meta = Builder<ArtistMetadata>.CreateNew().Build();
|
||||
var meta2 = Builder<ArtistMetadata>.CreateNew().Build();
|
||||
|
||||
ReferenceEquals(meta, meta2).Should().BeFalse();
|
||||
meta.Equals(meta2).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_remove_items_from_list()
|
||||
{
|
||||
var releases = Builder<AlbumRelease>.CreateListOfSize(2).Build();
|
||||
var release = releases[0];
|
||||
releases.Remove(release);
|
||||
releases.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,11 +46,7 @@ namespace NzbDrone.Core.Test.MusicTests
|
|||
.Returns(_artist);
|
||||
|
||||
Mocker.GetMock<IAlbumService>()
|
||||
.Setup(s => s.GetAlbumsByArtist(It.IsAny<int>()))
|
||||
.Returns(new List<Album>());
|
||||
|
||||
Mocker.GetMock<IAlbumService>()
|
||||
.Setup(s => s.FindById(It.IsAny<List<string>>()))
|
||||
.Setup(s => s.GetAlbumsForRefresh(It.IsAny<int>(), It.IsAny<IEnumerable<string>>()))
|
||||
.Returns(new List<Album>());
|
||||
|
||||
Mocker.GetMock<IProvideArtistInfo>()
|
||||
|
|
|
@ -87,6 +87,10 @@
|
|||
<Reference Include="Prowlin, Version=0.9.4456.26422, Culture=neutral, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="taglib-sharp, Version=2.2.0.0, Culture=neutral, PublicKeyToken=db62eba44689b5b0, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\TagLibSharp.2.2.0-beta\lib\netstandard2.0\taglib-sharp.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Drawing" />
|
||||
|
@ -281,6 +285,7 @@
|
|||
<Compile Include="MediaCoverTests\CoverExistsSpecificationFixture.cs" />
|
||||
<Compile Include="MediaCoverTests\ImageResizerFixture.cs" />
|
||||
<Compile Include="MediaCoverTests\MediaCoverServiceFixture.cs" />
|
||||
<Compile Include="MediaFiles\AudioTagServiceFixture.cs" />
|
||||
<Compile Include="MediaFiles\DiskScanServiceTests\ScanFixture.cs" />
|
||||
<Compile Include="MediaFiles\DownloadedAlbumsCommandServiceFixture.cs" />
|
||||
<Compile Include="MediaFiles\DownloadedTracksImportServiceFixture.cs" />
|
||||
|
@ -502,7 +507,7 @@
|
|||
<Content Include="Files\LongOverview.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Files\Media\H264_sample.mp4">
|
||||
<Content Include="Files\Media\nin.mp2">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Files\Media\nin.mp3">
|
||||
|
@ -511,6 +516,18 @@
|
|||
<Content Include="Files\Media\nin.flac">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Files\Media\nin.m4a">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Files\Media\nin.wma">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Files\Media\nin.ape">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Files\Media\nin.opus">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Files\Nzbget\JsonError.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
|
|
@ -183,12 +183,14 @@ namespace NzbDrone.Core.Test.ParserTests
|
|||
}
|
||||
|
||||
[TestCase("", "MPEG-4 Audio (mp4a)", 320)]
|
||||
[TestCase("", "MPEG-4 Audio (drms)", 320)]
|
||||
public void should_parse_aac_320_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_320);
|
||||
}
|
||||
|
||||
|
||||
[TestCase("", "MPEG-4 Audio (mp4a)", 321)]
|
||||
[TestCase("", "MPEG-4 Audio (drms)", 321)]
|
||||
public void should_parse_aac_vbr_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_VBR);
|
||||
|
@ -196,12 +198,14 @@ namespace NzbDrone.Core.Test.ParserTests
|
|||
|
||||
[TestCase("Kirlian Camera - The Ice Curtain - Album 1998 - Ogg-Vorbis Q10", null, 0)]
|
||||
[TestCase("", "Vorbis Version 0 Audio", 500)]
|
||||
[TestCase("", "Opus Version 1 Audio", 501)]
|
||||
public void should_parse_vorbis_q10_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q10);
|
||||
}
|
||||
|
||||
[TestCase("", "Vorbis Version 0 Audio", 320)]
|
||||
[TestCase("", "Opus Version 1 Audio", 321)]
|
||||
public void should_parse_vorbis_q9_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q9);
|
||||
|
@ -209,6 +213,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
|||
|
||||
[TestCase("Various Artists - No New York [1978/Ogg/q8]", null, 0)]
|
||||
[TestCase("", "Vorbis Version 0 Audio", 256)]
|
||||
[TestCase("", "Opus Version 1 Audio", 257)]
|
||||
public void should_parse_vorbis_q8_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q8);
|
||||
|
@ -216,18 +221,21 @@ namespace NzbDrone.Core.Test.ParserTests
|
|||
|
||||
[TestCase("Masters_At_Work-Nuyorican_Soul-.Talkin_Loud.-1997-OGG.Q7", null, 0)]
|
||||
[TestCase("", "Vorbis Version 0 Audio", 224)]
|
||||
[TestCase("", "Opus Version 1 Audio", 225)]
|
||||
public void should_parse_vorbis_q7_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q7);
|
||||
}
|
||||
|
||||
[TestCase("", "Vorbis Version 0 Audio", 192)]
|
||||
[TestCase("", "Opus Version 1 Audio", 193)]
|
||||
public void should_parse_vorbis_q6_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q6);
|
||||
}
|
||||
|
||||
[TestCase("", "Vorbis Version 0 Audio", 160)]
|
||||
[TestCase("", "Opus Version 1 Audio", 161)]
|
||||
public void should_parse_vorbis_q5_quality(string title, string desc, int bitrate)
|
||||
{
|
||||
ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q5);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="AutoMoq" version="1.8.1.0" targetFramework="net461" />
|
||||
<package id="CommonServiceLocator" version="1.3" targetFramework="net461" />
|
||||
|
@ -14,4 +14,5 @@
|
|||
<package id="NUnit" version="3.11.0" targetFramework="net461" />
|
||||
<package id="Prowlin" version="0.9.4456.26422" targetFramework="net461" />
|
||||
<package id="Unity" version="2.1.505.2" targetFramework="net461" />
|
||||
<package id="TagLibSharp" version="2.2.0-beta" targetFramework="net461" />
|
||||
</packages>
|
|
@ -265,6 +265,20 @@ namespace NzbDrone.Core.Configuration
|
|||
set { SetValue("MetadataSource", value); }
|
||||
}
|
||||
|
||||
public WriteAudioTagsType WriteAudioTags
|
||||
{
|
||||
get { return GetValueEnum("WriteAudioTags", WriteAudioTagsType.No); }
|
||||
|
||||
set { SetValue("WriteAudioTags", value); }
|
||||
}
|
||||
|
||||
public bool ScrubAudioTags
|
||||
{
|
||||
get { return GetValueBoolean("ScrubAudioTags", false); }
|
||||
|
||||
set { SetValue("ScrubAudioTags", value); }
|
||||
}
|
||||
|
||||
public int FirstDayOfWeek
|
||||
{
|
||||
get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); }
|
||||
|
|
|
@ -69,9 +69,10 @@ namespace NzbDrone.Core.Configuration
|
|||
|
||||
string PlexClientIdentifier { get; }
|
||||
|
||||
//MetadataSource
|
||||
//Metadata
|
||||
string MetadataSource { get; set; }
|
||||
|
||||
WriteAudioTagsType WriteAudioTags { get; set; }
|
||||
bool ScrubAudioTags { get; set; }
|
||||
|
||||
//Forms Auth
|
||||
string RijndaelPassphrase { get; }
|
||||
|
|
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,
|
||||
TrackFileRenamed = 6,
|
||||
AlbumImportIncomplete = 7,
|
||||
DownloadImported = 8
|
||||
DownloadImported = 8,
|
||||
TrackFileRetagged = 9
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ namespace NzbDrone.Core.History
|
|||
IHandle<DownloadCompletedEvent>,
|
||||
IHandle<TrackFileDeletedEvent>,
|
||||
IHandle<TrackFileRenamedEvent>,
|
||||
IHandle<TrackFileRetaggedEvent>,
|
||||
IHandle<ArtistDeletedEvent>
|
||||
{
|
||||
private readonly IHistoryRepository _historyRepository;
|
||||
|
@ -345,6 +346,35 @@ namespace NzbDrone.Core.History
|
|||
}
|
||||
}
|
||||
|
||||
public void Handle(TrackFileRetaggedEvent message)
|
||||
{
|
||||
var path = Path.Combine(message.Artist.Path, message.TrackFile.RelativePath);
|
||||
var relativePath = message.TrackFile.RelativePath;
|
||||
|
||||
foreach (var track in message.TrackFile.Tracks.Value)
|
||||
{
|
||||
var history = new History
|
||||
{
|
||||
EventType = HistoryEventType.TrackFileRetagged,
|
||||
Date = DateTime.UtcNow,
|
||||
Quality = message.TrackFile.Quality,
|
||||
SourceTitle = path,
|
||||
ArtistId = message.TrackFile.Artist.Value.Id,
|
||||
AlbumId = message.TrackFile.AlbumId,
|
||||
TrackId = track.Id,
|
||||
};
|
||||
|
||||
history.Data.Add("TagsScrubbed", message.Scrubbed.ToString());
|
||||
history.Data.Add("Diff", message.Diff.Select(x => new {
|
||||
Field = x.Key,
|
||||
OldValue = x.Value.Item1,
|
||||
NewValue = x.Value.Item2
|
||||
}).ToJson());
|
||||
|
||||
_historyRepository.Insert(history);
|
||||
}
|
||||
}
|
||||
|
||||
public void Handle(ArtistDeletedEvent message)
|
||||
{
|
||||
_historyRepository.DeleteForArtist(message.Artist.Id);
|
||||
|
|
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)
|
||||
{
|
||||
{ ".mp2", Quality.Unknown },
|
||||
{ ".mp3", Quality.Unknown },
|
||||
{ ".m4a", Quality.Unknown },
|
||||
{ ".m4b", Quality.Unknown },
|
||||
{ ".m4p", Quality.Unknown },
|
||||
{ ".ogg", Quality.Unknown },
|
||||
{ ".oga", Quality.Unknown },
|
||||
{ ".opus", Quality.Unknown },
|
||||
{ ".wma", Quality.WMA },
|
||||
{ ".wav", Quality.WAV },
|
||||
{ ".wv" , Quality.WAVPACK },
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Marr.Data.QGen;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
|
@ -12,6 +10,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
{
|
||||
List<TrackFile> GetFilesByArtist(int artistId);
|
||||
List<TrackFile> GetFilesByAlbum(int albumId);
|
||||
List<TrackFile> GetFilesByRelease(int releaseId);
|
||||
List<TrackFile> GetFilesWithRelativePath(int artistId, string relativePath);
|
||||
}
|
||||
|
||||
|
@ -27,10 +26,10 @@ namespace NzbDrone.Core.MediaFiles
|
|||
// needed more often than not so better to load it all now
|
||||
protected override QueryBuilder<TrackFile> Query =>
|
||||
DataMapper.Query<TrackFile>()
|
||||
.Join<TrackFile, Track>(JoinType.Inner, t => t.Tracks, (t, x) => t.Id == x.TrackFileId)
|
||||
.Join<TrackFile, Album>(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id)
|
||||
.Join<TrackFile, Artist>(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId)
|
||||
.Join<Artist, ArtistMetadata>(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id);
|
||||
.Join<TrackFile, Track>(JoinType.Left, t => t.Tracks, (t, x) => t.Id == x.TrackFileId)
|
||||
.Join<TrackFile, Album>(JoinType.Left, t => t.Album, (t, a) => t.AlbumId == a.Id)
|
||||
.Join<TrackFile, Artist>(JoinType.Left, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId)
|
||||
.Join<Artist, ArtistMetadata>(JoinType.Left, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id);
|
||||
|
||||
public List<TrackFile> GetFilesByArtist(int artistId)
|
||||
{
|
||||
|
@ -48,6 +47,14 @@ namespace NzbDrone.Core.MediaFiles
|
|||
.ToList();
|
||||
}
|
||||
|
||||
public List<TrackFile> GetFilesByRelease(int releaseId)
|
||||
{
|
||||
return Query
|
||||
.Where<Track>(x => x.AlbumReleaseId == releaseId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
public List<TrackFile> GetFilesWithRelativePath(int artistId, string relativePath)
|
||||
{
|
||||
return Query
|
||||
|
|
|
@ -20,6 +20,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
void Delete(TrackFile trackFile, DeleteMediaFileReason reason);
|
||||
List<TrackFile> GetFilesByArtist(int artistId);
|
||||
List<TrackFile> GetFilesByAlbum(int albumId);
|
||||
List<TrackFile> GetFilesByRelease(int releaseId);
|
||||
List<string> FilterExistingFiles(List<string> files, Artist artist);
|
||||
TrackFile Get(int id);
|
||||
List<TrackFile> Get(IEnumerable<int> ids);
|
||||
|
@ -115,6 +116,11 @@ namespace NzbDrone.Core.MediaFiles
|
|||
return _mediaFileRepository.GetFilesByAlbum(albumId);
|
||||
}
|
||||
|
||||
public List<TrackFile> GetFilesByRelease(int releaseId)
|
||||
{
|
||||
return _mediaFileRepository.GetFilesByRelease(releaseId);
|
||||
}
|
||||
|
||||
public void UpdateMediaInfo(List<TrackFile> trackFiles)
|
||||
{
|
||||
_mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo);
|
||||
|
|
|
@ -33,6 +33,8 @@ namespace NzbDrone.Core.MediaFiles
|
|||
}
|
||||
|
||||
public static readonly Dictionary<Codec, string> CodecNames = new Dictionary<Codec, string> {
|
||||
{Codec.MP1, "MP1"},
|
||||
{Codec.MP2, "MP2"},
|
||||
{Codec.AAC, "AAC"},
|
||||
{Codec.AACVBR, "AAC"},
|
||||
{Codec.ALAC, "ALAC"},
|
||||
|
@ -41,6 +43,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
{Codec.MP3CBR, "MP3"},
|
||||
{Codec.MP3VBR, "MP3"},
|
||||
{Codec.OGG, "OGG"},
|
||||
{Codec.OPUS, "OPUS"},
|
||||
{Codec.WAV, "PCM"},
|
||||
{Codec.WAVPACK, "WavPack"},
|
||||
{Codec.WMA, "WMA"}
|
||||
|
|
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());
|
||||
|
||||
_logger.Debug($"Got tracks in {watch.ElapsedMilliseconds}ms");
|
||||
_logger.Debug($"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms");
|
||||
|
||||
GetBestRelease(localAlbumRelease, candidateReleases, allTracks);
|
||||
|
||||
|
@ -228,12 +228,16 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Identification
|
|||
|
||||
List<AlbumRelease> candidateReleases;
|
||||
|
||||
// if we have a release ID, use that
|
||||
// if we have a release ID that makes sense, use that
|
||||
var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList();
|
||||
if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace())
|
||||
{
|
||||
_logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]);
|
||||
return _releaseService.GetReleasesByForeignReleaseId(releaseIds);
|
||||
var tagRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0]);
|
||||
if (tagRelease != null)
|
||||
{
|
||||
_logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]);
|
||||
return new List<AlbumRelease> { tagRelease };
|
||||
}
|
||||
}
|
||||
|
||||
if (release != null)
|
||||
|
|
|
@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
{
|
||||
private readonly IUpgradeMediaFiles _trackFileUpgrader;
|
||||
private readonly IMediaFileService _mediaFileService;
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
private readonly ITrackService _trackService;
|
||||
private readonly IRecycleBinProvider _recycleBinProvider;
|
||||
private readonly IExtraService _extraService;
|
||||
|
@ -36,6 +37,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
|
||||
public ImportApprovedTracks(IUpgradeMediaFiles trackFileUpgrader,
|
||||
IMediaFileService mediaFileService,
|
||||
IAudioTagService audioTagService,
|
||||
ITrackService trackService,
|
||||
IRecycleBinProvider recycleBinProvider,
|
||||
IExtraService extraService,
|
||||
|
@ -46,6 +48,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
{
|
||||
_trackFileUpgrader = trackFileUpgrader;
|
||||
_mediaFileService = mediaFileService;
|
||||
_audioTagService = audioTagService;
|
||||
_trackService = trackService;
|
||||
_recycleBinProvider = recycleBinProvider;
|
||||
_extraService = extraService;
|
||||
|
@ -202,6 +205,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
_mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride);
|
||||
}
|
||||
|
||||
_audioTagService.WriteTags(trackFile, newDownload);
|
||||
}
|
||||
|
||||
filesToAdd.Add(trackFile);
|
||||
|
|
|
@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications;
|
||||
private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumSpecifications;
|
||||
private readonly IMediaFileService _mediaFileService;
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
private readonly IAugmentingService _augmentingService;
|
||||
private readonly IIdentificationService _identificationService;
|
||||
private readonly IAlbumService _albumService;
|
||||
|
@ -37,6 +38,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications,
|
||||
IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> albumSpecifications,
|
||||
IMediaFileService mediaFileService,
|
||||
IAudioTagService audioTagService,
|
||||
IAugmentingService augmentingService,
|
||||
IIdentificationService identificationService,
|
||||
IAlbumService albumService,
|
||||
|
@ -48,6 +50,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
_trackSpecifications = trackSpecifications;
|
||||
_albumSpecifications = albumSpecifications;
|
||||
_mediaFileService = mediaFileService;
|
||||
_audioTagService = audioTagService;
|
||||
_augmentingService = augmentingService;
|
||||
_identificationService = identificationService;
|
||||
_albumService = albumService;
|
||||
|
@ -95,7 +98,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport
|
|||
DownloadClientAlbumInfo = downloadClientItemInfo,
|
||||
FolderTrackInfo = folderInfo,
|
||||
Path = file,
|
||||
FileTrackInfo = Parser.Parser.ParseMusicPath(file),
|
||||
FileTrackInfo = _audioTagService.ReadTags(file)
|
||||
};
|
||||
|
||||
try
|
||||
|
|
|
@ -17,18 +17,21 @@ namespace NzbDrone.Core.MediaFiles
|
|||
{
|
||||
private readonly IRecycleBinProvider _recycleBinProvider;
|
||||
private readonly IMediaFileService _mediaFileService;
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
private readonly IMoveTrackFiles _trackFileMover;
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider,
|
||||
IMediaFileService mediaFileService,
|
||||
IAudioTagService audioTagService,
|
||||
IMoveTrackFiles trackFileMover,
|
||||
IDiskProvider diskProvider,
|
||||
Logger logger)
|
||||
{
|
||||
_recycleBinProvider = recycleBinProvider;
|
||||
_mediaFileService = mediaFileService;
|
||||
_audioTagService = audioTagService;
|
||||
_trackFileMover = trackFileMover;
|
||||
_diskProvider = diskProvider;
|
||||
_logger = logger;
|
||||
|
@ -76,6 +79,8 @@ namespace NzbDrone.Core.MediaFiles
|
|||
moveFileResult.TrackFile = _trackFileMover.MoveTrackFile(trackFile, localTrack);
|
||||
}
|
||||
|
||||
_audioTagService.WriteTags(trackFile, true);
|
||||
|
||||
return moveFileResult;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,10 @@ namespace NzbDrone.Core.Music
|
|||
_logger.ProgressInfo("Adding Album {0}", newAlbum.Title);
|
||||
_artistMetadataRepository.UpsertMany(tuple.Item3);
|
||||
_albumService.AddAlbum(newAlbum, tuple.Item1);
|
||||
_refreshTrackService.RefreshTrackInfo(newAlbum);
|
||||
|
||||
// make sure releases are populated for tag writing in the track refresh
|
||||
newAlbum.AlbumReleases.Value.ForEach(x => x.Album = newAlbum);
|
||||
_refreshTrackService.RefreshTrackInfo(newAlbum, false);
|
||||
|
||||
return newAlbum;
|
||||
}
|
||||
|
@ -66,7 +69,10 @@ namespace NzbDrone.Core.Music
|
|||
_logger.ProgressInfo("Adding Album {0}", newAlbum.Title);
|
||||
_artistMetadataRepository.UpsertMany(tuple.Item3);
|
||||
album = _albumService.AddAlbum(album, tuple.Item1);
|
||||
_refreshTrackService.RefreshTrackInfo(album);
|
||||
|
||||
// make sure releases are populated for tag writing in the track refresh
|
||||
album.AlbumReleases.Value.ForEach(x => x.Album = album);
|
||||
_refreshTrackService.RefreshTrackInfo(album, false);
|
||||
albumsToAdd.Add(album);
|
||||
}
|
||||
|
||||
|
|
|
@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music
|
|||
{
|
||||
List<Album> GetAlbums(int artistId);
|
||||
List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId);
|
||||
List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds);
|
||||
Album FindByTitle(int artistMetadataId, string title);
|
||||
Album FindById(string foreignId);
|
||||
List<Album> FindById(List<string> foreignIds);
|
||||
PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec);
|
||||
PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, List<LanguagesBelowCutoff> languagesBelowCutoff);
|
||||
List<Album> AlbumsBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored);
|
||||
|
@ -53,21 +53,19 @@ namespace NzbDrone.Core.Music
|
|||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var currentTime = DateTime.UtcNow;
|
||||
|
|
|
@ -16,9 +16,9 @@ namespace NzbDrone.Core.Music
|
|||
List<Album> GetAlbums(IEnumerable<int> albumIds);
|
||||
List<Album> GetAlbumsByArtist(int artistId);
|
||||
List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId);
|
||||
List<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> foreignIds);
|
||||
Album AddAlbum(Album newAlbum, string albumArtistId);
|
||||
Album FindById(string foreignId);
|
||||
List<Album> FindById(List<string> foreignIds);
|
||||
Album FindByTitle(int artistId, string title);
|
||||
Album FindByTitleInexact(int artistId, string title);
|
||||
List<Album> GetCandidates(int artistId, string title);
|
||||
|
@ -41,7 +41,7 @@ namespace NzbDrone.Core.Music
|
|||
}
|
||||
|
||||
public class AlbumService : IAlbumService,
|
||||
IHandleAsync<ArtistDeletedEvent>
|
||||
IHandle<ArtistDeletedEvent>
|
||||
{
|
||||
private readonly IAlbumRepository _albumRepository;
|
||||
private readonly IReleaseRepository _releaseRepository;
|
||||
|
@ -96,11 +96,6 @@ namespace NzbDrone.Core.Music
|
|||
return _albumRepository.FindById(lidarrId);
|
||||
}
|
||||
|
||||
public List<Album> FindById(List<string> ids)
|
||||
{
|
||||
return _albumRepository.FindById(ids);
|
||||
}
|
||||
|
||||
public Album FindByTitle(int artistId, string title)
|
||||
{
|
||||
return _albumRepository.FindByTitle(artistId, title);
|
||||
|
@ -200,6 +195,11 @@ namespace NzbDrone.Core.Music
|
|||
return _albumRepository.GetAlbumsByArtistMetadataId(artistMetadataId).ToList();
|
||||
}
|
||||
|
||||
public List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds)
|
||||
{
|
||||
return _albumRepository.GetAlbumsForRefresh(artistId, foreignIds);
|
||||
}
|
||||
|
||||
public Album FindAlbumByRelease(string albumReleaseId)
|
||||
{
|
||||
return _albumRepository.FindAlbumByRelease(albumReleaseId);
|
||||
|
@ -300,7 +300,7 @@ namespace NzbDrone.Core.Music
|
|||
return albums;
|
||||
}
|
||||
|
||||
public void HandleAsync(ArtistDeletedEvent message)
|
||||
public void Handle(ArtistDeletedEvent message)
|
||||
{
|
||||
var albums = GetAlbumsByArtistMetadataId(message.Artist.ArtistMetadataId);
|
||||
DeleteMany(albums);
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
using Marr.Data;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Profiles.Qualities;
|
||||
using NzbDrone.Core.Profiles.Languages;
|
||||
using NzbDrone.Core.Profiles.Metadata;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace NzbDrone.Core.Music
|
||||
{
|
||||
public class ArtistMetadata : ModelBase
|
||||
public class ArtistMetadata : ModelBase, IEquatable<ArtistMetadata>
|
||||
{
|
||||
public ArtistMetadata()
|
||||
{
|
||||
|
@ -52,5 +48,70 @@ namespace NzbDrone.Core.Music
|
|||
Ratings = otherArtist.Ratings;
|
||||
Members = otherArtist.Members;
|
||||
}
|
||||
|
||||
public bool Equals(ArtistMetadata other)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Id == other.Id &&
|
||||
ForeignArtistId == other.ForeignArtistId &&
|
||||
Name == other.Name &&
|
||||
Overview == other.Overview &&
|
||||
Disambiguation == other.Disambiguation &&
|
||||
Type == other.Type &&
|
||||
Status == other.Status &&
|
||||
Images?.ToJson() == other.Images?.ToJson() &&
|
||||
Links?.ToJson() == other.Links?.ToJson() &&
|
||||
(Genres?.SequenceEqual(other.Genres) ?? true) &&
|
||||
Ratings?.ToJson() == other.Ratings?.ToJson() &&
|
||||
Members?.ToJson() == other.Members?.ToJson())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var other = obj as ArtistMetadata;
|
||||
if (other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Equals(other);
|
||||
}
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
hash = hash * 23 + Id;
|
||||
hash = hash * 23 + ForeignArtistId.GetHashCode();
|
||||
hash = hash * 23 + Name?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Overview?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Disambiguation?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Type?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + (int)Status;
|
||||
hash = hash * 23 + Images?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Links?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Genres?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Ratings?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Members?.GetHashCode() ?? 0;
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ namespace NzbDrone.Core.Music
|
|||
Artist Upsert(Artist artist);
|
||||
void UpdateMany(List<Artist> artists);
|
||||
ArtistMetadata FindById(string ArtistId);
|
||||
List<ArtistMetadata> FindById(List<string> foreignIds);
|
||||
void UpsertMany(List<ArtistMetadata> artists);
|
||||
void UpsertMany(List<Artist> artists);
|
||||
}
|
||||
|
@ -87,6 +88,11 @@ namespace NzbDrone.Core.Music
|
|||
return Query.Where(a => a.ForeignArtistId == artistId).SingleOrDefault();
|
||||
}
|
||||
|
||||
public List<ArtistMetadata> FindById(List<string> foreignIds)
|
||||
{
|
||||
return Query.Where($"[ForeignArtistId] IN ('{string.Join("','", foreignIds)}')").ToList();
|
||||
}
|
||||
|
||||
public void UpsertMany(List<ArtistMetadata> artists)
|
||||
{
|
||||
foreach (var artist in artists)
|
||||
|
|
|
@ -4,23 +4,20 @@ using NzbDrone.Core.Messaging.Events;
|
|||
using NzbDrone.Core.Music.Events;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Organizer;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using NzbDrone.Core.MetadataSource;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using NzbDrone.Core.Exceptions;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Core.Music.Commands;
|
||||
|
||||
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
|
||||
namespace NzbDrone.Core.Music
|
||||
{
|
||||
public interface IRefreshAlbumService
|
||||
{
|
||||
void RefreshAlbumInfo(Album album);
|
||||
void RefreshAlbumInfo(List<Album> albums, bool forceAlbumRefresh);
|
||||
void RefreshAlbumInfo(Album album, bool forceUpdateFileTags);
|
||||
void RefreshAlbumInfo(List<Album> albums, bool forceAlbumRefresh, bool forceUpdateFileTags);
|
||||
}
|
||||
|
||||
public class RefreshAlbumService : IRefreshAlbumService, IExecute<RefreshAlbumCommand>
|
||||
|
@ -31,6 +28,7 @@ namespace NzbDrone.Core.Music
|
|||
private readonly IReleaseService _releaseService;
|
||||
private readonly IProvideAlbumInfo _albumInfo;
|
||||
private readonly IRefreshTrackService _refreshTrackService;
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly ICheckIfAlbumShouldBeRefreshed _checkIfAlbumShouldBeRefreshed;
|
||||
private readonly Logger _logger;
|
||||
|
@ -41,6 +39,7 @@ namespace NzbDrone.Core.Music
|
|||
IReleaseService releaseService,
|
||||
IProvideAlbumInfo albumInfo,
|
||||
IRefreshTrackService refreshTrackService,
|
||||
IAudioTagService audioTagService,
|
||||
IEventAggregator eventAggregator,
|
||||
ICheckIfAlbumShouldBeRefreshed checkIfAlbumShouldBeRefreshed,
|
||||
Logger logger)
|
||||
|
@ -51,23 +50,24 @@ namespace NzbDrone.Core.Music
|
|||
_releaseService = releaseService;
|
||||
_albumInfo = albumInfo;
|
||||
_refreshTrackService = refreshTrackService;
|
||||
_audioTagService = audioTagService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_checkIfAlbumShouldBeRefreshed = checkIfAlbumShouldBeRefreshed;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void RefreshAlbumInfo(List<Album> albums, bool forceAlbumRefresh)
|
||||
public void RefreshAlbumInfo(List<Album> albums, bool forceAlbumRefresh, bool forceUpdateFileTags)
|
||||
{
|
||||
foreach (var album in albums)
|
||||
{
|
||||
if (forceAlbumRefresh || _checkIfAlbumShouldBeRefreshed.ShouldRefresh(album))
|
||||
{
|
||||
RefreshAlbumInfo(album);
|
||||
RefreshAlbumInfo(album, forceUpdateFileTags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshAlbumInfo(Album album)
|
||||
public void RefreshAlbumInfo(Album album, bool forceUpdateFileTags)
|
||||
{
|
||||
_logger.ProgressInfo("Updating Info for {0}", album.Title);
|
||||
|
||||
|
@ -79,13 +79,43 @@ namespace NzbDrone.Core.Music
|
|||
}
|
||||
catch (AlbumNotFoundException)
|
||||
{
|
||||
_logger.Error(
|
||||
"Album '{0}' (LidarrAPI {1}) was not found, it may have been removed from Metadata sources.",
|
||||
album.Title, album.ForeignAlbumId);
|
||||
_logger.Error($"{album} was not found, it may have been removed from Metadata sources.");
|
||||
return;
|
||||
}
|
||||
|
||||
_artistMetadataRepository.UpsertMany(tuple.Item3);
|
||||
var remoteMetadata = tuple.Item3.DistinctBy(x => x.ForeignArtistId).ToList();
|
||||
var existingMetadata = _artistMetadataRepository.FindById(remoteMetadata.Select(x => x.ForeignArtistId).ToList());
|
||||
var updateMetadataList = new List<ArtistMetadata>();
|
||||
var addMetadataList = new List<ArtistMetadata>();
|
||||
var upToDateMetadataCount = 0;
|
||||
|
||||
foreach (var meta in remoteMetadata)
|
||||
{
|
||||
var existing = existingMetadata.SingleOrDefault(x => x.ForeignArtistId == meta.ForeignArtistId);
|
||||
if (existing != null)
|
||||
{
|
||||
meta.Id = existing.Id;
|
||||
if (!meta.Equals(existing))
|
||||
{
|
||||
updateMetadataList.Add(meta);
|
||||
}
|
||||
else
|
||||
{
|
||||
upToDateMetadataCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
addMetadataList.Add(meta);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Debug($"{album}: {upToDateMetadataCount} artist metadata up to date; Updating {updateMetadataList.Count}, Adding {addMetadataList.Count} artist metadata entries.");
|
||||
|
||||
_artistMetadataRepository.UpdateMany(updateMetadataList);
|
||||
_artistMetadataRepository.InsertMany(addMetadataList);
|
||||
|
||||
forceUpdateFileTags |= updateMetadataList.Any();
|
||||
|
||||
var albumInfo = tuple.Item2;
|
||||
|
||||
|
@ -97,6 +127,9 @@ namespace NzbDrone.Core.Music
|
|||
album.ForeignAlbumId = albumInfo.ForeignAlbumId;
|
||||
}
|
||||
|
||||
// the only thing written to tags from the album object is the title
|
||||
forceUpdateFileTags |= album.Title != (albumInfo.Title ?? "Unknown");
|
||||
|
||||
album.LastInfoSync = DateTime.UtcNow;
|
||||
album.CleanTitle = albumInfo.CleanTitle;
|
||||
album.Title = albumInfo.Title ?? "Unknown";
|
||||
|
@ -112,28 +145,34 @@ namespace NzbDrone.Core.Music
|
|||
album.AlbumReleases = new List<AlbumRelease>();
|
||||
|
||||
var remoteReleases = albumInfo.AlbumReleases.Value.DistinctBy(m => m.ForeignReleaseId).ToList();
|
||||
|
||||
// Search both ways to make sure we properly deal with releases that have been moved from one album to another
|
||||
// as well as deleting any releases that have been removed from an album.
|
||||
// note that under normal circumstances, a release would be captured by both queries.
|
||||
var existingReleasesByAlbum = _releaseService.GetReleasesByAlbum(album.Id);
|
||||
var existingReleasesById = _releaseService.GetReleasesByForeignReleaseId(remoteReleases.Select(x => x.ForeignReleaseId).ToList());
|
||||
var existingReleases = existingReleasesByAlbum.Union(existingReleasesById).DistinctBy(x => x.Id).ToList();
|
||||
|
||||
var existingReleases = _releaseService.GetReleasesForRefresh(album.Id, remoteReleases.Select(x => x.ForeignReleaseId));
|
||||
var newReleaseList = new List<AlbumRelease>();
|
||||
var updateReleaseList = new List<AlbumRelease>();
|
||||
var upToDateCount = 0;
|
||||
|
||||
foreach (var release in remoteReleases)
|
||||
{
|
||||
release.AlbumId = album.Id;
|
||||
release.Album = album;
|
||||
var releaseToRefresh = existingReleases.SingleOrDefault(r => r.ForeignReleaseId == release.ForeignReleaseId);
|
||||
|
||||
if (releaseToRefresh != null)
|
||||
{
|
||||
existingReleases.Remove(releaseToRefresh);
|
||||
|
||||
// copy across the db keys and check for equality
|
||||
release.Id = releaseToRefresh.Id;
|
||||
release.AlbumId = releaseToRefresh.AlbumId;
|
||||
release.Monitored = releaseToRefresh.Monitored;
|
||||
updateReleaseList.Add(release);
|
||||
|
||||
if (!releaseToRefresh.Equals(release))
|
||||
{
|
||||
updateReleaseList.Add(release);
|
||||
}
|
||||
else
|
||||
{
|
||||
upToDateCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -143,10 +182,11 @@ namespace NzbDrone.Core.Music
|
|||
album.AlbumReleases.Value.Add(release);
|
||||
}
|
||||
|
||||
_logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} releases",
|
||||
album, existingReleases.Count, updateReleaseList.Count, newReleaseList.Count);
|
||||
_logger.Debug($"{album} {upToDateCount} releases up to date; Deleting {existingReleases.Count}, Updating {updateReleaseList.Count}, Adding {newReleaseList.Count} releases.");
|
||||
|
||||
// before deleting anything, remove musicbrainz ids for things we are deleting
|
||||
_audioTagService.RemoveMusicBrainzTags(existingReleases);
|
||||
|
||||
// Delete first to avoid hitting distinct constraints
|
||||
_releaseService.DeleteMany(existingReleases);
|
||||
_releaseService.UpdateMany(updateReleaseList);
|
||||
_releaseService.InsertMany(newReleaseList);
|
||||
|
@ -158,7 +198,10 @@ namespace NzbDrone.Core.Music
|
|||
_releaseService.UpdateMany(new List<AlbumRelease> { toMonitor });
|
||||
}
|
||||
|
||||
_refreshTrackService.RefreshTrackInfo(album);
|
||||
// if we have updated a monitored release, refresh all file tags
|
||||
forceUpdateFileTags |= updateReleaseList.Any(x => x.Monitored);
|
||||
|
||||
_refreshTrackService.RefreshTrackInfo(album, forceUpdateFileTags);
|
||||
_albumService.UpdateMany(new List<Album>{album});
|
||||
|
||||
_logger.Debug("Finished album refresh for {0}", album.Title);
|
||||
|
@ -171,7 +214,7 @@ namespace NzbDrone.Core.Music
|
|||
{
|
||||
var album = _albumService.GetAlbum(message.AlbumId.Value);
|
||||
var artist = _artistService.GetArtistByMetadataId(album.ArtistMetadataId);
|
||||
RefreshAlbumInfo(album);
|
||||
RefreshAlbumInfo(album, false);
|
||||
_eventAggregator.PublishEvent(new ArtistUpdatedEvent(artist));
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ namespace NzbDrone.Core.Music
|
|||
private readonly IAlbumService _albumService;
|
||||
private readonly IRefreshAlbumService _refreshAlbumService;
|
||||
private readonly IRefreshTrackService _refreshTrackService;
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IDiskScanService _diskScanService;
|
||||
private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed;
|
||||
|
@ -39,6 +40,7 @@ namespace NzbDrone.Core.Music
|
|||
IAlbumService albumService,
|
||||
IRefreshAlbumService refreshAlbumService,
|
||||
IRefreshTrackService refreshTrackService,
|
||||
IAudioTagService audioTagService,
|
||||
IEventAggregator eventAggregator,
|
||||
IDiskScanService diskScanService,
|
||||
ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed,
|
||||
|
@ -52,6 +54,7 @@ namespace NzbDrone.Core.Music
|
|||
_albumService = albumService;
|
||||
_refreshAlbumService = refreshAlbumService;
|
||||
_refreshTrackService = refreshTrackService;
|
||||
_audioTagService = audioTagService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_diskScanService = diskScanService;
|
||||
_checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed;
|
||||
|
@ -72,13 +75,15 @@ namespace NzbDrone.Core.Music
|
|||
}
|
||||
catch (ArtistNotFoundException)
|
||||
{
|
||||
_logger.Error("Artist '{0}' (LidarrAPI {1}) was not found, it may have been removed from Metadata sources.", artist.Name, artist.Metadata.Value.ForeignArtistId);
|
||||
_logger.Error($"Artist {artist} was not found, it may have been removed from Metadata sources.");
|
||||
return;
|
||||
}
|
||||
|
||||
var forceUpdateFileTags = artist.Name != artistInfo.Name;
|
||||
|
||||
if (artist.Metadata.Value.ForeignArtistId != artistInfo.Metadata.Value.ForeignArtistId)
|
||||
{
|
||||
_logger.Warn("Artist '{0}' (Artist {1}) was replaced with '{2}' (LidarrAPI {3}), because the original was a duplicate.", artist.Name, artist.Metadata.Value.ForeignArtistId, artistInfo.Name, artistInfo.Metadata.Value.ForeignArtistId);
|
||||
_logger.Warn($"Artist {artist} was replaced with {artistInfo} because the original was a duplicate.");
|
||||
|
||||
// Update list exclusion if one exists
|
||||
var importExclusion = _importListExclusionService.FindByForeignId(artist.Metadata.Value.ForeignArtistId);
|
||||
|
@ -90,6 +95,7 @@ namespace NzbDrone.Core.Music
|
|||
}
|
||||
|
||||
artist.Metadata.Value.ForeignArtistId = artistInfo.Metadata.Value.ForeignArtistId;
|
||||
forceUpdateFileTags = true;
|
||||
}
|
||||
|
||||
artist.Metadata.Value.ApplyChanges(artistInfo.Metadata.Value);
|
||||
|
@ -107,13 +113,10 @@ namespace NzbDrone.Core.Music
|
|||
_logger.Warn(e, "Couldn't update artist path for " + artist.Path);
|
||||
}
|
||||
|
||||
var remoteAlbums = artistInfo.Albums.Value.DistinctBy(m => new { m.ForeignAlbumId, m.ReleaseDate }).ToList();
|
||||
var remoteAlbums = artistInfo.Albums.Value.DistinctBy(m => m.ForeignAlbumId).ToList();
|
||||
|
||||
// Get list of DB current db albums for artist
|
||||
var existingAlbumsByArtist = _albumService.GetAlbumsByArtist(artist.Id);
|
||||
var existingAlbumsById = _albumService.FindById(remoteAlbums.Select(x => x.ForeignAlbumId).ToList());
|
||||
var existingAlbums = existingAlbumsByArtist.Union(existingAlbumsById).DistinctBy(x => x.Id).ToList();
|
||||
|
||||
var existingAlbums = _albumService.GetAlbumsForRefresh(artist.ArtistMetadataId, remoteAlbums.Select(x => x.ForeignAlbumId));
|
||||
var newAlbumsList = new List<Album>();
|
||||
var updateAlbumsList = new List<Album>();
|
||||
|
||||
|
@ -121,15 +124,17 @@ namespace NzbDrone.Core.Music
|
|||
foreach (var album in remoteAlbums)
|
||||
{
|
||||
// Check for album in existing albums, if not set properties and add to new list
|
||||
var albumToRefresh = existingAlbums.FirstOrDefault(s => s.ForeignAlbumId == album.ForeignAlbumId);
|
||||
var albumToRefresh = existingAlbums.SingleOrDefault(s => s.ForeignAlbumId == album.ForeignAlbumId);
|
||||
|
||||
if (albumToRefresh != null)
|
||||
{
|
||||
albumToRefresh.Artist = artist;
|
||||
existingAlbums.Remove(albumToRefresh);
|
||||
updateAlbumsList.Add(albumToRefresh);
|
||||
}
|
||||
else
|
||||
{
|
||||
album.Artist = artist;
|
||||
newAlbumsList.Add(album);
|
||||
}
|
||||
}
|
||||
|
@ -139,6 +144,9 @@ namespace NzbDrone.Core.Music
|
|||
_logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} albums",
|
||||
artist, existingAlbums.Count, updateAlbumsList.Count, newAlbumsList.Count);
|
||||
|
||||
// before deleting anything, remove musicbrainz ids for things we are deleting
|
||||
_audioTagService.RemoveMusicBrainzTags(existingAlbums);
|
||||
|
||||
// Delete old albums first - this avoids errors if albums have been merged and we'll
|
||||
// end up trying to duplicate an existing release under a new album
|
||||
_albumService.DeleteMany(existingAlbums);
|
||||
|
@ -147,7 +155,7 @@ namespace NzbDrone.Core.Music
|
|||
newAlbumsList = UpdateAlbums(artist, newAlbumsList);
|
||||
_addAlbumService.AddAlbums(newAlbumsList);
|
||||
|
||||
_refreshAlbumService.RefreshAlbumInfo(updateAlbumsList, forceAlbumRefresh);
|
||||
_refreshAlbumService.RefreshAlbumInfo(updateAlbumsList, forceAlbumRefresh, forceUpdateFileTags);
|
||||
|
||||
_eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(artist, newAlbumsList, updateAlbumsList));
|
||||
|
||||
|
|
|
@ -1,35 +1,43 @@
|
|||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.Music.Events;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace NzbDrone.Core.Music
|
||||
{
|
||||
public interface IRefreshTrackService
|
||||
{
|
||||
void RefreshTrackInfo(Album rg);
|
||||
void RefreshTrackInfo(Album rg, bool forceUpdateFileTags);
|
||||
}
|
||||
|
||||
public class RefreshTrackService : IRefreshTrackService
|
||||
{
|
||||
private readonly ITrackService _trackService;
|
||||
private readonly IAlbumService _albumService;
|
||||
private readonly IMediaFileService _mediaFileService;
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public RefreshTrackService(ITrackService trackService, IAlbumService albumService, IEventAggregator eventAggregator, Logger logger)
|
||||
public RefreshTrackService(ITrackService trackService,
|
||||
IAlbumService albumService,
|
||||
IMediaFileService mediaFileService,
|
||||
IAudioTagService audioTagService,
|
||||
IEventAggregator eventAggregator,
|
||||
Logger logger)
|
||||
{
|
||||
_trackService = trackService;
|
||||
_albumService = albumService;
|
||||
_mediaFileService = mediaFileService;
|
||||
_audioTagService = audioTagService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void RefreshTrackInfo(Album album)
|
||||
public void RefreshTrackInfo(Album album, bool forceUpdateFileTags)
|
||||
{
|
||||
_logger.Info("Starting track info refresh for: {0}", album);
|
||||
var successCount = 0;
|
||||
|
@ -37,48 +45,50 @@ namespace NzbDrone.Core.Music
|
|||
|
||||
foreach (var release in album.AlbumReleases.Value)
|
||||
{
|
||||
var dupeFreeRemoteTracks = release.Tracks.Value.DistinctBy(m => new { m.ForeignTrackId, m.TrackNumber }).ToList();
|
||||
|
||||
// Search both ways to make sure we properly deal with tracks that have been moved from one release to another
|
||||
// as well as deleting any tracks that have been removed from a release.
|
||||
// note that under normal circumstances, a track would be captured by both queries.
|
||||
var existingTracksByRelease = _trackService.GetTracksByForeignReleaseId(release.ForeignReleaseId);
|
||||
var existingTracksById = _trackService.GetTracksByForeignTrackIds(dupeFreeRemoteTracks.Select(x => x.ForeignTrackId).ToList());
|
||||
var existingTracks = existingTracksByRelease.Union(existingTracksById).DistinctBy(x => x.Id).ToList();
|
||||
var remoteTracks = release.Tracks.Value.DistinctBy(m => m.ForeignTrackId).ToList();
|
||||
var existingTracks = _trackService.GetTracksForRefresh(release.Id, remoteTracks.Select(x => x.ForeignTrackId));
|
||||
|
||||
var updateList = new List<Track>();
|
||||
var newList = new List<Track>();
|
||||
var upToDateList = new List<Track>();
|
||||
|
||||
foreach (var track in OrderTracks(dupeFreeRemoteTracks))
|
||||
foreach (var track in remoteTracks)
|
||||
{
|
||||
track.AlbumRelease = release;
|
||||
track.AlbumReleaseId = release.Id;
|
||||
// the artist metadata will have been inserted by RefreshAlbumInfo so the Id will now be populated
|
||||
track.ArtistMetadataId = track.ArtistMetadata.Value.Id;
|
||||
|
||||
try
|
||||
{
|
||||
var trackToUpdate = GetTrackToUpdate(track, existingTracks);
|
||||
|
||||
var trackToUpdate = existingTracks.SingleOrDefault(e => e.ForeignTrackId == track.ForeignTrackId);
|
||||
if (trackToUpdate != null)
|
||||
{
|
||||
existingTracks.Remove(trackToUpdate);
|
||||
updateList.Add(trackToUpdate);
|
||||
|
||||
// populate albumrelease for later
|
||||
trackToUpdate.AlbumRelease = release;
|
||||
|
||||
// copy across the db keys to the remote track and check if we need to update
|
||||
track.Id = trackToUpdate.Id;
|
||||
track.TrackFileId = trackToUpdate.TrackFileId;
|
||||
// make sure title is not null
|
||||
track.Title = track.Title ?? "Unknown";
|
||||
|
||||
if (!trackToUpdate.Equals(track))
|
||||
{
|
||||
updateList.Add(track);
|
||||
}
|
||||
else
|
||||
{
|
||||
upToDateList.Add(track);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
trackToUpdate = new Track();
|
||||
trackToUpdate.Id = track.Id;
|
||||
newList.Add(trackToUpdate);
|
||||
newList.Add(track);
|
||||
}
|
||||
|
||||
// TODO: Use object mapper to automatically handle this
|
||||
trackToUpdate.ForeignTrackId = track.ForeignTrackId;
|
||||
trackToUpdate.ForeignRecordingId = track.ForeignRecordingId;
|
||||
trackToUpdate.AlbumReleaseId = release.Id;
|
||||
trackToUpdate.ArtistMetadataId = track.ArtistMetadata.Value.Id;
|
||||
trackToUpdate.TrackNumber = track.TrackNumber;
|
||||
trackToUpdate.AbsoluteTrackNumber = track.AbsoluteTrackNumber;
|
||||
trackToUpdate.Title = track.Title ?? "Unknown";
|
||||
trackToUpdate.Explicit = track.Explicit;
|
||||
trackToUpdate.Duration = track.Duration;
|
||||
trackToUpdate.MediumNumber = track.MediumNumber;
|
||||
|
||||
successCount++;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
@ -88,8 +98,19 @@ namespace NzbDrone.Core.Music
|
|||
}
|
||||
}
|
||||
|
||||
_logger.Debug("{0} Deleting {1}, Updating {2}, Adding {3} tracks",
|
||||
release, existingTracks.Count, updateList.Count, newList.Count);
|
||||
// if any tracks with files are deleted, strip out the MB tags from the metadata
|
||||
// so that we stand a chance of matching next time
|
||||
_audioTagService.RemoveMusicBrainzTags(existingTracks);
|
||||
|
||||
var tagsToUpdate = updateList;
|
||||
if (forceUpdateFileTags)
|
||||
{
|
||||
_logger.Debug("Forcing tag update due to Artist/Album/Release updates");
|
||||
tagsToUpdate = updateList.Concat(upToDateList).ToList();
|
||||
}
|
||||
_audioTagService.SyncTags(tagsToUpdate);
|
||||
|
||||
_logger.Debug($"{release}: {upToDateList.Count} tracks up to date; Deleting {existingTracks.Count}, Updating {updateList.Count}, Adding {newList.Count} tracks.");
|
||||
|
||||
_trackService.DeleteMany(existingTracks);
|
||||
_trackService.UpdateMany(updateList);
|
||||
|
@ -106,17 +127,6 @@ namespace NzbDrone.Core.Music
|
|||
_logger.Info("Finished track refresh for album: {0}.", album);
|
||||
}
|
||||
}
|
||||
|
||||
private Track GetTrackToUpdate(Track track, List<Track> existingTracks)
|
||||
{
|
||||
var result = existingTracks.FirstOrDefault(e => e.ForeignTrackId == track.ForeignTrackId && e.TrackNumber == track.TrackNumber);
|
||||
return result;
|
||||
}
|
||||
|
||||
private IEnumerable<Track> OrderTracks(List<Track> tracks)
|
||||
{
|
||||
return tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,10 +4,11 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Marr.Data;
|
||||
using NzbDrone.Common.Serializer;
|
||||
|
||||
namespace NzbDrone.Core.Music
|
||||
{
|
||||
public class AlbumRelease : ModelBase
|
||||
public class AlbumRelease : ModelBase, IEquatable<AlbumRelease>
|
||||
{
|
||||
// These correspond to columns in the AlbumReleases table
|
||||
public int AlbumId { get; set; }
|
||||
|
@ -31,5 +32,72 @@ namespace NzbDrone.Core.Music
|
|||
{
|
||||
return string.Format("[{0}][{1}]", ForeignReleaseId, Title.NullSafe());
|
||||
}
|
||||
|
||||
public bool Equals (AlbumRelease other)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Id == other.Id &&
|
||||
AlbumId == other.AlbumId &&
|
||||
ForeignReleaseId == other.ForeignReleaseId &&
|
||||
Title == other.Title &&
|
||||
Status == other.Status &&
|
||||
Duration == other.Duration &&
|
||||
(Label?.SequenceEqual(other.Label) ?? true) &&
|
||||
Disambiguation == other.Disambiguation &&
|
||||
(Country?.SequenceEqual(other.Country) ?? true) &&
|
||||
ReleaseDate == other.ReleaseDate &&
|
||||
((Media == null && other.Media == null) || (Media?.ToJson() == other.Media?.ToJson())) &&
|
||||
TrackCount == other.TrackCount &&
|
||||
Monitored == other.Monitored)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var other = obj as AlbumRelease;
|
||||
if (other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Equals(other);
|
||||
}
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
hash = hash * 23 + Id;
|
||||
hash = hash * 23 + AlbumId;
|
||||
hash = hash * 23 + ForeignReleaseId.GetHashCode();
|
||||
hash = hash * 23 + Title?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Status?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Duration;
|
||||
hash = hash * 23 + Label?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Disambiguation?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Country?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + ReleaseDate.GetHashCode();
|
||||
hash = hash * 23 + Media?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + TrackCount;
|
||||
hash = hash * 23 + Monitored.GetHashCode();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,10 +9,11 @@ namespace NzbDrone.Core.Music
|
|||
{
|
||||
public interface IReleaseRepository : IBasicRepository<AlbumRelease>
|
||||
{
|
||||
AlbumRelease FindByForeignReleaseId(string foreignReleaseId);
|
||||
List<AlbumRelease> FindByAlbum(int id);
|
||||
List<AlbumRelease> FindByRecordingId(List<string> recordingIds);
|
||||
List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds);
|
||||
List<AlbumRelease> SetMonitored(AlbumRelease release);
|
||||
List<AlbumRelease> FindByForeignReleaseId(List<string> foreignReleaseIds);
|
||||
}
|
||||
|
||||
public class ReleaseRepository : BasicRepository<AlbumRelease>, IReleaseRepository
|
||||
|
@ -22,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)
|
||||
{
|
||||
// populate the albums and artist metadata also
|
||||
|
@ -33,15 +49,6 @@ namespace NzbDrone.Core.Music
|
|||
.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)
|
||||
{
|
||||
var query = "SELECT DISTINCT AlbumReleases.*" +
|
||||
|
|
|
@ -7,17 +7,18 @@ namespace NzbDrone.Core.Music
|
|||
public interface IReleaseService
|
||||
{
|
||||
AlbumRelease GetRelease(int id);
|
||||
AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId);
|
||||
void InsertMany(List<AlbumRelease> releases);
|
||||
void UpdateMany(List<AlbumRelease> releases);
|
||||
void DeleteMany(List<AlbumRelease> releases);
|
||||
List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds);
|
||||
List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId);
|
||||
List<AlbumRelease> GetReleasesByForeignReleaseId(List<string> foreignReleaseIds);
|
||||
List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds);
|
||||
List<AlbumRelease> SetMonitored(AlbumRelease release);
|
||||
}
|
||||
|
||||
public class ReleaseService : IReleaseService,
|
||||
IHandleAsync<AlbumDeletedEvent>
|
||||
IHandle<AlbumDeletedEvent>
|
||||
{
|
||||
private readonly IReleaseRepository _releaseRepository;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
|
@ -34,6 +35,11 @@ namespace NzbDrone.Core.Music
|
|||
return _releaseRepository.Get(id);
|
||||
}
|
||||
|
||||
public AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId)
|
||||
{
|
||||
return _releaseRepository.FindByForeignReleaseId(foreignReleaseId);
|
||||
}
|
||||
|
||||
public void InsertMany(List<AlbumRelease> releases)
|
||||
{
|
||||
_releaseRepository.InsertMany(releases);
|
||||
|
@ -53,16 +59,16 @@ namespace NzbDrone.Core.Music
|
|||
}
|
||||
}
|
||||
|
||||
public List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds)
|
||||
{
|
||||
return _releaseRepository.GetReleasesForRefresh(albumId, foreignReleaseIds);
|
||||
}
|
||||
|
||||
public List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId)
|
||||
{
|
||||
return _releaseRepository.FindByAlbum(releaseGroupId);
|
||||
}
|
||||
|
||||
public List<AlbumRelease> GetReleasesByForeignReleaseId(List<string> foreignReleaseIds)
|
||||
{
|
||||
return _releaseRepository.FindByForeignReleaseId(foreignReleaseIds);
|
||||
}
|
||||
|
||||
public List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds)
|
||||
{
|
||||
return _releaseRepository.FindByRecordingId(recordingIds);
|
||||
|
@ -73,7 +79,7 @@ namespace NzbDrone.Core.Music
|
|||
return _releaseRepository.SetMonitored(release);
|
||||
}
|
||||
|
||||
public void HandleAsync(AlbumDeletedEvent message)
|
||||
public void Handle(AlbumDeletedEvent message)
|
||||
{
|
||||
var releases = GetReleasesByAlbum(message.Album.Id);
|
||||
DeleteMany(releases);
|
||||
|
|
|
@ -2,10 +2,12 @@ using NzbDrone.Core.Datastore;
|
|||
using NzbDrone.Core.MediaFiles;
|
||||
using Marr.Data;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using System;
|
||||
using NzbDrone.Common.Serializer;
|
||||
|
||||
namespace NzbDrone.Core.Music
|
||||
{
|
||||
public class Track : ModelBase
|
||||
public class Track : ModelBase, IEquatable<Track>
|
||||
{
|
||||
public Track()
|
||||
{
|
||||
|
@ -41,5 +43,72 @@ namespace NzbDrone.Core.Music
|
|||
{
|
||||
return string.Format("[{0}]{1}", ForeignTrackId, Title.NullSafe());
|
||||
}
|
||||
|
||||
public bool Equals(Track other)
|
||||
{
|
||||
if (other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Id == other.Id &&
|
||||
ForeignTrackId == other.ForeignTrackId &&
|
||||
ForeignRecordingId == other.ForeignRecordingId &&
|
||||
AlbumReleaseId == other.AlbumReleaseId &&
|
||||
ArtistMetadataId == other.ArtistMetadataId &&
|
||||
TrackNumber == other.TrackNumber &&
|
||||
AbsoluteTrackNumber == other.AbsoluteTrackNumber &&
|
||||
Title == other.Title &&
|
||||
Duration == other.Duration &&
|
||||
Explicit == other.Explicit &&
|
||||
Ratings?.ToJson() == other.Ratings?.ToJson() &&
|
||||
MediumNumber == other.MediumNumber &&
|
||||
TrackFileId == other.TrackFileId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var other = obj as Track;
|
||||
if (other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Equals(other);
|
||||
}
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
hash = hash * 23 + Id;
|
||||
hash = hash * 23 + ForeignTrackId.GetHashCode();
|
||||
hash = hash * 23 + ForeignRecordingId.GetHashCode();
|
||||
hash = hash * 23 + AlbumReleaseId;
|
||||
hash = hash * 23 + ArtistMetadataId;
|
||||
hash = hash * 23 + TrackNumber?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + AbsoluteTrackNumber;
|
||||
hash = hash * 23 + Title?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + Duration;
|
||||
hash = hash * 23 + Explicit.GetHashCode();
|
||||
hash = hash * 23 + Ratings?.GetHashCode() ?? 0;
|
||||
hash = hash * 23 + MediumNumber;
|
||||
hash = hash * 23 + TrackFileId;
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,8 +11,7 @@ namespace NzbDrone.Core.Music
|
|||
List<Track> GetTracksByAlbum(int albumId);
|
||||
List<Track> GetTracksByRelease(int albumReleaseId);
|
||||
List<Track> GetTracksByReleases(List<int> albumReleaseId);
|
||||
List<Track> GetTracksByForeignReleaseId(string foreignReleaseId);
|
||||
List<Track> GetTracksByForeignTrackIds(List<string> foreignTrackId);
|
||||
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
|
||||
List<Track> GetTracksByFileId(int fileId);
|
||||
List<Track> TracksWithFiles(int artistId);
|
||||
List<Track> TracksWithoutFiles(int albumId);
|
||||
|
@ -73,25 +72,12 @@ namespace NzbDrone.Core.Music
|
|||
.ToList();
|
||||
}
|
||||
|
||||
public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId)
|
||||
public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds)
|
||||
{
|
||||
string query = string.Format("SELECT Tracks.* " +
|
||||
"FROM AlbumReleases " +
|
||||
"JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " +
|
||||
"WHERE AlbumReleases.ForeignReleaseId = '{0}'",
|
||||
foreignReleaseId);
|
||||
|
||||
return Query.QueryText(query).ToList();
|
||||
}
|
||||
|
||||
public List<Track> GetTracksByForeignTrackIds(List<string> ids)
|
||||
{
|
||||
string query = string.Format("SELECT Tracks.* " +
|
||||
"FROM Tracks " +
|
||||
"WHERE ForeignTrackId IN ('{0}')",
|
||||
string.Join("', '", ids));
|
||||
|
||||
return Query.QueryText(query).ToList();
|
||||
return Query
|
||||
.Where(t => t.AlbumReleaseId == albumReleaseId)
|
||||
.OrWhere($"[ForeignTrackId] IN ('{string.Join("', '", foreignTrackIds)}')")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<Track> GetTracksByFileId(int fileId)
|
||||
|
|
|
@ -16,8 +16,7 @@ namespace NzbDrone.Core.Music
|
|||
List<Track> GetTracksByAlbum(int albumId);
|
||||
List<Track> GetTracksByRelease(int albumReleaseId);
|
||||
List<Track> GetTracksByReleases(List<int> albumReleaseIds);
|
||||
List<Track> GetTracksByForeignReleaseId(string foreignReleaseId);
|
||||
List<Track> GetTracksByForeignTrackIds(List<string> ids);
|
||||
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
|
||||
List<Track> TracksWithFiles(int artistId);
|
||||
List<Track> TracksWithoutFiles(int albumId);
|
||||
List<Track> GetTracksByFileId(int trackFileId);
|
||||
|
@ -29,7 +28,7 @@ namespace NzbDrone.Core.Music
|
|||
}
|
||||
|
||||
public class TrackService : ITrackService,
|
||||
IHandleAsync<ReleaseDeletedEvent>,
|
||||
IHandle<ReleaseDeletedEvent>,
|
||||
IHandle<TrackFileDeletedEvent>
|
||||
{
|
||||
private readonly ITrackRepository _trackRepository;
|
||||
|
@ -74,14 +73,9 @@ namespace NzbDrone.Core.Music
|
|||
return _trackRepository.GetTracksByReleases(albumReleaseIds);
|
||||
}
|
||||
|
||||
public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId)
|
||||
public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds)
|
||||
{
|
||||
return _trackRepository.GetTracksByForeignReleaseId(foreignReleaseId);
|
||||
}
|
||||
|
||||
public List<Track> GetTracksByForeignTrackIds(List<string> ids)
|
||||
{
|
||||
return _trackRepository.GetTracksByForeignTrackIds(ids);
|
||||
return _trackRepository.GetTracksForRefresh(albumReleaseId, foreignTrackIds);
|
||||
}
|
||||
|
||||
public List<Track> TracksWithFiles(int artistId)
|
||||
|
@ -124,7 +118,7 @@ namespace NzbDrone.Core.Music
|
|||
_trackRepository.SetFileId(tracks);
|
||||
}
|
||||
|
||||
public void HandleAsync(ReleaseDeletedEvent message)
|
||||
public void Handle(ReleaseDeletedEvent message)
|
||||
{
|
||||
var tracks = GetTracksByRelease(message.Release.Id);
|
||||
_trackRepository.DeleteMany(tracks);
|
||||
|
|
|
@ -141,6 +141,7 @@
|
|||
<Compile Include="Configuration\InvalidConfigFileException.cs" />
|
||||
<Compile Include="Configuration\RescanAfterRefreshType.cs" />
|
||||
<Compile Include="Configuration\AllowFingerprinting.cs" />
|
||||
<Compile Include="Configuration\WriteAudioTagsType.cs" />
|
||||
<Compile Include="Configuration\ResetApiKeyCommand.cs" />
|
||||
<Compile Include="CustomFilters\CustomFilter.cs" />
|
||||
<Compile Include="CustomFilters\CustomFilterRepository.cs" />
|
||||
|
@ -723,14 +724,19 @@
|
|||
<Compile Include="MediaFiles\Commands\CleanUpRecycleBinCommand.cs" />
|
||||
<Compile Include="MediaFiles\Commands\DownloadedAlbumsScanCommand.cs" />
|
||||
<Compile Include="MediaFiles\Commands\RenameArtistCommand.cs" />
|
||||
<Compile Include="MediaFiles\Commands\RetagArtistCommand.cs" />
|
||||
<Compile Include="MediaFiles\Events\AlbumImportedEvent.cs" />
|
||||
<Compile Include="MediaFiles\Events\AlbumImportIncompleteEvent.cs" />
|
||||
<Compile Include="MediaFiles\Events\TrackFileRenamedEvent.cs" />
|
||||
<Compile Include="MediaFiles\Events\TrackFileRetaggedEvent.cs" />
|
||||
<Compile Include="MediaFiles\Events\TrackFolderCreatedEvent.cs" />
|
||||
<Compile Include="MediaFiles\Events\TrackImportFailedEvent.cs" />
|
||||
<Compile Include="MediaFiles\AudioTag.cs" />
|
||||
<Compile Include="MediaFiles\AudioTagService.cs" />
|
||||
<Compile Include="MediaFiles\MediaFileDeletionService.cs" />
|
||||
<Compile Include="MediaFiles\MediaInfoFormatter.cs" />
|
||||
<Compile Include="MediaFiles\RenameTrackFilePreview.cs" />
|
||||
<Compile Include="MediaFiles\RetagTrackFilePreview.cs" />
|
||||
<Compile Include="MediaFiles\RenameTrackFileService.cs" />
|
||||
<Compile Include="MediaFiles\TrackFileMovingService.cs" />
|
||||
<Compile Include="MediaFiles\TrackFileMoveResult.cs" />
|
||||
|
@ -742,6 +748,7 @@
|
|||
<Compile Include="MediaFiles\TrackImport\Aggregation\AggregationService.cs" />
|
||||
<Compile Include="MediaFiles\TrackImport\Aggregation\AggregationFailedException.cs" />
|
||||
<Compile Include="MediaFiles\Commands\RenameFilesCommand.cs" />
|
||||
<Compile Include="MediaFiles\Commands\RetagFilesCommand.cs" />
|
||||
<Compile Include="MediaFiles\DeleteMediaFileReason.cs" />
|
||||
<Compile Include="MediaFiles\DiskScanService.cs">
|
||||
<SubType>Code</SubType>
|
||||
|
|
|
@ -8,11 +8,6 @@ using NzbDrone.Common.Extensions;
|
|||
using NzbDrone.Common.Instrumentation;
|
||||
using NzbDrone.Core.Music;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Languages;
|
||||
using TagLib;
|
||||
using TagLib.Id3v2;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NzbDrone.Core.Parser
|
||||
{
|
||||
|
@ -20,14 +15,6 @@ namespace NzbDrone.Core.Parser
|
|||
{
|
||||
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(Parser));
|
||||
|
||||
private static readonly JsonSerializerSettings SerializerSettings;
|
||||
|
||||
static Parser()
|
||||
{
|
||||
SerializerSettings = Json.GetSerializerSettings();
|
||||
SerializerSettings.Formatting = Formatting.None;
|
||||
}
|
||||
|
||||
private static readonly Regex[] ReportMusicTitleRegex = new[]
|
||||
{
|
||||
// Track with artist (01 - artist - trackName)
|
||||
|
@ -229,34 +216,10 @@ namespace NzbDrone.Core.Parser
|
|||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
|
||||
ParsedTrackInfo result;
|
||||
ParsedTrackInfo result = null;
|
||||
|
||||
if (MediaFiles.MediaFileExtensions.Extensions.Contains(fileInfo.Extension))
|
||||
{
|
||||
try
|
||||
{
|
||||
result = ParseAudioTags(path);
|
||||
}
|
||||
catch(TagLib.CorruptFileException)
|
||||
{
|
||||
Logger.Debug("Caught exception parsing {0}", path);
|
||||
result = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = null;
|
||||
}
|
||||
|
||||
// TODO: Check if it is common that we might need to fallback to parser to gather details
|
||||
//var result = ParseMusicTitle(fileInfo.Name);
|
||||
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
Logger.Debug("Attempting to parse track info using directory and file names. {0}", fileInfo.Directory.Name);
|
||||
result = ParseMusicTitle(fileInfo.Directory.Name + " " + fileInfo.Name);
|
||||
}
|
||||
Logger.Debug("Attempting to parse track info using directory and file names. {0}", fileInfo.Directory.Name);
|
||||
result = ParseMusicTitle(fileInfo.Directory.Name + " " + fileInfo.Name);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
|
@ -619,93 +582,6 @@ namespace NzbDrone.Core.Parser
|
|||
return intermediateTitle;
|
||||
}
|
||||
|
||||
private static ParsedTrackInfo ParseAudioTags(string path)
|
||||
{
|
||||
using(var file = TagLib.File.Create(path))
|
||||
{
|
||||
Logger.Debug("Starting Tag Parse for {0}", file.Name);
|
||||
|
||||
var artist = file.Tag.FirstAlbumArtist;
|
||||
|
||||
if (artist.IsNullOrWhiteSpace())
|
||||
{
|
||||
artist = file.Tag.FirstPerformer;
|
||||
}
|
||||
|
||||
var artistTitleInfo = new ArtistTitleInfo
|
||||
{
|
||||
Title = artist,
|
||||
Year = (int)file.Tag.Year
|
||||
};
|
||||
|
||||
var result = new ParsedTrackInfo
|
||||
{
|
||||
Language = Language.English, //TODO Parse from Tag/Mediainfo
|
||||
AlbumTitle = file.Tag.Album,
|
||||
ArtistTitle = artist,
|
||||
ArtistMBId = file.Tag.MusicBrainzArtistId,
|
||||
AlbumMBId = file.Tag.MusicBrainzReleaseGroupId,
|
||||
ReleaseMBId = file.Tag.MusicBrainzReleaseId,
|
||||
// SIC: the recording ID is stored in this field.
|
||||
// See https://picard.musicbrainz.org/docs/mappings/
|
||||
RecordingMBId = file.Tag.MusicBrainzTrackId,
|
||||
DiscNumber = (int) file.Tag.Disc,
|
||||
DiscCount = (int) file.Tag.DiscCount,
|
||||
Duration = file.Properties.Duration,
|
||||
Year = file.Tag.Year,
|
||||
Label = file.Tag.Publisher,
|
||||
TrackNumbers = new [] { (int) file.Tag.Track },
|
||||
ArtistTitleInfo = artistTitleInfo,
|
||||
Title = file.Tag.Title,
|
||||
CleanTitle = file.Tag.Title?.CleanTrackTitle(),
|
||||
Country = IsoCountries.Find(file.Tag.MusicBrainzReleaseCountry)
|
||||
};
|
||||
|
||||
// custom tags varying by format
|
||||
if ((file.TagTypesOnDisk & TagTypes.Id3v2) == TagTypes.Id3v2)
|
||||
{
|
||||
var tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2);
|
||||
result.CatalogNumber = UserTextInformationFrame.Get(tag, "CATALOGNUMBER", false)?.Text.ExclusiveOrDefault();
|
||||
// this one was invented for beets
|
||||
result.Disambiguation = UserTextInformationFrame.Get(tag, "MusicBrainz Album Comment", false)?.Text.ExclusiveOrDefault();
|
||||
result.TrackMBId = UserTextInformationFrame.Get(tag, "MusicBrainz Release Track Id", false)?.Text.ExclusiveOrDefault();
|
||||
}
|
||||
else if ((file.TagTypesOnDisk & TagTypes.Xiph) == TagTypes.Xiph)
|
||||
{
|
||||
var tag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph);
|
||||
result.CatalogNumber = tag.GetField("CATALOGNUMBER").ExclusiveOrDefault();
|
||||
result.Disambiguation = tag.GetField("MUSICBRAINZ_ALBUMCOMMENT").ExclusiveOrDefault();
|
||||
result.TrackMBId = tag.GetField("MUSICBRAINZ_RELEASETRACKID").ExclusiveOrDefault();
|
||||
}
|
||||
|
||||
Logger.Debug("File Tags Parsed: {0}", JsonConvert.SerializeObject(result, SerializerSettings));
|
||||
|
||||
foreach (ICodec codec in file.Properties.Codecs)
|
||||
{
|
||||
IAudioCodec acodec = codec as IAudioCodec;
|
||||
|
||||
if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None)
|
||||
{
|
||||
Logger.Debug("Audio Properties : " + acodec.Description + ", Bitrate: " + acodec.AudioBitrate + ", Sample Size: " +
|
||||
file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels);
|
||||
|
||||
result.Quality = QualityParser.ParseQuality(file.Name, acodec.Description, acodec.AudioBitrate, file.Properties.BitsPerSample);
|
||||
Logger.Debug("Quality parsed: {0}", result.Quality);
|
||||
|
||||
result.MediaInfo = new MediaInfoModel {
|
||||
AudioFormat = acodec.Description,
|
||||
AudioBitrate = acodec.AudioBitrate,
|
||||
AudioChannels = acodec.AudioChannels,
|
||||
AudioBits = file.Properties.BitsPerSample,
|
||||
AudioSampleRate = acodec.AudioSampleRate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static ParsedTrackInfo ParseMatchMusicCollection(MatchCollection matchCollection)
|
||||
{
|
||||
var artistName = matchCollection[0].Groups["artist"].Value./*Removed for cases like Will.I.Am Replace('.', ' ').*/Replace('_', ' ');
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Text.RegularExpressions;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
|
@ -14,23 +13,6 @@ namespace NzbDrone.Core.Parser
|
|||
{
|
||||
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(QualityParser));
|
||||
|
||||
private static readonly Regex SourceRegex = new Regex(@"\b(?:
|
||||
(?<bluray>BluRay|Blu-Ray|HDDVD|BD)|
|
||||
(?<webdl>WEB[-_. ]DL|WEBDL|WebRip|iTunesHD|WebHD|[. ]WEB[. ](?:[xh]26[45]|DD5[. ]1)|\d+0p[. ]WEB[. ])|
|
||||
(?<hdtv>HDTV)|
|
||||
(?<bdrip>BDRip)|
|
||||
(?<brrip>BRRip)|
|
||||
(?<dvd>DVD|DVDRip|NTSC|PAL|xvidvd)|
|
||||
(?<dsr>WS[-_. ]DSR|DSR)|
|
||||
(?<pdtv>PDTV)|
|
||||
(?<sdtv>SDTV)|
|
||||
(?<tvrip>TVRip)
|
||||
)\b",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
|
||||
|
||||
private static readonly Regex RawHDRegex = new Regex(@"\b(?<rawhd>RawHD|1080i[-_. ]HDTV|Raw[-_. ]HD|MPEG[-_. ]?2)\b",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex ProperRegex = new Regex(@"\b(?<proper>proper|repack|rerip)\b",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
|
@ -54,7 +36,7 @@ namespace NzbDrone.Core.Parser
|
|||
|
||||
private static readonly Regex SampleSizeRegex = new Regex(@"\b(?:(?<S24>24[ ]bit|24bit|[\[\(].*24bit.*[\]\)]))");
|
||||
|
||||
private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<MP3VBR>MP3.*VBR|MPEG Version 1 Audio, Layer 3 vbr)|(?<MP3CBR>MP3|MPEG Version \d+ Audio, Layer 3)|(?<FLAC>flac)|(?<WAVPACK>wavpack|wv)|(?<ALAC>alac)|(?<WMA>WMA\d?)|(?<WAV>WAV|PCM)|(?<AAC>M4A|AAC|mp4a)|(?<OGG>OGG|Vorbis))\b|(?<APE>monkey's audio|[\[|\(].*ape.*[\]|\)])",
|
||||
private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<MP1>MPEG Version \d(.5)? Audio, Layer 1|MP1)|(?<MP2>MPEG Version \d(.5)? Audio, Layer 2|MP2)|(?<MP3VBR>MP3.*VBR|MPEG Version \d(.5)? Audio, Layer 3 vbr)|(?<MP3CBR>MP3|MPEG Version \d(.5)? Audio, Layer 3)|(?<FLAC>flac)|(?<WAVPACK>wavpack|wv)|(?<ALAC>alac)|(?<WMA>WMA\d?)|(?<WAV>WAV|PCM)|(?<AAC>M4A|M4P|M4B|AAC|mp4a|MPEG-4 Audio(?!.*alac))|(?<OGG>OGG|OGA|Vorbis))\b|(?<APE>monkey's audio|[\[|\(].*ape.*[\]|\)])|(?<OPUS>Opus)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public static QualityModel ParseQuality(string name, string desc, int fileBitrate, int fileSampleSize = 0)
|
||||
|
@ -67,6 +49,7 @@ namespace NzbDrone.Core.Parser
|
|||
if (desc.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
var descCodec = ParseCodec(desc, "");
|
||||
Logger.Trace($"Got codec {descCodec}");
|
||||
|
||||
result.Quality = FindQuality(descCodec, fileBitrate, fileSampleSize);
|
||||
|
||||
|
@ -83,6 +66,10 @@ namespace NzbDrone.Core.Parser
|
|||
|
||||
switch(codec)
|
||||
{
|
||||
case Codec.MP1:
|
||||
case Codec.MP2:
|
||||
result.Quality = Quality.Unknown;
|
||||
break;
|
||||
case Codec.MP3VBR:
|
||||
if (bitrate == BitRate.VBRV0) { result.Quality = Quality.MP3_VBR; }
|
||||
else if (bitrate == BitRate.VBRV2) { result.Quality = Quality.MP3_VBR_V2; }
|
||||
|
@ -126,6 +113,7 @@ namespace NzbDrone.Core.Parser
|
|||
result.Quality = Quality.AAC_VBR;
|
||||
break;
|
||||
case Codec.OGG:
|
||||
case Codec.OPUS:
|
||||
if (bitrate == BitRate.B160) { result.Quality = Quality.VORBIS_Q5; }
|
||||
else if (bitrate == BitRate.B192) { result.Quality = Quality.VORBIS_Q6; }
|
||||
else if (bitrate == BitRate.B224) { result.Quality = Quality.VORBIS_Q7; }
|
||||
|
@ -175,6 +163,9 @@ namespace NzbDrone.Core.Parser
|
|||
if (match.Groups["WAV"].Success) { return Codec.WAV; }
|
||||
if (match.Groups["AAC"].Success) { return Codec.AAC; }
|
||||
if (match.Groups["OGG"].Success) { return Codec.OGG; }
|
||||
if (match.Groups["OPUS"].Success) { return Codec.OPUS; }
|
||||
if (match.Groups["MP1"].Success) { return Codec.MP1; }
|
||||
if (match.Groups["MP2"].Success) { return Codec.MP2; }
|
||||
if (match.Groups["MP3VBR"].Success) { return Codec.MP3VBR; }
|
||||
if (match.Groups["MP3CBR"].Success) { return Codec.MP3CBR; }
|
||||
if (match.Groups["WAVPACK"].Success) { return Codec.WAVPACK; }
|
||||
|
@ -218,6 +209,9 @@ namespace NzbDrone.Core.Parser
|
|||
{
|
||||
switch (codec)
|
||||
{
|
||||
case Codec.MP1:
|
||||
case Codec.MP2:
|
||||
return Quality.Unknown;
|
||||
case Codec.MP3VBR:
|
||||
return Quality.MP3_VBR;
|
||||
case Codec.MP3CBR:
|
||||
|
@ -265,6 +259,14 @@ namespace NzbDrone.Core.Parser
|
|||
if (bitrate == 320) { return Quality.VORBIS_Q9; }
|
||||
if (bitrate == 500) { return Quality.VORBIS_Q10; }
|
||||
return Quality.Unknown;
|
||||
case Codec.OPUS:
|
||||
if (bitrate < 130) { return Quality.Unknown; }
|
||||
if (bitrate < 180) { return Quality.VORBIS_Q5; }
|
||||
if (bitrate < 205) { return Quality.VORBIS_Q6; }
|
||||
if (bitrate < 240) { return Quality.VORBIS_Q7; }
|
||||
if (bitrate < 290) { return Quality.VORBIS_Q8; }
|
||||
if (bitrate < 410) { return Quality.VORBIS_Q9; }
|
||||
return Quality.VORBIS_Q10;
|
||||
default:
|
||||
return Quality.Unknown;
|
||||
}
|
||||
|
@ -301,6 +303,8 @@ namespace NzbDrone.Core.Parser
|
|||
|
||||
public enum Codec
|
||||
{
|
||||
MP1,
|
||||
MP2,
|
||||
MP3CBR,
|
||||
MP3VBR,
|
||||
FLAC,
|
||||
|
@ -311,6 +315,7 @@ namespace NzbDrone.Core.Parser
|
|||
AAC,
|
||||
AACVBR,
|
||||
OGG,
|
||||
OPUS,
|
||||
WAV,
|
||||
Unknown
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue