New: Write metadata to tags, with UI for previewing changes (#633)
parent
6548f4b1b7
commit
072f772dc8
@ -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);
|
@ -0,0 +1,34 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import RetagPreviewModalContentConnector from './RetagPreviewModalContentConnector';
|
||||
|
||||
function RetagPreviewModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
{
|
||||
isOpen &&
|
||||
<RetagPreviewModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
RetagPreviewModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RetagPreviewModal;
|
@ -0,0 +1,39 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearRetagPreview } from 'Store/Actions/retagPreviewActions';
|
||||
import RetagPreviewModal from './RetagPreviewModal';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
clearRetagPreview
|
||||
};
|
||||
|
||||
class RetagPreviewModalConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.clearRetagPreview();
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<RetagPreviewModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewModalConnector.propTypes = {
|
||||
clearRetagPreview: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(RetagPreviewModalConnector);
|
@ -0,0 +1,24 @@
|
||||
.path {
|
||||
margin-left: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.trackFormat {
|
||||
margin-left: 5px;
|
||||
font-family: $monoSpaceFontFamily;
|
||||
}
|
||||
|
||||
.previews {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.selectAllInputContainer {
|
||||
margin-right: auto;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.selectAllInput {
|
||||
composes: input from '~Components/Form/CheckInput.css';
|
||||
|
||||
margin: 0;
|
||||
}
|
@ -0,0 +1,186 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Alert from 'Components/Alert';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import RetagPreviewRow from './RetagPreviewRow';
|
||||
import styles from './RetagPreviewModalContent.css';
|
||||
|
||||
function getValue(allSelected, allUnselected) {
|
||||
if (allSelected) {
|
||||
return true;
|
||||
} else if (allUnselected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class RetagPreviewModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
lastToggled: null,
|
||||
selectedState: {}
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
getSelectedIds = () => {
|
||||
return getSelectedIds(this.state.selectedState);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSelectAllChange = ({ value }) => {
|
||||
this.setState(selectAll(this.state.selectedState, value));
|
||||
}
|
||||
|
||||
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||
this.setState((state) => {
|
||||
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||
});
|
||||
}
|
||||
|
||||
onRetagPress = () => {
|
||||
this.props.onRetagPress(this.getSelectedIds());
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
path,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
selectedState
|
||||
} = this.state;
|
||||
|
||||
const selectAllValue = getValue(allSelected, allUnselected);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Write Metadata Tags
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error &&
|
||||
<div>Error loading previews</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && ((isPopulated && !items.length)) &&
|
||||
<div>Success! My work is done, no files to retag.</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && isPopulated && !!items.length &&
|
||||
<div>
|
||||
<Alert>
|
||||
<div>
|
||||
All paths are relative to:
|
||||
<span className={styles.path}>
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
MusicBrainz identifiers will also be added to the files; these are not shown below.
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className={styles.previews}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<RetagPreviewRow
|
||||
key={item.trackFileId}
|
||||
id={item.trackFileId}
|
||||
path={item.relativePath}
|
||||
changes={item.changes}
|
||||
isSelected={selectedState[item.trackFileId]}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
isPopulated && !!items.length &&
|
||||
<CheckInput
|
||||
className={styles.selectAllInput}
|
||||
containerClassName={styles.selectAllInputContainer}
|
||||
name="selectAll"
|
||||
value={selectAllValue}
|
||||
onChange={this.onSelectAllChange}
|
||||
/>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={this.onRetagPress}
|
||||
>
|
||||
Retag
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewModalContent.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
onRetagPress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RetagPreviewModalContent;
|
@ -0,0 +1,86 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createArtistSelector from 'Store/Selectors/createArtistSelector';
|
||||
import { fetchRetagPreview } from 'Store/Actions/retagPreviewActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import RetagPreviewModalContent from './RetagPreviewModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.retagPreview,
|
||||
createArtistSelector(),
|
||||
(retagPreview, artist) => {
|
||||
const props = { ...retagPreview };
|
||||
props.isFetching = retagPreview.isFetching;
|
||||
props.isPopulated = retagPreview.isPopulated;
|
||||
props.error = retagPreview.error;
|
||||
props.path = artist.path;
|
||||
|
||||
return props;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchRetagPreview,
|
||||
executeCommand
|
||||
};
|
||||
|
||||
class RetagPreviewModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
artistId,
|
||||
albumId
|
||||
} = this.props;
|
||||
|
||||
this.props.fetchRetagPreview({
|
||||
artistId,
|
||||
albumId
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRetagPress = (files) => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.RETAG_FILES,
|
||||
artistId: this.props.artistId,
|
||||
files
|
||||
});
|
||||
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<RetagPreviewModalContent
|
||||
{...this.props}
|
||||
onRetagPress={this.onRetagPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewModalContentConnector.propTypes = {
|
||||
artistId: PropTypes.number.isRequired,
|
||||
albumId: PropTypes.number,
|
||||
retagTracks: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
fetchRetagPreview: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(RetagPreviewModalContentConnector);
|
@ -0,0 +1,26 @@
|
||||
.row {
|
||||
display: flex;
|
||||
margin-bottom: 5px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectedContainer {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.path {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import styles from './RetagPreviewRow.css';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
|
||||
function formatMissing(value) {
|
||||
if (value === undefined || value === 0 || value === '0') {
|
||||
return (<Icon name={icons.BAN} size={12} />);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatChange(oldValue, newValue) {
|
||||
return (
|
||||
<div> {formatMissing(oldValue)} <Icon name={icons.ARROW_RIGHT_NO_CIRCLE} size={12} /> {formatMissing(newValue)} </div>
|
||||
);
|
||||
}
|
||||
|
||||
class RetagPreviewRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
id,
|
||||
onSelectedChange
|
||||
} = this.props;
|
||||
|
||||
onSelectedChange({ id, value: true });
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSelectedChange = ({ value, shiftKey }) => {
|
||||
const {
|
||||
id,
|
||||
onSelectedChange
|
||||
} = this.props;
|
||||
|
||||
onSelectedChange({ id, value, shiftKey });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
path,
|
||||
changes,
|
||||
isSelected
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<CheckInput
|
||||
containerClassName={styles.selectedContainer}
|
||||
name={id.toString()}
|
||||
value={isSelected}
|
||||
onChange={this.onSelectedChange}
|
||||
/>
|
||||
|
||||
<div className={styles.column}>
|
||||
<span className={styles.path}>
|
||||
{path}
|
||||
</span>
|
||||
|
||||
<DescriptionList>
|
||||
{
|
||||
changes.map(({ field, oldValue, newValue }) => {
|
||||
return (
|
||||
<DescriptionListItem
|
||||
key={field}
|
||||
title={field}
|
||||
data={formatChange(oldValue, newValue)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
changes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
onSelectedChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RetagPreviewRow;
|
@ -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);
|
@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Lidarr.Http;
|
||||
using Lidarr.Http.REST;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
|
||||
namespace Lidarr.Api.V1.Tracks
|
||||
{
|
||||
public class RetagTrackModule : LidarrRestModule<RetagTrackResource>
|
||||
{
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
|
||||
public RetagTrackModule(IAudioTagService audioTagService)
|
||||
: base("retag")
|
||||
{
|
||||
_audioTagService = audioTagService;
|
||||
|
||||
GetResourceAll = GetTracks;
|
||||
}
|
||||
|
||||
private List<RetagTrackResource> GetTracks()
|
||||
{
|
||||
if (Request.Query.albumId.HasValue)
|
||||
{
|
||||
var albumId = (int)Request.Query.albumId;
|
||||
return _audioTagService.GetRetagPreviewsByAlbum(albumId).Where(x => x.Changes.Any()).ToResource();
|
||||
}
|
||||
else if (Request.Query.ArtistId.HasValue)
|
||||
{
|
||||
var artistId = (int)Request.Query.ArtistId;
|
||||
return _audioTagService.GetRetagPreviewsByArtist(artistId).Where(x => x.Changes.Any()).ToResource();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new BadRequestException("One of artistId or albumId must be specified");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Lidarr.Http.REST;
|
||||
|
||||
namespace Lidarr.Api.V1.Tracks
|
||||
{
|
||||
public class TagDifference
|
||||
{
|
||||
public string Field { get; set; }
|
||||
public string OldValue { get; set; }
|
||||
public string NewValue { get; set; }
|
||||
}
|
||||
|
||||
public class RetagTrackResource : RestResource
|
||||
{
|
||||
public int ArtistId { get; set; }
|
||||
public int AlbumId { get; set; }
|
||||
public List<int> TrackNumbers { get; set; }
|
||||
public int TrackFileId { get; set; }
|
||||
public string RelativePath { get; set; }
|
||||
public List<TagDifference> Changes { get; set; }
|
||||
}
|
||||
|
||||
public static class RetagTrackResourceMapper
|
||||
{
|
||||
public static RetagTrackResource ToResource(this NzbDrone.Core.MediaFiles.RetagTrackFilePreview model)
|
||||
{
|
||||
if (model == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RetagTrackResource
|
||||
{
|
||||
ArtistId = model.ArtistId,
|
||||
AlbumId = model.AlbumId,
|
||||
TrackNumbers = model.TrackNumbers.ToList(),
|
||||
TrackFileId = model.TrackFileId,
|
||||
RelativePath = model.RelativePath,
|
||||
Changes = model.Changes.Select(x => new TagDifference {
|
||||
Field = x.Key,
|
||||
OldValue = x.Value.Item1,
|
||||
NewValue = x.Value.Item2
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public static List<RetagTrackResource> ToResource(this IEnumerable<NzbDrone.Core.MediaFiles.RetagTrackFilePreview> models)
|
||||
{
|
||||
return models.Select(ToResource).ToList();
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
@ -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/
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
namespace NzbDrone.Core.Configuration
|
||||
{
|
||||
public enum WriteAudioTagsType
|
||||
{
|
||||
No,
|
||||
NewFiles,
|
||||
AllFiles,
|
||||
Sync
|
||||
}
|
||||
}
|
@ -0,0 +1,590 @@
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Languages;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Common.Instrumentation;
|
||||
using NLog;
|
||||
using TagLib;
|
||||
using TagLib.Id3v2;
|
||||
using NLog.Fluent;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using System.Globalization;
|
||||
|
||||
namespace NzbDrone.Core.MediaFiles
|
||||
{
|
||||
public class AudioTag
|
||||
{
|
||||
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AudioTag));
|
||||
|
||||
public string Title { get; set; }
|
||||
public string[] Performers { get; set; }
|
||||
public string[] AlbumArtists { get; set; }
|
||||
public uint Track { get; set; }
|
||||
public uint TrackCount { get; set; }
|
||||
public string Album { get; set; }
|
||||
public uint Disc { get; set; }
|
||||
public uint DiscCount { get; set; }
|
||||
public string Media { get; set; }
|
||||
public DateTime? Date { get; set; }
|
||||
public DateTime? OriginalReleaseDate { get; set; }
|
||||
public uint Year { get; set; }
|
||||
public uint OriginalYear { get; set; }
|
||||
public string Publisher { get; set; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
public string MusicBrainzReleaseCountry { get; set; }
|
||||
public string MusicBrainzReleaseStatus { get; set; }
|
||||
public string MusicBrainzReleaseType { get; set; }
|
||||
public string MusicBrainzReleaseId { get; set; }
|
||||
public string MusicBrainzArtistId { get; set; }
|
||||
public string MusicBrainzReleaseArtistId { get; set; }
|
||||
public string MusicBrainzReleaseGroupId { get; set; }
|
||||
public string MusicBrainzTrackId { get; set; }
|
||||
public string MusicBrainzReleaseTrackId { get; set; }
|
||||
public string MusicBrainzAlbumComment { get; set; }
|
||||
|
||||
public bool IsValid { get; private set; }
|
||||
public QualityModel Quality { get; set; }
|
||||
public MediaInfoModel MediaInfo { get; set; }
|
||||
|
||||
public AudioTag()
|
||||
{
|
||||
IsValid = true;
|
||||
}
|
||||
|
||||
public AudioTag(string path)
|
||||
{
|
||||
Read(path);
|
||||
}
|
||||
|
||||
public void Read(string path)
|
||||
{
|
||||
Logger.Debug($"Starting tag read for {path}");
|
||||
|
||||
IsValid = false;
|
||||
TagLib.File file = null;
|
||||
try
|
||||
{
|
||||
file = TagLib.File.Create(path);
|
||||
var tag = file.Tag;
|
||||
|
||||
Title = tag.Title ?? tag.TitleSort;
|
||||
Performers = tag.Performers ?? tag.PerformersSort;
|
||||
AlbumArtists = tag.AlbumArtists ?? tag.AlbumArtistsSort;
|
||||
Track = tag.Track;
|
||||
TrackCount = tag.TrackCount;
|
||||
Album = tag.Album ?? tag.AlbumSort;
|
||||
Disc = tag.Disc;
|
||||
DiscCount = tag.DiscCount;
|
||||
Year = tag.Year;
|
||||
Publisher = tag.Publisher;
|
||||
Duration = file.Properties.Duration;
|
||||
MusicBrainzReleaseCountry = tag.MusicBrainzReleaseCountry;
|
||||
MusicBrainzReleaseStatus = tag.MusicBrainzReleaseStatus;
|
||||
MusicBrainzReleaseType = tag.MusicBrainzReleaseType;
|
||||
MusicBrainzReleaseId = tag.MusicBrainzReleaseId;
|
||||
MusicBrainzArtistId = tag.MusicBrainzArtistId;
|
||||
MusicBrainzReleaseArtistId = tag.MusicBrainzReleaseArtistId;
|
||||
MusicBrainzReleaseGroupId = tag.MusicBrainzReleaseGroupId;
|
||||
MusicBrainzTrackId = tag.MusicBrainzTrackId;
|
||||
|
||||
DateTime tempDate;
|
||||
|
||||
// Do the ones that aren't handled by the generic taglib implementation
|
||||
if (file.TagTypesOnDisk.HasFlag(TagTypes.Id3v2))
|
||||
{
|
||||
var id3tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2);
|
||||
Media = id3tag.GetTextAsString("TMED");
|
||||
Date = ReadId3Date(id3tag, "TDRC");
|
||||
OriginalReleaseDate = ReadId3Date(id3tag, "TDOR");
|
||||
MusicBrainzAlbumComment = UserTextInformationFrame.Get(id3tag, "MusicBrainz Album Comment", false)?.Text.ExclusiveOrDefault();
|
||||
MusicBrainzReleaseTrackId = UserTextInformationFrame.Get(id3tag, "MusicBrainz Release Track Id", false)?.Text.ExclusiveOrDefault();
|
||||
}
|
||||
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Xiph))
|
||||
{
|
||||
// while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is
|
||||
// https://picard.musicbrainz.org/docs/mappings/
|
||||
var flactag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph);
|
||||
Media = flactag.GetField("MEDIA").ExclusiveOrDefault();
|
||||
Date = DateTime.TryParse(flactag.GetField("DATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?);
|
||||
OriginalReleaseDate = DateTime.TryParse(flactag.GetField("ORIGINALDATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?);
|
||||
Publisher = flactag.GetField("LABEL").ExclusiveOrDefault();
|
||||
MusicBrainzAlbumComment = flactag.GetField("MUSICBRAINZ_ALBUMCOMMENT").ExclusiveOrDefault();
|
||||
MusicBrainzReleaseTrackId = flactag.GetField("MUSICBRAINZ_RELEASETRACKID").ExclusiveOrDefault();
|
||||
|
||||
// If we haven't managed to read status/type, try the alternate mapping
|
||||
if (MusicBrainzReleaseStatus.IsNullOrWhiteSpace())
|
||||
{
|
||||
MusicBrainzReleaseStatus = flactag.GetField("RELEASESTATUS").ExclusiveOrDefault();
|
||||
}
|
||||
|
||||
if (MusicBrainzReleaseType.IsNullOrWhiteSpace())
|
||||
{
|
||||
MusicBrainzReleaseType = flactag.GetField("RELEASETYPE").ExclusiveOrDefault();
|
||||
}
|
||||
}
|
||||
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Ape))
|
||||
{
|
||||
var apetag = (TagLib.Ape.Tag) file.GetTag(TagTypes.Ape);
|
||||
Media = apetag.GetItem("Media")?.ToString();
|
||||
Date = DateTime.TryParse(apetag.GetItem("Year")?.ToString(), out tempDate) ? tempDate : default(DateTime?);
|
||||
OriginalReleaseDate = DateTime.TryParse(apetag.GetItem("Original Date")?.ToString(), out tempDate) ? tempDate : default(DateTime?);
|
||||
Publisher = apetag.GetItem("Label")?.ToString();
|
||||
MusicBrainzAlbumComment = apetag.GetItem("MUSICBRAINZ_ALBUMCOMMENT")?.ToString();
|
||||
MusicBrainzReleaseTrackId = apetag.GetItem("MUSICBRAINZ_RELEASETRACKID")?.ToString();
|
||||
}
|
||||
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Asf))
|
||||
{
|
||||
var asftag = (TagLib.Asf.Tag) file.GetTag(TagTypes.Asf);
|
||||
Media = asftag.GetDescriptorString("WM/Media");
|
||||
Date = DateTime.TryParse(asftag.GetDescriptorString("WM/Year"), out tempDate) ? tempDate : default(DateTime?);
|
||||
OriginalReleaseDate = DateTime.TryParse(asftag.GetDescriptorString("WM/OriginalReleaseTime"), out tempDate) ? tempDate : default(DateTime?);
|
||||
Publisher = asftag.GetDescriptorString("WM/Publisher");
|
||||
MusicBrainzAlbumComment = asftag.GetDescriptorString("MusicBrainz/Album Comment");
|
||||
MusicBrainzReleaseTrackId = asftag.GetDescriptorString("MusicBrainz/Release Track Id");
|
||||
}
|
||||
else if (file.TagTypesOnDisk.HasFlag(TagTypes.Apple))
|
||||
{
|
||||
var appletag = (TagLib.Mpeg4.AppleTag) file.GetTag(TagTypes.Apple);
|
||||
Media = appletag.GetDashBox("com.apple.iTunes", "MEDIA");
|
||||
Date = DateTime.TryParse(appletag.DataBoxes(FixAppleId("day")).First().Text, out tempDate) ? tempDate : default(DateTime?);
|
||||
OriginalReleaseDate = DateTime.TryParse(appletag.GetDashBox("com.apple.iTunes", "Original Date"), out tempDate) ? tempDate : default(DateTime?);
|
||||
MusicBrainzAlbumComment = appletag.GetDashBox("com.apple.iTunes", "MusicBrainz Album Comment");
|
||||
MusicBrainzReleaseTrackId = appletag.GetDashBox("com.apple.iTunes", "MusicBrainz Release Track Id");
|
||||
}
|
||||
|
||||
OriginalYear = OriginalReleaseDate.HasValue ? (uint)OriginalReleaseDate?.Year : 0;
|
||||
|
||||
foreach (ICodec codec in file.Properties.Codecs)
|
||||
{
|
||||
IAudioCodec acodec = codec as IAudioCodec;
|
||||
|
||||
if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None)
|
||||
{
|
||||
int bitrate = acodec.AudioBitrate;
|
||||
if (bitrate == 0)
|
||||
{
|
||||
// Taglib can't read bitrate for Opus.
|
||||
// Taglib File.Length is unreliable so use System.IO
|
||||
var size = new System.IO.FileInfo(path).Length;
|
||||
var duration = file.Properties.Duration.TotalSeconds;
|
||||
bitrate = (int) ((size * 8L) / (duration * 1024));
|
||||
Logger.Trace($"Estimating bitrate. Size: {size} Duration: {duration} Bitrate: {bitrate}");
|
||||
}
|
||||
|
||||
Logger.Debug("Audio Properties: " + acodec.Description + ", Bitrate: " + bitrate + ", Sample Size: " +
|
||||
file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels);
|
||||
|
||||
Quality = QualityParser.ParseQuality(file.Name, acodec.Description, bitrate, file.Properties.BitsPerSample);
|
||||
Logger.Debug($"Quality parsed: {Quality}, Source: {Quality.QualityDetectionSource}");
|
||||
|
||||
MediaInfo = new MediaInfoModel {
|
||||
AudioFormat = acodec.Description,
|
||||
AudioBitrate = bitrate,
|
||||
AudioChannels = acodec.AudioChannels,
|
||||
AudioBits = file.Properties.BitsPerSample,
|
||||
AudioSampleRate = acodec.AudioSampleRate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
IsValid = true;
|
||||
}
|
||||
catch (CorruptFileException ex)
|
||||
{
|
||||
Logger.Warn(ex, $"Tag reading failed for {path}. File is corrupt");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn()
|
||||
.Exception(ex)
|
||||
.Message($"Tag reading failed for {path}")
|
||||
.WriteSentryWarn("Tag reading failed")
|
||||
.Write();
|
||||
}
|
||||
finally
|
||||
{
|
||||
file?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime? ReadId3Date(TagLib.Id3v2.Tag tag, string dateTag)
|
||||
{
|
||||
string date = tag.GetTextAsString(dateTag);
|
||||
|
||||
if (tag.Version == 4)
|
||||
{
|
||||
// the unabused TDRC/TDOR tags
|
||||
return DateTime.TryParse(date, out DateTime result) ? result : default(DateTime?);
|
||||
}
|
||||
else if (dateTag == "TDRC")
|
||||
{
|
||||
// taglib maps the v3 TYER and TDAT to TDRC but does it incorrectly
|
||||
return DateTime.TryParseExact(date, "yyyy-dd-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : default(DateTime?);
|
||||
}
|
||||
else
|
||||
{
|
||||
// taglib maps the v3 TORY to TDRC so we just get a year
|
||||
return Int32.TryParse(date, out int year) ? new DateTime(year, 1, 1) : default(DateTime?);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveId3UserTextFrame(TagLib.Id3v2.Tag tag, string ident)
|
||||
{
|
||||
var frame = UserTextInformationFrame.Get(tag, ident, false);
|
||||
if (frame != null)
|
||||
{
|
||||
tag.RemoveFrame(frame);
|
||||
}
|
||||
tag.RemoveFrames(ident);
|
||||
}
|
||||
|
||||
private void WriteId3Date(TagLib.Id3v2.Tag tag, string v4field, string v3yyyy, string v3ddmm, DateTime? date)
|
||||
{
|
||||
if (date.HasValue)
|
||||
{
|
||||
if (tag.Version == 4)
|
||||
{
|
||||
RemoveId3UserTextFrame(tag, v3yyyy);
|
||||
if (v3ddmm.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
RemoveId3UserTextFrame(tag, v3ddmm);
|
||||
}
|
||||
tag.SetTextFrame(v4field, date.Value.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveId3UserTextFrame(tag, v4field);
|
||||
tag.SetTextFrame(v3yyyy, date.Value.ToString("yyyy"));
|
||||
if (v3ddmm.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
tag.SetTextFrame(v3ddmm, date.Value.ToString("ddMM"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteId3Tag(TagLib.Id3v2.Tag tag, string id, string value)
|
||||
{
|
||||
var frame = UserTextInformationFrame.Get(tag, id, true);
|
||||
|
||||
if (value.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
frame.Text = value.Split(';');
|
||||
}
|
||||
else
|
||||
{
|
||||
tag.RemoveFrame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
private static ReadOnlyByteVector FixAppleId(ByteVector id)
|
||||
{
|
||||
if (id.Count == 4) {
|
||||
var roid = id as ReadOnlyByteVector;
|
||||
if (roid != null)
|
||||
return roid;
|
||||
|
||||
return new ReadOnlyByteVector(id);
|
||||
}
|
||||
|
||||
if (id.Count == 3)
|
||||
return new ReadOnlyByteVector(0xa9, id[0], id[1], id[2]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Write(string path)
|
||||
{
|
||||
Logger.Debug($"Starting tag write for {path}");
|
||||
|
||||
TagLib.File file = null;
|
||||
try
|
||||
{
|
||||
file = TagLib.File.Create(path);
|
||||
var tag = file.Tag;
|
||||
|
||||
// do the ones with direct support in TagLib
|
||||
tag.Title = Title;
|
||||
tag.Performers = Performers;
|
||||
tag.AlbumArtists = AlbumArtists;
|
||||
tag.Track = Track;
|
||||
tag.TrackCount = TrackCount;
|
||||
tag.Album = Album;
|
||||
tag.Disc = Disc;
|
||||
tag.DiscCount = DiscCount;
|
||||
tag.Publisher = Publisher;
|
||||
tag.MusicBrainzReleaseCountry = MusicBrainzReleaseCountry;
|
||||
tag.MusicBrainzReleaseStatus = MusicBrainzReleaseStatus;
|
||||
tag.MusicBrainzReleaseType = MusicBrainzReleaseType;
|
||||
tag.MusicBrainzReleaseId = MusicBrainzReleaseId;
|
||||
tag.MusicBrainzArtistId = MusicBrainzArtistId;
|
||||
tag.MusicBrainzReleaseArtistId = MusicBrainzReleaseArtistId;
|
||||
tag.MusicBrainzReleaseGroupId = MusicBrainzReleaseGroupId;
|
||||
tag.MusicBrainzTrackId = MusicBrainzTrackId;
|
||||
|
||||
if (file.TagTypes.HasFlag(TagTypes.Id3v2))
|
||||
{
|
||||
var id3tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2);
|
||||
id3tag.SetTextFrame("TMED", Media);
|
||||
WriteId3Date(id3tag, "TDRC", "TYER", "TDAT", Date);
|
||||
WriteId3Date(id3tag, "TDOR", "TORY", null, OriginalReleaseDate);
|
||||
WriteId3Tag(id3tag, "MusicBrainz Album Comment", MusicBrainzAlbumComment);
|
||||
WriteId3Tag(id3tag, "MusicBrainz Release Track Id", MusicBrainzReleaseTrackId);
|
||||
}
|
||||
else if (file.TagTypes.HasFlag(TagTypes.Xiph))
|
||||
{
|
||||
// while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is
|
||||
// https://picard.musicbrainz.org/docs/mappings/
|
||||
tag.Publisher = null;
|
||||
// taglib inserts leading zeros so set manually
|
||||
tag.Track = 0;
|
||||
|
||||
var flactag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph);
|
||||
|
||||
if (Date.HasValue)
|
||||
{
|
||||
flactag.SetField("DATE", Date.Value.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
if (OriginalReleaseDate.HasValue)
|
||||
{
|
||||
flactag.SetField("ORIGINALDATE", OriginalReleaseDate.Value.ToString("yyyy-MM-dd"));
|
||||
flactag.SetField("ORIGINALYEAR", OriginalReleaseDate.Value.Year.ToString());
|
||||
}
|
||||
|
||||
flactag.SetField("TRACKTOTAL", TrackCount);
|
||||
flactag.SetField("TOTALTRACKS", TrackCount);
|
||||
flactag.SetField("TRACKNUMBER", Track);
|
||||
flactag.SetField("TOTALDISCS", DiscCount);
|
||||
flactag.SetField("MEDIA", Media);
|
||||
flactag.SetField("LABEL", Publisher);
|
||||
flactag.SetField("MUSICBRAINZ_ALBUMCOMMENT", MusicBrainzAlbumComment);
|
||||
flactag.SetField("MUSICBRAINZ_RELEASETRACKID", MusicBrainzReleaseTrackId);
|
||||
|
||||
// Add the alternate mappings used by picard (we write both)
|
||||
flactag.SetField("RELEASESTATUS", MusicBrainzReleaseStatus);
|
||||
flactag.SetField("RELEASETYPE", MusicBrainzReleaseType);
|
||||
}
|
||||
else if (file.TagTypes.HasFlag(TagTypes.Ape))
|
||||
{
|
||||
var apetag = (TagLib.Ape.Tag) file.GetTag(TagTypes.Ape);
|
||||
|
||||
if (Date.HasValue)
|
||||
{
|
||||
apetag.SetValue("Year", Date.Value.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
if (OriginalReleaseDate.HasValue)
|
||||
{
|
||||
apetag.SetValue("Original Date", OriginalReleaseDate.Value.ToString("yyyy-MM-dd"));
|
||||
apetag.SetValue("Original Year", OriginalReleaseDate.Value.Year.ToString());
|
||||
}
|
||||
|
||||
apetag.SetValue("Media", Media);
|
||||
apetag.SetValue("Label", Publisher);
|
||||
apetag.SetValue("MUSICBRAINZ_ALBUMCOMMENT", MusicBrainzAlbumComment);
|
||||
apetag.SetValue("MUSICBRAINZ_RELEASETRACKID", MusicBrainzReleaseTrackId);
|
||||
}
|
||||
else if (file.TagTypes.HasFlag(TagTypes.Asf))
|
||||
{
|
||||
var asftag = (TagLib.Asf.Tag) file.GetTag(TagTypes.Asf);
|
||||
|
||||
if (Date.HasValue)
|
||||
{
|
||||
asftag.SetDescriptorString(Date.Value.ToString("yyyy-MM-dd"), "WM/Year");
|
||||
}
|
||||
if (OriginalReleaseDate.HasValue)
|
||||
{
|
||||
asftag.SetDescriptorString(OriginalReleaseDate.Value.ToString("yyyy-MM-dd"), "WM/OriginalReleaseTime");
|
||||
asftag.SetDescriptorString(OriginalReleaseDate.Value.Year.ToString(), "WM/OriginalReleaseYear");
|
||||
}
|
||||
|
||||
asftag.SetDescriptorString(Media, "WM/Media");
|
||||
asftag.SetDescriptorString(Publisher, "WM/Publisher");
|
||||
asftag.SetDescriptorString(MusicBrainzAlbumComment, "MusicBrainz/Album Comment");
|
||||
asftag.SetDescriptorString(MusicBrainzReleaseTrackId, "MusicBrainz/Release Track Id");
|
||||
}
|
||||
else if (file.TagTypes.HasFlag(TagTypes.Apple))
|
||||
{
|
||||
var appletag = (TagLib.Mpeg4.AppleTag) file.GetTag(TagTypes.Apple);
|
||||
|
||||
if (Date.HasValue)
|
||||
{
|
||||
appletag.SetText(FixAppleId("day"), Date.Value.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
if (OriginalReleaseDate.HasValue)
|
||||
{
|
||||
appletag.SetDashBox("com.apple.iTunes", "Original Date", OriginalReleaseDate.Value.ToString("yyyy-MM-dd"));
|
||||
appletag.SetDashBox("com.apple.iTunes", "Original Year", OriginalReleaseDate.Value.Year.ToString());
|
||||
}
|
||||
|
||||
appletag.SetDashBox("com.apple.iTunes", "MEDIA", Media);
|
||||
appletag.SetDashBox("com.apple.iTunes", "MusicBrainz Album Comment", MusicBrainzAlbumComment);
|
||||
appletag.SetDashBox("com.apple.iTunes", "MusicBrainz Release Track Id", MusicBrainzReleaseTrackId);
|
||||
}
|
||||
|
||||
file.Save();
|
||||
}
|
||||
catch (CorruptFileException ex)
|
||||
{
|
||||
Logger.Warn(ex, $"Tag writing failed for {path}. File is corrupt");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn()
|
||||
.Exception(ex)
|
||||
.Message($"Tag writing failed for {path}")
|
||||
.WriteSentryWarn("Tag writing failed")
|
||||
.Write();
|
||||
}
|
||||
finally
|
||||
{
|
||||
file?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, Tuple<string, string>> Diff(AudioTag other)
|
||||
{
|
||||
var output = new Dictionary<string, Tuple<string, string>>();
|
||||
|
||||
if (!IsValid || !other.IsValid)
|
||||
{
|
||||
return output;
|
||||
}
|
||||
|
||||
if (Title != other.Title)
|
||||
{
|
||||
output.Add("Title", Tuple.Create(Title, other.Title));
|
||||
}
|
||||
|
||||
if (!Performers.SequenceEqual(other.Performers))
|
||||
{
|
||||
var oldValue = Performers.Any() ? string.Join(" / ", Performers) : null;
|
||||
var newValue = other.Performers.Any() ? string.Join(" / ", other.Performers) : null;
|
||||
|
||||
output.Add("Artist", Tuple.Create(oldValue, newValue));
|
||||
}
|
||||
|
||||
if (Album != other.Album)
|
||||
{
|
||||
output.Add("Album", Tuple.Create(Album, other.Album));
|
||||
}
|
||||
|
||||
if (!AlbumArtists.SequenceEqual(other.AlbumArtists))
|
||||
{
|
||||
var oldValue = AlbumArtists.Any() ? string.Join(" / ", AlbumArtists) : null;
|
||||
var newValue = other.AlbumArtists.Any() ? string.Join(" / ", other.AlbumArtists) : null;
|
||||
|
||||
output.Add("Album Artist", Tuple.Create(oldValue, newValue));
|
||||
}
|
||||
|
||||
if (Track != other.Track)
|
||||
{
|
||||
output.Add("Track", Tuple.Create(Track.ToString(), other.Track.ToString()));
|
||||
}
|
||||
|
||||
if (TrackCount != other.TrackCount)
|
||||
{
|
||||
output.Add("Track Count", Tuple.Create(TrackCount.ToString(), other.TrackCount.ToString()));
|
||||
}
|
||||
|
||||
if (Disc != other.Disc)
|
||||
{
|
||||
output.Add("Disc", Tuple.Create(Disc.ToString(), other.Disc.ToString()));
|
||||
}
|
||||
|
||||
if (DiscCount != other.DiscCount)
|
||||
{
|
||||
output.Add("Disc Count", Tuple.Create(DiscCount.ToString(), other.DiscCount.ToString()));
|
||||
}
|
||||
|
||||
if (Media != other.Media)
|
||||
{
|
||||
output.Add("Media Format", Tuple.Create(Media, other.Media));
|
||||
}
|
||||
|
||||
if (Date != other.Date)
|
||||
{
|
||||
var oldValue = Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null;
|
||||
var newValue = other.Date.HasValue ? other.Date.Value.ToString("yyyy-MM-dd") : null;
|
||||
output.Add("Date", Tuple.Create(oldValue, newValue));
|
||||
}
|
||||
|
||||
if (OriginalReleaseDate != other.OriginalReleaseDate)
|
||||
{
|
||||
// Id3v2.3 tags can only store the year, not the full date
|
||||
if (OriginalReleaseDate.HasValue &&
|
||||
OriginalReleaseDate.Value.Month == 1 &&
|
||||
OriginalReleaseDate.Value.Day == 1)
|
||||
{
|
||||
if (OriginalReleaseDate.Value.Year != other.OriginalReleaseDate.Value.Year)
|
||||
{
|
||||
output.Add("Original Year", Tuple.Create(OriginalReleaseDate.Value.Year.ToString(), other.OriginalReleaseDate.Value.Year.ToString()));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var oldValue = OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null;
|
||||
var newValue = other.OriginalReleaseDate.HasValue ? other.OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null;
|
||||
output.Add("Original Release Date", Tuple.Create(oldValue, newValue));
|
||||
}
|
||||
}
|
||||
|
||||
if (Publisher != other.Publisher)
|
||||
{
|
||||
output.Add("Label", Tuple.Create(Publisher, other.Publisher));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public static implicit operator ParsedTrackInfo (AudioTag tag)
|
||||
{
|
||||
if (!tag.IsValid)
|
||||
{
|
||||
return new ParsedTrackInfo { Language = Language.English };
|
||||
}
|
||||
|
||||
var artist = tag.AlbumArtists?.FirstOrDefault();
|
||||
|
||||
if (artist.IsNullOrWhiteSpace())
|
||||
{
|
||||
artist = tag.Performers?.FirstOrDefault();
|
||||
}
|
||||
|
||||
var artistTitleInfo = new ArtistTitleInfo
|
||||
{
|
||||
Title = artist,
|
||||
Year = (int)tag.Year
|
||||
};
|
||||
|
||||
return new ParsedTrackInfo {
|
||||
Language = Language.English,
|
||||
AlbumTitle = tag.Album,
|
||||
ArtistTitle = artist,
|
||||
ArtistMBId = tag.MusicBrainzReleaseArtistId,
|
||||
AlbumMBId = tag.MusicBrainzReleaseGroupId,
|
||||
ReleaseMBId = tag.MusicBrainzReleaseId,
|
||||
// SIC: the recording ID is stored in this field.
|
||||
// See https://picard.musicbrainz.org/docs/mappings/
|
||||
RecordingMBId = tag.MusicBrainzTrackId,
|
||||
TrackMBId = tag.MusicBrainzReleaseTrackId,
|
||||
DiscNumber = (int)tag.Disc,
|
||||
DiscCount = (int)tag.DiscCount,
|
||||
Year = tag.Year,
|
||||
Label = tag.Publisher,
|
||||
TrackNumbers = new [] { (int) tag.Track },
|
||||
ArtistTitleInfo = artistTitleInfo,
|
||||
Title = tag.Title,
|
||||
CleanTitle = tag.Title?.CleanTrackTitle(),
|
||||
Country = IsoCountries.Find(tag.MusicBrainzReleaseCountry),
|
||||
Duration = tag.Duration,
|
||||
Disambiguation = tag.MusicBrainzAlbumComment,
|
||||
Quality = tag.Quality,
|
||||
MediaInfo = tag.MediaInfo
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,363 @@
|
||||
using NLog;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Core.MediaFiles.Commands;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using NzbDrone.Core.Music;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Common.Disk;
|
||||
using System;
|
||||
using NLog.Fluent;
|
||||
using NzbDrone.Core.MediaFiles.Events;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using TagLib;
|
||||
|
||||
namespace NzbDrone.Core.MediaFiles
|
||||
{
|
||||
public interface IAudioTagService
|
||||
{
|
||||
ParsedTrackInfo ReadTags(string file);
|
||||
void WriteTags(TrackFile trackfile, bool newDownload, bool force = false);
|
||||
void SyncTags(List<Track> tracks);
|
||||
void RemoveMusicBrainzTags(IEnumerable<Album> album);
|
||||
void RemoveMusicBrainzTags(IEnumerable<AlbumRelease> albumRelease);
|
||||
void RemoveMusicBrainzTags(IEnumerable<Track> tracks);
|
||||
void RemoveMusicBrainzTags(TrackFile trackfile);
|
||||
List<RetagTrackFilePreview> GetRetagPreviewsByArtist(int artistId);
|
||||
List<RetagTrackFilePreview> GetRetagPreviewsByAlbum(int artistId);
|
||||
}
|
||||
|
||||
public class AudioTagService : IAudioTagService,
|
||||
IExecute<RetagArtistCommand>,
|
||||
IExecute<RetagFilesCommand>
|
||||
{
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IMediaFileService _mediaFileService;
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly IArtistService _artistService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public AudioTagService(IConfigService configService,
|
||||
IMediaFileService mediaFileService,
|
||||
IDiskProvider diskProvider,
|
||||
IArtistService artistService,
|
||||
IEventAggregator eventAggregator,
|
||||
Logger logger)
|
||||
{
|
||||
_configService = configService;
|
||||
_mediaFileService = mediaFileService;
|
||||
_diskProvider = diskProvider;
|
||||
_artistService = artistService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public AudioTag ReadAudioTag(string path)
|
||||
{
|
||||
return new AudioTag(path);
|
||||
}
|
||||
|
||||
public ParsedTrackInfo ReadTags(string path)
|
||||
{
|
||||
return new AudioTag(path);
|
||||
}
|
||||
|
||||
private AudioTag GetTrackMetadata(TrackFile trackfile)
|
||||
{
|
||||
var track = trackfile.Tracks.Value[0];
|
||||
var release = track.AlbumRelease.Value;
|
||||
var album = release.Album.Value;
|
||||
var albumartist = album.Artist.Value;
|
||||
var artist = track.ArtistMetadata.Value;
|
||||
|
||||
return new AudioTag {
|
||||
Title = track.Title,
|
||||
Performers = new [] { artist.Name },
|
||||
AlbumArtists = new [] { albumartist.Name },
|
||||
Track = (uint)track.AbsoluteTrackNumber,
|
||||
TrackCount = (uint)release.Tracks.Value.Count(x => x.MediumNumber == track.MediumNumber),
|
||||
Album = album.Title,
|
||||
Disc = (uint)track.MediumNumber,
|
||||
DiscCount = (uint)release.Media.Count,
|
||||
Media = release.Media[track.MediumNumber - 1].Format,
|
||||
Date = release.ReleaseDate,
|
||||
Year = (uint)album.ReleaseDate?.Year,
|
||||
OriginalReleaseDate = album.ReleaseDate,
|
||||
OriginalYear = (uint)album.ReleaseDate?.Year,
|
||||
Publisher = release.Label.FirstOrDefault(),
|
||||
MusicBrainzReleaseCountry = IsoCountries.Find(release.Country.FirstOrDefault()).TwoLetterCode,
|
||||
MusicBrainzReleaseStatus = release.Status.ToLower(),
|
||||
MusicBrainzReleaseType = album.AlbumType.ToLower(),
|
||||
MusicBrainzReleaseId = release.ForeignReleaseId,
|
||||
MusicBrainzArtistId = artist.ForeignArtistId,
|
||||
MusicBrainzReleaseArtistId = albumartist.ForeignArtistId,
|
||||
MusicBrainzReleaseGroupId = album.ForeignAlbumId,
|
||||
MusicBrainzTrackId = track.ForeignRecordingId,
|
||||
MusicBrainzReleaseTrackId = track.ForeignTrackId,
|
||||
MusicBrainzAlbumComment = album.Disambiguation,
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateTrackfileSize(TrackFile trackfile, string path)
|
||||
{
|
||||
// update the saved file size so that the importer doesn't get confused on the next scan
|
||||
trackfile.Size = _diskProvider.GetFileSize(path);
|
||||
if (trackfile.Id > 0)
|
||||
{
|
||||
_mediaFileService.Update(trackfile);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveAllTags(string path)
|
||||
{
|
||||
TagLib.File file = null;
|
||||
try
|
||||
{
|
||||
file = TagLib.File.Create(path);
|
||||
file.RemoveTags(TagLib.TagTypes.AllTags);
|
||||
file.Save();
|
||||
}
|
||||
catch (CorruptFileException ex)
|
||||
{
|
||||
_logger.Warn(ex, $"Tag removal failed for {path}. File is corrupt");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn()
|
||||
.Exception(ex)
|
||||
.Message($"Tag removal failed for {path}")
|
||||
.WriteSentryWarn("Tag removal failed")
|
||||
.Write();
|
||||
}
|
||||
finally
|
||||
{
|
||||
file?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveMusicBrainzTags(string path)
|
||||
{
|
||||
var tags = new AudioTag(path);
|
||||
|
||||
tags.MusicBrainzReleaseCountry = null;
|
||||
tags.MusicBrainzReleaseStatus = null;
|
||||
tags.MusicBrainzReleaseType = null;
|
||||
tags.MusicBrainzReleaseId = null;
|
||||
tags.MusicBrainzArtistId = null;
|
||||
tags.MusicBrainzReleaseArtistId = null;
|
||||
tags.MusicBrainzReleaseGroupId = null;
|
||||
tags.MusicBrainzTrackId = null;
|
||||
tags.MusicBrainzAlbumComment = null;
|
||||
tags.MusicBrainzReleaseTrackId = null;
|
||||
|
||||
tags.Write(path);
|
||||
}
|
||||
|
||||
public void WriteTags(TrackFile trackfile, bool newDownload, bool force = false)
|
||||
{
|
||||
if (!force)
|
||||
{
|
||||
if (_configService.WriteAudioTags == WriteAudioTagsType.No ||
|
||||
(_configService.WriteAudioTags == WriteAudioTagsType.NewFiles && !newDownload))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (trackfile.Tracks.Value.Count > 1)
|
||||
{
|
||||
_logger.Debug($"File {trackfile} is linked to multiple tracks. Not writing tags.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newTags = GetTrackMetadata(trackfile);
|
||||
var path = Path.Combine(trackfile.Artist.Value.Path, trackfile.RelativePath);
|
||||
|
||||
var diff = ReadAudioTag(path).Diff(newTags);
|
||||
|
||||
if (_configService.ScrubAudioTags)
|
||||
{
|
||||
_logger.Debug($"Scrubbing tags for {trackfile}");
|
||||
RemoveAllTags(path);
|
||||
}
|
||||
|
||||
_logger.Debug($"Writing tags for {trackfile}");
|
||||
newTags.Write(path);
|
||||
|
||||
UpdateTrackfileSize(trackfile, path);
|
||||
|
||||
_eventAggregator.PublishEvent(new TrackFileRetaggedEvent(trackfile.Artist.Value, trackfile, diff, _configService.ScrubAudioTags));
|
||||
}
|
||||
|
||||
public void SyncTags(List<Track> tracks)
|
||||
{
|
||||
if (_configService.WriteAudioTags != WriteAudioTagsType.Sync)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// get the tracks to update
|
||||
var trackFiles = _mediaFileService.Get(tracks.Where(x => x.TrackFileId > 0).Select(x => x.TrackFileId));
|
||||
|
||||
_logger.Debug($"Syncing audio tags for {trackFiles.Count} files");
|
||||
|
||||
foreach (var file in trackFiles)
|
||||
{
|
||||
// populate tracks (which should also have release/album/artist set) because
|
||||
// not all of the updates will have been committed to the database yet
|
||||
file.Tracks = tracks.Where(x => x.TrackFileId == file.Id).ToList();
|
||||
WriteTags(file, false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveMusicBrainzTags(IEnumerable<Album> albums)
|
||||
{
|
||||
if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var album in albums)
|
||||
{
|
||||
var files = _mediaFileService.GetFilesByAlbum(album.Id);
|
||||
foreach (var file in files)
|
||||
{
|
||||
RemoveMusicBrainzTags(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveMusicBrainzTags(IEnumerable<AlbumRelease> releases)
|
||||
{
|
||||
if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var release in releases)
|
||||
{
|
||||
var files = _mediaFileService.GetFilesByRelease(release.Id);
|
||||
foreach (var file in files)
|
||||
{
|
||||
RemoveMusicBrainzTags(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveMusicBrainzTags(IEnumerable<Track> tracks)
|
||||
{
|
||||
if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var files = _mediaFileService.Get(tracks.Where(x => x.TrackFileId > 0).Select(x => x.TrackFileId));
|
||||
foreach (var file in files)
|
||||
{
|
||||
RemoveMusicBrainzTags(file);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveMusicBrainzTags(TrackFile trackfile)
|
||||
{
|
||||
if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = Path.Combine(trackfile.Artist.Value.Path, trackfile.RelativePath);
|
||||
_logger.Debug($"Removing MusicBrainz tags for {path}");
|
||||
|
||||
RemoveMusicBrainzTags(path);
|
||||
|
||||
UpdateTrackfileSize(trackfile, path);
|
||||
}
|
||||
|
||||
public List<RetagTrackFilePreview> GetRetagPreviewsByArtist(int artistId)
|
||||
{
|
||||
var files = _mediaFileService.GetFilesByArtist(artistId);
|
||||
|
||||
return GetPreviews(files).ToList();
|
||||
}
|
||||
|
||||
public List<RetagTrackFilePreview> GetRetagPreviewsByAlbum(int albumId)
|
||||
{
|
||||
var files = _mediaFileService.GetFilesByAlbum(albumId);
|
||||
|
||||
return GetPreviews(files).ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<RetagTrackFilePreview> GetPreviews(List<TrackFile> files)
|
||||
{
|
||||
foreach (var f in files.OrderBy(x => x.Album.Value.Title)
|
||||
.ThenBy(x => x.Tracks.Value.First().MediumNumber)
|
||||
.ThenBy(x => x.Tracks.Value.First().AbsoluteTrackNumber))
|
||||
{
|
||||
var file = f;
|
||||
|
||||
if (!f.Tracks.Value.Any())
|
||||
{
|
||||
_logger.Warn($"File {f} is not linked to any tracks");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (f.Tracks.Value.Count > 1)
|
||||
{
|
||||
_logger.Debug($"File {f} is linked to multiple tracks. Not writing tags.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var oldTags = ReadAudioTag(Path.Combine(f.Artist.Value.Path, f.RelativePath));
|
||||
var newTags = GetTrackMetadata(f);
|
||||
var diff = oldTags.Diff(newTags);
|
||||
|
||||
if (diff.Any())
|
||||
{
|
||||
yield return new RetagTrackFilePreview {
|
||||
ArtistId = file.Artist.Value.Id,
|
||||
AlbumId = file.Album.Value.Id,
|
||||
TrackNumbers = file.Tracks.Value.Select(e => e.AbsoluteTrackNumber).ToList(),
|
||||
TrackFileId = file.Id,
|
||||
RelativePath = file.RelativePath,
|
||||
Changes = diff
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Execute(RetagFilesCommand message)
|
||||
{
|
||||
var artist = _artistService.GetArtist(message.ArtistId);
|
||||
var trackFiles = _mediaFileService.Get(message.Files);
|
||||
|
||||
_logger.ProgressInfo("Re-tagging {0} files for {1}", trackFiles.Count, artist.Name);
|
||||
foreach (var file in trackFiles)
|
||||
{
|
||||
WriteTags(file, false, force: true);
|
||||
}
|
||||
_logger.ProgressInfo("Selected track files re-tagged for {0}", artist.Name);
|
||||
}
|
||||
|
||||
public void Execute(RetagArtistCommand message)
|
||||
{
|
||||
_logger.Debug("Re-tagging all files for selected artists");
|
||||
var artistToRename = _artistService.GetArtists(message.ArtistIds);
|
||||
|
||||
foreach (var artist in artistToRename)
|
||||
{
|
||||
var trackFiles = _mediaFileService.GetFilesByArtist(artist.Id);
|
||||
_logger.ProgressInfo("Re-tagging all files in artist: {0}", artist.Name);
|
||||
foreach (var file in trackFiles)
|
||||
{
|
||||
WriteTags(file, false, force: true);
|
||||
}
|
||||
_logger.ProgressInfo("All track files re-tagged for {0}", artist.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
|
||||
namespace NzbDrone.Core.MediaFiles.Commands
|
||||
{
|
||||
public class RetagArtistCommand : Command
|
||||
{
|
||||
public List<int> ArtistIds { get; set; }
|
||||
|
||||
public override bool SendUpdatesToClient => true;
|
||||
public override bool RequiresDiskAccess => true;
|
||||
|
||||
public RetagArtistCommand()
|
||||
{
|
||||
ArtistIds = new List<int>();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
|
||||
namespace NzbDrone.Core.MediaFiles.Commands
|
||||
{
|
||||
public class RetagFilesCommand : Command
|
||||
{
|
||||
public int ArtistId { get; set; }
|
||||
public List<int> Files { get; set; }
|
||||
|
||||
public override bool SendUpdatesToClient => true;
|
||||
public override bool RequiresDiskAccess => true;
|
||||
|
||||
public RetagFilesCommand()
|
||||
{
|
||||
}
|
||||
|
||||
public RetagFilesCommand(int artistId, List<int> files)
|
||||
{
|
||||
ArtistId = artistId;
|
||||
Files = files;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Core.Music;
|
||||
|
||||
namespace NzbDrone.Core.MediaFiles.Events
|
||||
{
|
||||
public class TrackFileRetaggedEvent : IEvent
|
||||
{
|
||||
public Artist Artist { get; private set; }
|
||||
public TrackFile TrackFile { get; private set; }
|
||||
public Dictionary<string, Tuple<string, string>> Diff { get; private set; }
|
||||
public bool Scrubbed { get; private set; }
|
||||
|
||||
public TrackFileRetaggedEvent(Artist artist,
|
||||
TrackFile trackFile,
|
||||
Dictionary<string, Tuple<string, string>> diff,
|
||||
bool scrubbed)
|
||||
{
|
||||
Artist = artist;
|
||||
TrackFile = trackFile;
|
||||
Diff = diff;
|
||||
Scrubbed = scrubbed;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue