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

This commit is contained in:
ta264 2019-03-15 12:10:45 +00:00 committed by GitHub
parent 6548f4b1b7
commit 072f772dc8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 2938 additions and 358 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

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