mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-30 03:38:26 -07:00
New: Write metadata to tags, with UI for previewing changes (#633)
This commit is contained in:
parent
6548f4b1b7
commit
072f772dc8
82 changed files with 2938 additions and 358 deletions
34
frontend/src/Retag/RetagPreviewModal.js
Normal file
34
frontend/src/Retag/RetagPreviewModal.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import RetagPreviewModalContentConnector from './RetagPreviewModalContentConnector';
|
||||
|
||||
function RetagPreviewModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
{
|
||||
isOpen &&
|
||||
<RetagPreviewModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
RetagPreviewModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RetagPreviewModal;
|
39
frontend/src/Retag/RetagPreviewModalConnector.js
Normal file
39
frontend/src/Retag/RetagPreviewModalConnector.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearRetagPreview } from 'Store/Actions/retagPreviewActions';
|
||||
import RetagPreviewModal from './RetagPreviewModal';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
clearRetagPreview
|
||||
};
|
||||
|
||||
class RetagPreviewModalConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.clearRetagPreview();
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<RetagPreviewModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewModalConnector.propTypes = {
|
||||
clearRetagPreview: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(RetagPreviewModalConnector);
|
24
frontend/src/Retag/RetagPreviewModalContent.css
Normal file
24
frontend/src/Retag/RetagPreviewModalContent.css
Normal file
|
@ -0,0 +1,24 @@
|
|||
.path {
|
||||
margin-left: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.trackFormat {
|
||||
margin-left: 5px;
|
||||
font-family: $monoSpaceFontFamily;
|
||||
}
|
||||
|
||||
.previews {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.selectAllInputContainer {
|
||||
margin-right: auto;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.selectAllInput {
|
||||
composes: input from '~Components/Form/CheckInput.css';
|
||||
|
||||
margin: 0;
|
||||
}
|
186
frontend/src/Retag/RetagPreviewModalContent.js
Normal file
186
frontend/src/Retag/RetagPreviewModalContent.js
Normal file
|
@ -0,0 +1,186 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Alert from 'Components/Alert';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import RetagPreviewRow from './RetagPreviewRow';
|
||||
import styles from './RetagPreviewModalContent.css';
|
||||
|
||||
function getValue(allSelected, allUnselected) {
|
||||
if (allSelected) {
|
||||
return true;
|
||||
} else if (allUnselected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class RetagPreviewModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
lastToggled: null,
|
||||
selectedState: {}
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
getSelectedIds = () => {
|
||||
return getSelectedIds(this.state.selectedState);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSelectAllChange = ({ value }) => {
|
||||
this.setState(selectAll(this.state.selectedState, value));
|
||||
}
|
||||
|
||||
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||
this.setState((state) => {
|
||||
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||
});
|
||||
}
|
||||
|
||||
onRetagPress = () => {
|
||||
this.props.onRetagPress(this.getSelectedIds());
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
path,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
allSelected,
|
||||
allUnselected,
|
||||
selectedState
|
||||
} = this.state;
|
||||
|
||||
const selectAllValue = getValue(allSelected, allUnselected);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Write Metadata Tags
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error &&
|
||||
<div>Error loading previews</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && ((isPopulated && !items.length)) &&
|
||||
<div>Success! My work is done, no files to retag.</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && isPopulated && !!items.length &&
|
||||
<div>
|
||||
<Alert>
|
||||
<div>
|
||||
All paths are relative to:
|
||||
<span className={styles.path}>
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
MusicBrainz identifiers will also be added to the files; these are not shown below.
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className={styles.previews}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<RetagPreviewRow
|
||||
key={item.trackFileId}
|
||||
id={item.trackFileId}
|
||||
path={item.relativePath}
|
||||
changes={item.changes}
|
||||
isSelected={selectedState[item.trackFileId]}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
isPopulated && !!items.length &&
|
||||
<CheckInput
|
||||
className={styles.selectAllInput}
|
||||
containerClassName={styles.selectAllInputContainer}
|
||||
name="selectAll"
|
||||
value={selectAllValue}
|
||||
onChange={this.onSelectAllChange}
|
||||
/>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={this.onRetagPress}
|
||||
>
|
||||
Retag
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewModalContent.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
onRetagPress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RetagPreviewModalContent;
|
86
frontend/src/Retag/RetagPreviewModalContentConnector.js
Normal file
86
frontend/src/Retag/RetagPreviewModalContentConnector.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createArtistSelector from 'Store/Selectors/createArtistSelector';
|
||||
import { fetchRetagPreview } from 'Store/Actions/retagPreviewActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import RetagPreviewModalContent from './RetagPreviewModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.retagPreview,
|
||||
createArtistSelector(),
|
||||
(retagPreview, artist) => {
|
||||
const props = { ...retagPreview };
|
||||
props.isFetching = retagPreview.isFetching;
|
||||
props.isPopulated = retagPreview.isPopulated;
|
||||
props.error = retagPreview.error;
|
||||
props.path = artist.path;
|
||||
|
||||
return props;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchRetagPreview,
|
||||
executeCommand
|
||||
};
|
||||
|
||||
class RetagPreviewModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
artistId,
|
||||
albumId
|
||||
} = this.props;
|
||||
|
||||
this.props.fetchRetagPreview({
|
||||
artistId,
|
||||
albumId
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRetagPress = (files) => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.RETAG_FILES,
|
||||
artistId: this.props.artistId,
|
||||
files
|
||||
});
|
||||
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<RetagPreviewModalContent
|
||||
{...this.props}
|
||||
onRetagPress={this.onRetagPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewModalContentConnector.propTypes = {
|
||||
artistId: PropTypes.number.isRequired,
|
||||
albumId: PropTypes.number,
|
||||
retagTracks: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
fetchRetagPreview: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(RetagPreviewModalContentConnector);
|
26
frontend/src/Retag/RetagPreviewRow.css
Normal file
26
frontend/src/Retag/RetagPreviewRow.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
.row {
|
||||
display: flex;
|
||||
margin-bottom: 5px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectedContainer {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.path {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
}
|
101
frontend/src/Retag/RetagPreviewRow.js
Normal file
101
frontend/src/Retag/RetagPreviewRow.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import styles from './RetagPreviewRow.css';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
|
||||
function formatMissing(value) {
|
||||
if (value === undefined || value === 0 || value === '0') {
|
||||
return (<Icon name={icons.BAN} size={12} />);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatChange(oldValue, newValue) {
|
||||
return (
|
||||
<div> {formatMissing(oldValue)} <Icon name={icons.ARROW_RIGHT_NO_CIRCLE} size={12} /> {formatMissing(newValue)} </div>
|
||||
);
|
||||
}
|
||||
|
||||
class RetagPreviewRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
id,
|
||||
onSelectedChange
|
||||
} = this.props;
|
||||
|
||||
onSelectedChange({ id, value: true });
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSelectedChange = ({ value, shiftKey }) => {
|
||||
const {
|
||||
id,
|
||||
onSelectedChange
|
||||
} = this.props;
|
||||
|
||||
onSelectedChange({ id, value, shiftKey });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
path,
|
||||
changes,
|
||||
isSelected
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<CheckInput
|
||||
containerClassName={styles.selectedContainer}
|
||||
name={id.toString()}
|
||||
value={isSelected}
|
||||
onChange={this.onSelectedChange}
|
||||
/>
|
||||
|
||||
<div className={styles.column}>
|
||||
<span className={styles.path}>
|
||||
{path}
|
||||
</span>
|
||||
|
||||
<DescriptionList>
|
||||
{
|
||||
changes.map(({ field, oldValue, newValue }) => {
|
||||
return (
|
||||
<DescriptionListItem
|
||||
key={field}
|
||||
title={field}
|
||||
data={formatChange(oldValue, newValue)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RetagPreviewRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
changes: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
onSelectedChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RetagPreviewRow;
|
Loading…
Add table
Add a link
Reference in a new issue