New: Manually Edit/Override Album Release (#181)
* New: Manually Edit/Override Album Release * !fixup for comments, loading all albums instead of only artist albums * fixup! UI Cleanup lint issues * fixup! Remove AddAlbum service for now, fix refresh override selected release * fixup! Last one... to fix updating albums with custom release set Closes #109 Closes #129 Closes #128pull/190/head
parent
74f433d4f0
commit
26ef43f302
@ -0,0 +1,168 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import LazyLoad from 'react-lazyload';
|
||||||
|
|
||||||
|
const coverPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII=';
|
||||||
|
|
||||||
|
function findCover(images) {
|
||||||
|
return _.find(images, { coverType: 'cover' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoverUrl(cover, size) {
|
||||||
|
if (cover) {
|
||||||
|
if (cover.url.contains('lastWrite=') || (/^https?:/).test(cover.url)) {
|
||||||
|
// Remove protocol
|
||||||
|
let url = cover.url.replace(/^https?:/, '');
|
||||||
|
url = url.replace('cover.jpg', `cover-${size}.jpg`);
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumCover extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
const pixelRatio = Math.floor(window.devicePixelRatio);
|
||||||
|
|
||||||
|
const {
|
||||||
|
images,
|
||||||
|
size
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const cover = findCover(images);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
pixelRatio,
|
||||||
|
cover,
|
||||||
|
coverUrl: getCoverUrl(cover, pixelRatio * size),
|
||||||
|
isLoaded: false,
|
||||||
|
hasError: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const {
|
||||||
|
images,
|
||||||
|
size
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
cover,
|
||||||
|
pixelRatio
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const nextcover = findCover(images);
|
||||||
|
|
||||||
|
if (nextcover && (!cover || nextcover.url !== cover.url)) {
|
||||||
|
this.setState({
|
||||||
|
cover: nextcover,
|
||||||
|
coverUrl: getCoverUrl(nextcover, pixelRatio * size),
|
||||||
|
hasError: false
|
||||||
|
// Don't reset isLoaded, as we want to immediately try to
|
||||||
|
// show the new image, whether an image was shown previously
|
||||||
|
// or the placeholder was shown.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onError = () => {
|
||||||
|
this.setState({ hasError: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad = () => {
|
||||||
|
this.setState({
|
||||||
|
isLoaded: true,
|
||||||
|
hasError: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
lazy,
|
||||||
|
overflow
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
coverUrl,
|
||||||
|
hasError,
|
||||||
|
isLoaded
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
if (hasError || !coverUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
src={coverPlaceholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lazy) {
|
||||||
|
return (
|
||||||
|
<LazyLoad
|
||||||
|
height={size}
|
||||||
|
offset={100}
|
||||||
|
overflow={overflow}
|
||||||
|
placeholder={
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
src={coverPlaceholder}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
src={coverUrl}
|
||||||
|
onError={this.onError}
|
||||||
|
/>
|
||||||
|
</LazyLoad>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
src={isLoaded ? coverUrl : coverPlaceholder}
|
||||||
|
onError={this.onError}
|
||||||
|
onLoad={this.onLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumCover.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
|
style: PropTypes.object,
|
||||||
|
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
size: PropTypes.number.isRequired,
|
||||||
|
lazy: PropTypes.bool.isRequired,
|
||||||
|
overflow: PropTypes.bool.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
AlbumCover.defaultProps = {
|
||||||
|
size: 250,
|
||||||
|
lazy: true,
|
||||||
|
overflow: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlbumCover;
|
@ -0,0 +1,20 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
|
||||||
|
function AlbumTitleDetailLink({ foreignAlbumId, title }) {
|
||||||
|
const link = `/album/${foreignAlbumId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={link}>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumTitleDetailLink.propTypes = {
|
||||||
|
foreignAlbumId: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlbumTitleDetailLink;
|
@ -0,0 +1,137 @@
|
|||||||
|
.innerContentBody {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 310px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdropOverlay {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: $black;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
padding: 30px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 35px;
|
||||||
|
width: 250px;
|
||||||
|
height: 97px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 35px;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 50px;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alternateTitlesIconContainer {
|
||||||
|
margin-left: 20px;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artistNavigationButtons {
|
||||||
|
white-space: no-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artistNavigationButton {
|
||||||
|
composes: button from 'Components/Link/IconButton.css';
|
||||||
|
|
||||||
|
margin-left: 5px;
|
||||||
|
color: #e1e2e3;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime {
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsLabel {
|
||||||
|
composes: label from 'Components/Label.css';
|
||||||
|
|
||||||
|
margin: 5px 10px 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sizeOnDisk,
|
||||||
|
.qualityProfileName,
|
||||||
|
.tags {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentContainer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: $breakpointSmall) {
|
||||||
|
.contentContainer {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: $breakpointLarge) {
|
||||||
|
.cover {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,422 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import moment from 'moment';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import formatBytes from 'Utilities/Number/formatBytes';
|
||||||
|
import selectAll from 'Utilities/Table/selectAll';
|
||||||
|
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||||
|
import { align, icons, sizes } from 'Helpers/Props';
|
||||||
|
import HeartRating from 'Components/HeartRating';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import AlbumCover from 'Album/AlbumCover';
|
||||||
|
import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import PageContent from 'Components/Page/PageContent';
|
||||||
|
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
|
||||||
|
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||||
|
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||||
|
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||||
|
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
|
import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector';
|
||||||
|
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
|
||||||
|
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
|
||||||
|
|
||||||
|
import styles from './AlbumDetails.css';
|
||||||
|
|
||||||
|
function getFanartUrl(images) {
|
||||||
|
const fanartImage = _.find(images, { coverType: 'fanart' });
|
||||||
|
if (fanartImage) {
|
||||||
|
// Remove protocol
|
||||||
|
return fanartImage.url.replace(/^https?:/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpandedState(newState) {
|
||||||
|
return {
|
||||||
|
allExpanded: newState.allSelected,
|
||||||
|
allCollapsed: newState.allUnselected,
|
||||||
|
expandedState: newState.selectedState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumDetails extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isArtistHistoryModalOpen: false,
|
||||||
|
isManageTracksOpen: false,
|
||||||
|
isEditAlbumModalOpen: false,
|
||||||
|
allExpanded: false,
|
||||||
|
allCollapsed: false,
|
||||||
|
expandedState: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onEditAlbumPress = () => {
|
||||||
|
this.setState({ isEditAlbumModalOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditAlbumModalClose = () => {
|
||||||
|
this.setState({ isEditAlbumModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
onManageTracksPress = () => {
|
||||||
|
this.setState({ isManageTracksOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onManageTracksModalClose = () => {
|
||||||
|
this.setState({ isManageTracksOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
onArtistHistoryPress = () => {
|
||||||
|
this.setState({ isArtistHistoryModalOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onArtistHistoryModalClose = () => {
|
||||||
|
this.setState({ isArtistHistoryModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
onExpandAllPress = () => {
|
||||||
|
const {
|
||||||
|
allExpanded,
|
||||||
|
expandedState
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
|
||||||
|
}
|
||||||
|
|
||||||
|
onExpandPress = (albumId, isExpanded) => {
|
||||||
|
this.setState((state) => {
|
||||||
|
const convertedState = {
|
||||||
|
allSelected: state.allExpanded,
|
||||||
|
allUnselected: state.allCollapsed,
|
||||||
|
selectedState: state.expandedState
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = toggleSelected(convertedState, [], albumId, isExpanded, false);
|
||||||
|
|
||||||
|
return getExpandedState(newState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
albumType,
|
||||||
|
statistics,
|
||||||
|
monitored,
|
||||||
|
releaseDate,
|
||||||
|
images,
|
||||||
|
media,
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
albumsError,
|
||||||
|
trackFilesError,
|
||||||
|
shortDateFormat,
|
||||||
|
artist,
|
||||||
|
previousAlbum,
|
||||||
|
nextAlbum,
|
||||||
|
isSearching,
|
||||||
|
onSearchPress
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isArtistHistoryModalOpen,
|
||||||
|
isEditAlbumModalOpen,
|
||||||
|
isManageTracksOpen,
|
||||||
|
allExpanded,
|
||||||
|
allCollapsed,
|
||||||
|
expandedState
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
let expandIcon = icons.EXPAND_INDETERMINATE;
|
||||||
|
|
||||||
|
if (allExpanded) {
|
||||||
|
expandIcon = icons.COLLAPSE;
|
||||||
|
} else if (allCollapsed) {
|
||||||
|
expandIcon = icons.EXPAND;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent title={title}>
|
||||||
|
<PageToolbar>
|
||||||
|
<PageToolbarSection>
|
||||||
|
<PageToolbarButton
|
||||||
|
label="Search Album"
|
||||||
|
iconName={icons.SEARCH}
|
||||||
|
isSpinning={isSearching}
|
||||||
|
onPress={onSearchPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label="Manage Tracks"
|
||||||
|
iconName={icons.TRACK_FILE}
|
||||||
|
onPress={this.onManageTracksPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label="History"
|
||||||
|
iconName={icons.HISTORY}
|
||||||
|
onPress={this.onArtistHistoryPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label="Edit"
|
||||||
|
iconName={icons.EDIT}
|
||||||
|
onPress={this.onEditAlbumPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</PageToolbarSection>
|
||||||
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
|
<PageToolbarButton
|
||||||
|
label={allExpanded ? 'Collapse All' : 'Expand All'}
|
||||||
|
iconName={expandIcon}
|
||||||
|
onPress={this.onExpandAllPress}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
</PageToolbar>
|
||||||
|
|
||||||
|
<PageContentBodyConnector innerClassName={styles.innerContentBody}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${getFanartUrl(images)})`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.backdropOverlay} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<AlbumCover
|
||||||
|
className={styles.cover}
|
||||||
|
images={images}
|
||||||
|
size={500}
|
||||||
|
lazy={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.artistNavigationButtons}>
|
||||||
|
<IconButton
|
||||||
|
className={styles.artistNavigationButton}
|
||||||
|
name={icons.ARROW_LEFT}
|
||||||
|
size={30}
|
||||||
|
title={`Go to ${previousAlbum.title}`}
|
||||||
|
to={`/album/${previousAlbum.foreignAlbumId}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles.artistNavigationButton}
|
||||||
|
name={icons.ARROW_UP}
|
||||||
|
size={30}
|
||||||
|
title={`Go to ${artist.artistName}`}
|
||||||
|
to={`/artist/${artist.foreignArtistId}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles.artistNavigationButton}
|
||||||
|
name={icons.ARROW_RIGHT}
|
||||||
|
size={30}
|
||||||
|
title={`Go to ${nextAlbum.title}`}
|
||||||
|
to={`/album/${nextAlbum.foreignAlbumId}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className={styles.details}>
|
||||||
|
<div>
|
||||||
|
<HeartRating
|
||||||
|
rating={ratings.value}
|
||||||
|
iconSize={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className={styles.detailsLabels}>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={icons.CALENDAR}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.sizeOnDisk}>
|
||||||
|
{
|
||||||
|
moment(releaseDate).format(shortDateFormat)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={icons.DRIVE}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.sizeOnDisk}>
|
||||||
|
{
|
||||||
|
formatBytes(statistics.sizeOnDisk)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={monitored ? icons.MONITORED : icons.UNMONITORED}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.qualityProfileName}>
|
||||||
|
{monitored ? 'Monitored' : 'Unmonitored'}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{
|
||||||
|
!!albumType &&
|
||||||
|
<Label
|
||||||
|
className={styles.detailsLabel}
|
||||||
|
title="Type"
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={icons.INFO}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.qualityProfileName}>
|
||||||
|
{albumType}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.contentContainer}>
|
||||||
|
{
|
||||||
|
!isPopulated && !albumsError && !trackFilesError &&
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && albumsError &&
|
||||||
|
<div>Loading albums failed</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && trackFilesError &&
|
||||||
|
<div>Loading track files failed</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isPopulated && !!media.length &&
|
||||||
|
<div>
|
||||||
|
|
||||||
|
{
|
||||||
|
media.slice(0).map((medium) => {
|
||||||
|
return (
|
||||||
|
<AlbumDetailsMediumConnector
|
||||||
|
key={medium.mediumNumber}
|
||||||
|
albumId={id}
|
||||||
|
{...medium}
|
||||||
|
isExpanded={expandedState[medium.mediumNumber]}
|
||||||
|
onExpandPress={this.onExpandPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrackFileEditorModal
|
||||||
|
isOpen={isManageTracksOpen}
|
||||||
|
artistId={artist.id}
|
||||||
|
albumId={id}
|
||||||
|
onModalClose={this.onManageTracksModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArtistHistoryModal
|
||||||
|
isOpen={isArtistHistoryModalOpen}
|
||||||
|
artistId={artist.id}
|
||||||
|
albumId={id}
|
||||||
|
onModalClose={this.onArtistHistoryModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditAlbumModalConnector
|
||||||
|
isOpen={isEditAlbumModalOpen}
|
||||||
|
albumId={id}
|
||||||
|
artistId={artist.id}
|
||||||
|
onModalClose={this.onEditAlbumModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</PageContentBodyConnector>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumDetails.propTypes = {
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
foreignAlbumId: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
albumType: PropTypes.string.isRequired,
|
||||||
|
statistics: PropTypes.object.isRequired,
|
||||||
|
releaseDate: PropTypes.string.isRequired,
|
||||||
|
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
media: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
monitored: PropTypes.bool.isRequired,
|
||||||
|
shortDateFormat: PropTypes.string.isRequired,
|
||||||
|
isSearching: PropTypes.bool,
|
||||||
|
isFetching: PropTypes.bool,
|
||||||
|
isPopulated: PropTypes.bool,
|
||||||
|
albumsError: PropTypes.object,
|
||||||
|
tracksError: PropTypes.object,
|
||||||
|
trackFilesError: PropTypes.object,
|
||||||
|
artist: PropTypes.object,
|
||||||
|
previousAlbum: PropTypes.object,
|
||||||
|
nextAlbum: PropTypes.object,
|
||||||
|
onRefreshPress: PropTypes.func,
|
||||||
|
onSearchPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
AlbumDetails.defaultProps = {
|
||||||
|
isSaving: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlbumDetails;
|
@ -0,0 +1,146 @@
|
|||||||
|
/* eslint max-params: 0 */
|
||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { findCommand } from 'Utilities/Command';
|
||||||
|
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||||
|
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||||
|
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
|
||||||
|
import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
|
||||||
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
|
import * as commandNames from 'Commands/commandNames';
|
||||||
|
import AlbumDetails from './AlbumDetails';
|
||||||
|
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
|
||||||
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { foreignAlbumId }) => foreignAlbumId,
|
||||||
|
(state) => state.tracks,
|
||||||
|
(state) => state.trackFiles,
|
||||||
|
(state) => state.albums,
|
||||||
|
createAllArtistSelector(),
|
||||||
|
createCommandsSelector(),
|
||||||
|
createUISettingsSelector(),
|
||||||
|
(foreignAlbumId, tracks, trackFiles, albums, artists, commands, uiSettings) => {
|
||||||
|
const sortedAlbums = _.orderBy(albums.items, 'releaseDate');
|
||||||
|
const albumIndex = _.findIndex(sortedAlbums, { foreignAlbumId });
|
||||||
|
const album = sortedAlbums[albumIndex];
|
||||||
|
const artist = _.find(artists, { id: album.artistId });
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousAlbum = sortedAlbums[albumIndex - 1] || _.last(sortedAlbums);
|
||||||
|
const nextAlbum = sortedAlbums[albumIndex + 1] || _.first(sortedAlbums);
|
||||||
|
const isSearching = !!findCommand(commands, { name: commandNames.ALBUM_SEARCH });
|
||||||
|
|
||||||
|
const isFetching = tracks.isFetching || trackFiles.isFetching;
|
||||||
|
const isPopulated = tracks.isPopulated && trackFiles.isPopulated;
|
||||||
|
const tracksError = tracks.error;
|
||||||
|
const trackFilesError = trackFiles.error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...album,
|
||||||
|
shortDateFormat: uiSettings.shortDateFormat,
|
||||||
|
artist,
|
||||||
|
isSearching,
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
tracksError,
|
||||||
|
trackFilesError,
|
||||||
|
previousAlbum,
|
||||||
|
nextAlbum
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
executeCommand,
|
||||||
|
fetchTracks,
|
||||||
|
clearTracks,
|
||||||
|
fetchTrackFiles,
|
||||||
|
clearTrackFiles
|
||||||
|
};
|
||||||
|
|
||||||
|
class AlbumDetailsConnector extends Component {
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
registerPagePopulator(this.populate);
|
||||||
|
this.populate();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const {
|
||||||
|
id
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
// If the id has changed we need to clear the tracks/track
|
||||||
|
// files and fetch from the server.
|
||||||
|
|
||||||
|
if (prevProps.id !== id) {
|
||||||
|
this.unpopulate();
|
||||||
|
this.populate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
unregisterPagePopulator(this.populate);
|
||||||
|
this.unpopulate();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
populate = () => {
|
||||||
|
const albumId = this.props.id;
|
||||||
|
|
||||||
|
this.props.fetchTracks({ albumId });
|
||||||
|
this.props.fetchTrackFiles({ albumId });
|
||||||
|
}
|
||||||
|
|
||||||
|
unpopulate = () => {
|
||||||
|
this.props.clearTracks();
|
||||||
|
this.props.clearTrackFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onSearchPress = () => {
|
||||||
|
this.props.executeCommand({
|
||||||
|
name: commandNames.ALBUM_SEARCH,
|
||||||
|
albumIds: [this.props.id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<AlbumDetails
|
||||||
|
{...this.props}
|
||||||
|
onSearchPress={this.onSearchPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumDetailsConnector.propTypes = {
|
||||||
|
id: PropTypes.number,
|
||||||
|
isAlbumFetching: PropTypes.bool,
|
||||||
|
isAlbumPopulated: PropTypes.bool,
|
||||||
|
foreignAlbumId: PropTypes.string.isRequired,
|
||||||
|
fetchTracks: PropTypes.func.isRequired,
|
||||||
|
clearTracks: PropTypes.func.isRequired,
|
||||||
|
fetchTrackFiles: PropTypes.func.isRequired,
|
||||||
|
clearTrackFiles: PropTypes.func.isRequired,
|
||||||
|
executeCommand: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsConnector);
|
@ -0,0 +1,114 @@
|
|||||||
|
.medium {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid $borderColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: $white;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediumNumber {
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediumFormat {
|
||||||
|
color: #8895aa;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButton {
|
||||||
|
composes: link from 'Components/Link/Link.css';
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
margin: 0 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 1 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.actions {
|
||||||
|
padding: 15px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsMenu {
|
||||||
|
composes: menu from 'Components/Menu/Menu.css';
|
||||||
|
|
||||||
|
flex: 0 0 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsMenuContent {
|
||||||
|
composes: menuContent from 'Components/Menu/MenuContent.css';
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionMenuIcon {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
composes: button from 'Components/Link/IconButton.css';
|
||||||
|
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks {
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid $borderColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseButtonContainer {
|
||||||
|
padding: 10px 15px;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid $borderColor;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButtonIcon {
|
||||||
|
composes: actionButton;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noTracks {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: $breakpointSmall) {
|
||||||
|
.medium {
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButtonIcon {
|
||||||
|
position: static;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,211 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import isAfter from 'Utilities/Date/isAfter';
|
||||||
|
import isBefore from 'Utilities/Date/isBefore';
|
||||||
|
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import Table from 'Components/Table/Table';
|
||||||
|
import TableBody from 'Components/Table/TableBody';
|
||||||
|
import TrackRowConnector from './TrackRowConnector';
|
||||||
|
import styles from './AlbumDetailsMedium.css';
|
||||||
|
|
||||||
|
function getMediumStatistics(tracks) {
|
||||||
|
let trackCount = 0;
|
||||||
|
let trackFileCount = 0;
|
||||||
|
let totalTrackCount = 0;
|
||||||
|
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
if (track.trackFileId) {
|
||||||
|
trackCount++;
|
||||||
|
trackFileCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTrackCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackCount,
|
||||||
|
trackFileCount,
|
||||||
|
totalTrackCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackCountKind(trackFileCount, trackCount) {
|
||||||
|
if (trackFileCount === trackCount && trackCount > 0) {
|
||||||
|
return kinds.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return kinds.DANGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumDetailsMedium extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._expandByDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.albumId !== this.props.albumId) {
|
||||||
|
this._expandByDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
_expandByDefault() {
|
||||||
|
const {
|
||||||
|
mediumNumber,
|
||||||
|
onExpandPress,
|
||||||
|
items
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const expand = _.some(items, (item) => {
|
||||||
|
return isAfter(item.airDateUtc) ||
|
||||||
|
isAfter(item.airDateUtc, { days: -30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
onExpandPress(mediumNumber, expand && mediumNumber > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onExpandPress = () => {
|
||||||
|
const {
|
||||||
|
mediumNumber,
|
||||||
|
isExpanded
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
this.props.onExpandPress(mediumNumber, !isExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
mediumNumber,
|
||||||
|
mediumFormat,
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
onTableOptionChange,
|
||||||
|
isExpanded,
|
||||||
|
isSmallScreen
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
trackCount,
|
||||||
|
trackFileCount,
|
||||||
|
totalTrackCount
|
||||||
|
} = getMediumStatistics(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.medium}
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<span className={styles.mediumNumber}>
|
||||||
|
{mediumFormat} {mediumNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Label
|
||||||
|
title={`${totalTrackCount} tracks total. ${trackFileCount} tracks with files.`}
|
||||||
|
kind={getTrackCountKind(trackFileCount, trackCount)}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
<span>{totalTrackCount} / {trackCount}</span>
|
||||||
|
}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className={styles.expandButton}
|
||||||
|
onPress={this.onExpandPress}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={styles.expandButtonIcon}
|
||||||
|
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
|
||||||
|
title={isExpanded ? 'Hide tracks' : 'Show tracks'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
!isSmallScreen &&
|
||||||
|
<span> </span>
|
||||||
|
}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
isExpanded &&
|
||||||
|
<div className={styles.tracks}>
|
||||||
|
{
|
||||||
|
items.length ?
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
onTableOptionChange={onTableOptionChange}
|
||||||
|
>
|
||||||
|
<TableBody>
|
||||||
|
{
|
||||||
|
items.map((item) => {
|
||||||
|
return (
|
||||||
|
<TrackRowConnector
|
||||||
|
key={item.id}
|
||||||
|
columns={columns}
|
||||||
|
{...item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</TableBody>
|
||||||
|
</Table> :
|
||||||
|
|
||||||
|
<div className={styles.noTracks}>
|
||||||
|
No tracks in this medium
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className={styles.collapseButtonContainer}>
|
||||||
|
<IconButton
|
||||||
|
name={icons.COLLAPSE}
|
||||||
|
size={20}
|
||||||
|
title="Hide tracks"
|
||||||
|
onPress={this.onExpandPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumDetailsMedium.propTypes = {
|
||||||
|
albumId: PropTypes.number.isRequired,
|
||||||
|
mediumNumber: PropTypes.number.isRequired,
|
||||||
|
mediumFormat: PropTypes.string.isRequired,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isSaving: PropTypes.bool,
|
||||||
|
isExpanded: PropTypes.bool,
|
||||||
|
isSmallScreen: PropTypes.bool.isRequired,
|
||||||
|
onTableOptionChange: PropTypes.func.isRequired,
|
||||||
|
onExpandPress: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlbumDetailsMedium;
|
@ -0,0 +1,64 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||||
|
import { setTracksTableOption } from 'Store/Actions/trackActions';
|
||||||
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
|
import AlbumDetailsMedium from './AlbumDetailsMedium';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { mediumNumber }) => mediumNumber,
|
||||||
|
(state) => state.tracks,
|
||||||
|
createDimensionsSelector(),
|
||||||
|
(mediumNumber, tracks, dimensions) => {
|
||||||
|
|
||||||
|
const tracksInMedium = _.filter(tracks.items, { mediumNumber });
|
||||||
|
const sortedTracks = _.orderBy(tracksInMedium, ['absoluteTrackNumber'], ['asc']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: sortedTracks,
|
||||||
|
columns: tracks.columns,
|
||||||
|
isSmallScreen: dimensions.isSmallScreen
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
setTracksTableOption,
|
||||||
|
executeCommand
|
||||||
|
};
|
||||||
|
|
||||||
|
class AlbumDetailsMediumConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onTableOptionChange = (payload) => {
|
||||||
|
this.props.setTracksTableOption(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<AlbumDetailsMedium
|
||||||
|
{...this.props}
|
||||||
|
onTableOptionChange={this.onTableOptionChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumDetailsMediumConnector.propTypes = {
|
||||||
|
albumId: PropTypes.number.isRequired,
|
||||||
|
mediumNumber: PropTypes.number.isRequired,
|
||||||
|
setTracksTableOption: PropTypes.func.isRequired,
|
||||||
|
executeCommand: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsMediumConnector);
|
@ -0,0 +1,110 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { push } from 'react-router-redux';
|
||||||
|
import NotFound from 'Components/NotFound';
|
||||||
|
import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import AlbumDetailsConnector from './AlbumDetailsConnector';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { match }) => match,
|
||||||
|
(state) => state.albums,
|
||||||
|
(match, albums) => {
|
||||||
|
const foreignAlbumId = match.params.foreignAlbumId;
|
||||||
|
const isAlbumsFetching = albums.isFetching;
|
||||||
|
const isAlbumsPopulated = albums.isPopulated;
|
||||||
|
|
||||||
|
return {
|
||||||
|
foreignAlbumId,
|
||||||
|
isAlbumsFetching,
|
||||||
|
isAlbumsPopulated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
push,
|
||||||
|
fetchAlbums,
|
||||||
|
clearAlbums
|
||||||
|
};
|
||||||
|
|
||||||
|
class AlbumDetailsPageConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.populate();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unpopulate();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
populate = () => {
|
||||||
|
const foreignAlbumId = this.props.foreignAlbumId;
|
||||||
|
this.props.fetchAlbums({ foreignAlbumId });
|
||||||
|
}
|
||||||
|
|
||||||
|
unpopulate = () => {
|
||||||
|
this.props.clearAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
foreignAlbumId,
|
||||||
|
isAlbumsFetching,
|
||||||
|
isAlbumsPopulated
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (!foreignAlbumId) {
|
||||||
|
return (
|
||||||
|
<NotFound
|
||||||
|
message="Sorry, that album cannot be found."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAlbumsFetching) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAlbumsFetching && !isAlbumsPopulated) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAlbumsFetching && isAlbumsPopulated) {
|
||||||
|
return (
|
||||||
|
<AlbumDetailsConnector
|
||||||
|
foreignAlbumId={foreignAlbumId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumDetailsPageConnector.propTypes = {
|
||||||
|
foreignAlbumId: PropTypes.string,
|
||||||
|
match: PropTypes.shape({ params: PropTypes.shape({ foreignAlbumId: PropTypes.string.isRequired }).isRequired }).isRequired,
|
||||||
|
push: PropTypes.func.isRequired,
|
||||||
|
fetchAlbums: PropTypes.func.isRequired,
|
||||||
|
clearAlbums: PropTypes.func.isRequired,
|
||||||
|
isAlbumsFetching: PropTypes.bool.isRequired,
|
||||||
|
isAlbumsPopulated: PropTypes.bool.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsPageConnector);
|
@ -0,0 +1,31 @@
|
|||||||
|
.title {
|
||||||
|
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitored {
|
||||||
|
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||||
|
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackNumber {
|
||||||
|
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||||
|
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio {
|
||||||
|
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||||
|
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language,
|
||||||
|
.duration,
|
||||||
|
.status {
|
||||||
|
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||||
|
|
||||||
|
width: 100px;
|
||||||
|
}
|
@ -0,0 +1,194 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import TableRow from 'Components/Table/TableRow';
|
||||||
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
|
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||||
|
import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
|
||||||
|
import TrackFileLanguageConnector from 'TrackFile/TrackFileLanguageConnector';
|
||||||
|
import MediaInfoConnector from 'TrackFile/MediaInfoConnector';
|
||||||
|
import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes';
|
||||||
|
|
||||||
|
import styles from './TrackRow.css';
|
||||||
|
|
||||||
|
class TrackRow extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isDetailsModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onDetailsModalClose = () => {
|
||||||
|
this.setState({ isDetailsModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
mediumNumber,
|
||||||
|
trackFileId,
|
||||||
|
absoluteTrackNumber,
|
||||||
|
title,
|
||||||
|
duration,
|
||||||
|
trackFilePath,
|
||||||
|
trackFileRelativePath,
|
||||||
|
columns
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
{
|
||||||
|
columns.map((column) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
isVisible
|
||||||
|
} = column;
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'medium') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.trackNumber}
|
||||||
|
>
|
||||||
|
{mediumNumber}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'absoluteTrackNumber') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.trackNumber}
|
||||||
|
>
|
||||||
|
{absoluteTrackNumber}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'title') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.title}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'path') {
|
||||||
|
return (
|
||||||
|
<TableRowCell key={name}>
|
||||||
|
{
|
||||||
|
trackFilePath
|
||||||
|
}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'relativePath') {
|
||||||
|
return (
|
||||||
|
<TableRowCell key={name}>
|
||||||
|
{
|
||||||
|
trackFileRelativePath
|
||||||
|
}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'duration') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.duration}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
formatTimeSpan(duration)
|
||||||
|
}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'language') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.language}
|
||||||
|
>
|
||||||
|
<TrackFileLanguageConnector
|
||||||
|
episodeFileId={trackFileId}
|
||||||
|
/>
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'audioInfo') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.audio}
|
||||||
|
>
|
||||||
|
<MediaInfoConnector
|
||||||
|
type={mediaInfoTypes.AUDIO}
|
||||||
|
trackFileId={trackFileId}
|
||||||
|
/>
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'status') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.status}
|
||||||
|
>
|
||||||
|
<EpisodeStatusConnector
|
||||||
|
albumId={id}
|
||||||
|
trackFileId={trackFileId}
|
||||||
|
/>
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackRow.propTypes = {
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
albumId: PropTypes.number.isRequired,
|
||||||
|
trackFileId: PropTypes.number,
|
||||||
|
monitored: PropTypes.bool.isRequired,
|
||||||
|
mediumNumber: PropTypes.number.isRequired,
|
||||||
|
trackNumber: PropTypes.string.isRequired,
|
||||||
|
absoluteTrackNumber: PropTypes.number,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
duration: PropTypes.number.isRequired,
|
||||||
|
isSaving: PropTypes.bool,
|
||||||
|
trackFilePath: PropTypes.string,
|
||||||
|
trackFileRelativePath: PropTypes.string,
|
||||||
|
mediaInfo: PropTypes.object,
|
||||||
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackRow;
|
@ -0,0 +1,22 @@
|
|||||||
|
/* eslint max-params: 0 */
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
|
||||||
|
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||||
|
import TrackRow from './TrackRow';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { id }) => id,
|
||||||
|
(state, { mediumNumber }) => mediumNumber,
|
||||||
|
createTrackFileSelector(),
|
||||||
|
createCommandsSelector(),
|
||||||
|
(id, mediumNumber, trackFile, commands) => {
|
||||||
|
return {
|
||||||
|
trackFilePath: trackFile ? trackFile.path : null,
|
||||||
|
trackFileRelativePath: trackFile ? trackFile.relativePath : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default connect(createMapStateToProps)(TrackRow);
|
@ -0,0 +1,25 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import EditAlbumModalContentConnector from './EditAlbumModalContentConnector';
|
||||||
|
|
||||||
|
function EditAlbumModal({ isOpen, onModalClose, ...otherProps }) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<EditAlbumModalContentConnector
|
||||||
|
{...otherProps}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditAlbumModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditAlbumModal;
|
@ -0,0 +1,39 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||||
|
import EditAlbumModal from './EditAlbumModal';
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
clearPendingChanges
|
||||||
|
};
|
||||||
|
|
||||||
|
class EditAlbumModalConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onModalClose = () => {
|
||||||
|
this.props.clearPendingChanges({ section: 'albums' });
|
||||||
|
this.props.onModalClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<EditAlbumModal
|
||||||
|
{...this.props}
|
||||||
|
onModalClose={this.onModalClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditAlbumModalConnector.propTypes = {
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
clearPendingChanges: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(undefined, mapDispatchToProps)(EditAlbumModalConnector);
|
@ -0,0 +1,119 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { inputTypes } from 'Helpers/Props';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||||
|
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 Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
|
||||||
|
class EditAlbumModalContent extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onSavePress = () => {
|
||||||
|
const {
|
||||||
|
onSavePress
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
onSavePress(false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
artistName,
|
||||||
|
albumType,
|
||||||
|
item,
|
||||||
|
isSaving,
|
||||||
|
onInputChange,
|
||||||
|
onModalClose,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
monitored,
|
||||||
|
currentRelease,
|
||||||
|
releases
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
Edit - {artistName} - {title} [{albumType}]
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<Form
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>Monitored</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="monitored"
|
||||||
|
helpText="Lidarr will search for and download album"
|
||||||
|
{...monitored}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel> Release</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.ALBUM_RELEASE_SELECT}
|
||||||
|
name="currentRelease"
|
||||||
|
helpText="Change release for this album"
|
||||||
|
albumReleases={releases}
|
||||||
|
selectedRelease={currentRelease}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SpinnerButton
|
||||||
|
isSpinning={isSaving}
|
||||||
|
onPress={this.onSavePress}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</SpinnerButton>
|
||||||
|
</ModalFooter>
|
||||||
|
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditAlbumModalContent.propTypes = {
|
||||||
|
albumId: PropTypes.number.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
artistName: PropTypes.string.isRequired,
|
||||||
|
albumType: PropTypes.string.isRequired,
|
||||||
|
item: PropTypes.object.isRequired,
|
||||||
|
isSaving: PropTypes.bool.isRequired,
|
||||||
|
onInputChange: PropTypes.func.isRequired,
|
||||||
|
onSavePress: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditAlbumModalContent;
|
@ -0,0 +1,97 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import selectSettings from 'Store/Selectors/selectSettings';
|
||||||
|
import createAlbumSelector from 'Store/Selectors/createAlbumSelector';
|
||||||
|
import createArtistSelector from 'Store/Selectors/createArtistSelector';
|
||||||
|
import { setAlbumValue, saveAlbum } from 'Store/Actions/albumActions';
|
||||||
|
import EditAlbumModalContent from './EditAlbumModalContent';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.albums,
|
||||||
|
createAlbumSelector(),
|
||||||
|
createArtistSelector(),
|
||||||
|
(albumState, album, artist) => {
|
||||||
|
const {
|
||||||
|
isSaving,
|
||||||
|
saveError,
|
||||||
|
pendingChanges
|
||||||
|
} = albumState;
|
||||||
|
|
||||||
|
const albumSettings = _.pick(album, [
|
||||||
|
'monitored',
|
||||||
|
'currentRelease',
|
||||||
|
'releases'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const settings = selectSettings(albumSettings, pendingChanges, saveError);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: album.title,
|
||||||
|
artistName: artist.artistName,
|
||||||
|
albumType: album.albumType,
|
||||||
|
isSaving,
|
||||||
|
saveError,
|
||||||
|
item: settings.settings,
|
||||||
|
...settings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
dispatchSetAlbumValue: setAlbumValue,
|
||||||
|
dispatchSaveAlbum: saveAlbum
|
||||||
|
};
|
||||||
|
|
||||||
|
class EditAlbumModalContentConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||||
|
this.props.onModalClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onInputChange = ({ name, value }) => {
|
||||||
|
this.props.dispatchSetAlbumValue({ name, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
onSavePress = () => {
|
||||||
|
this.props.dispatchSaveAlbum({
|
||||||
|
id: this.props.albumId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<EditAlbumModalContent
|
||||||
|
{...this.props}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onSavePress={this.onSavePress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditAlbumModalContentConnector.propTypes = {
|
||||||
|
albumId: PropTypes.number,
|
||||||
|
isSaving: PropTypes.bool.isRequired,
|
||||||
|
saveError: PropTypes.object,
|
||||||
|
dispatchSetAlbumValue: PropTypes.func.isRequired,
|
||||||
|
dispatchSaveAlbum: PropTypes.func.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(EditAlbumModalContentConnector);
|
@ -0,0 +1,65 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import titleCase from 'Utilities/String/titleCase';
|
||||||
|
import SelectInput from './SelectInput';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { albumReleases }) => albumReleases,
|
||||||
|
(state, { selectedRelease }) => selectedRelease,
|
||||||
|
(albumReleases, selectedRelease) => {
|
||||||
|
const values = _.map(albumReleases.value, (albumRelease) => {
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: albumRelease.id,
|
||||||
|
value: `${albumRelease.mediaCount} med, ${albumRelease.trackCount} tracks` +
|
||||||
|
`${albumRelease.country.length > 0 ? ', ' : ''}${albumRelease.country}` +
|
||||||
|
`${albumRelease.disambiguation ? ', ' : ''}${titleCase(albumRelease.disambiguation)}` +
|
||||||
|
`${albumRelease.format ? ', [' : ''}${albumRelease.format}${albumRelease.format ? ']' : ''}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const value = selectedRelease.value.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlbumReleaseSelectInputConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onChange = ({ name, value }) => {
|
||||||
|
const {
|
||||||
|
albumReleases
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
this.props.onChange({ name, value: _.find(albumReleases.value, { id: value }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectInput
|
||||||
|
{...this.props}
|
||||||
|
onChange={this.onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumReleaseSelectInputConnector.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
albumReleases: PropTypes.object
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps)(AlbumReleaseSelectInputConnector);
|
@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using NzbDrone.Core.Music;
|
||||||
|
|
||||||
|
namespace Lidarr.Api.V1.Albums
|
||||||
|
{
|
||||||
|
public class AlbumReleaseResource
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime? ReleaseDate { get; set; }
|
||||||
|
public int TrackCount { get; set; }
|
||||||
|
public int MediaCount { get; set; }
|
||||||
|
public string Disambiguation { get; set; }
|
||||||
|
public List<string> Country { get; set; }
|
||||||
|
public List<string> Label { get; set; }
|
||||||
|
public string Format { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AlbumReleaseResourceMapper
|
||||||
|
{
|
||||||
|
public static AlbumReleaseResource ToResource(this AlbumRelease model)
|
||||||
|
{
|
||||||
|
if (model == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AlbumReleaseResource
|
||||||
|
{
|
||||||
|
Id = model.Id,
|
||||||
|
ReleaseDate = model.ReleaseDate,
|
||||||
|
TrackCount = model.TrackCount,
|
||||||
|
MediaCount = model.MediaCount,
|
||||||
|
Disambiguation = model.Disambiguation,
|
||||||
|
Country = model.Country,
|
||||||
|
Label = model.Label,
|
||||||
|
Format = model.Format
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AlbumRelease ToModel(this AlbumReleaseResource resource)
|
||||||
|
{
|
||||||
|
if (resource == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AlbumRelease
|
||||||
|
{
|
||||||
|
Id = resource.Id,
|
||||||
|
ReleaseDate = resource.ReleaseDate,
|
||||||
|
TrackCount = resource.TrackCount,
|
||||||
|
MediaCount = resource.MediaCount,
|
||||||
|
Disambiguation = resource.Disambiguation,
|
||||||
|
Country = resource.Country,
|
||||||
|
Label = resource.Label,
|
||||||
|
Format = resource.Format
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<AlbumReleaseResource> ToResource(this IEnumerable<AlbumRelease> models)
|
||||||
|
{
|
||||||
|
return models.Select(ToResource).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<AlbumRelease> ToModel(this IEnumerable<AlbumReleaseResource> resources)
|
||||||
|
{
|
||||||
|
return resources.Select(ToModel).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
using FluentMigrator;
|
||||||
|
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Datastore.Migration
|
||||||
|
{
|
||||||
|
[Migration(9)]
|
||||||
|
public class album_releases : NzbDroneMigrationBase
|
||||||
|
{
|
||||||
|
protected override void MainDbUpgrade()
|
||||||
|
{
|
||||||
|
Alter.Table("Albums").AddColumn("Releases").AsString().WithDefaultValue("").Nullable();
|
||||||
|
Alter.Table("Albums").AddColumn("CurrentRelease").AsString().WithDefaultValue("").Nullable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
using NzbDrone.Common.Exceptions;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Exceptions
|
||||||
|
{
|
||||||
|
public class AlbumNotFoundException : NzbDroneException
|
||||||
|
{
|
||||||
|
public string MusicBrainzId { get; set; }
|
||||||
|
|
||||||
|
public AlbumNotFoundException(string musicbrainzId)
|
||||||
|
: base(string.Format("Album with MusicBrainz {0} was not found, it may have been removed from MusicBrainz.", musicbrainzId))
|
||||||
|
{
|
||||||
|
MusicBrainzId = musicbrainzId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlbumNotFoundException(string musicbrainzId, string message, params object[] args)
|
||||||
|
: base(message, args)
|
||||||
|
{
|
||||||
|
MusicBrainzId = musicbrainzId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlbumNotFoundException(string musicbrainzId, string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
MusicBrainzId = musicbrainzId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
using NzbDrone.Core.Music;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.MetadataSource
|
||||||
|
{
|
||||||
|
public interface IProvideAlbumInfo
|
||||||
|
{
|
||||||
|
Tuple<Album, List<Track>> GetAlbumInfo(string lidarrId, string releaseId);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.MetadataSource.SkyHook.Resource
|
||||||
|
{
|
||||||
|
public class ReleaseResource
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime ReleaseDate { get; set; }
|
||||||
|
public int MediaCount { get; set; }
|
||||||
|
public int TrackCount { get; set; }
|
||||||
|
public string Disambiguation { get; set; }
|
||||||
|
public List<string> Label {get; set;}
|
||||||
|
public List<string> Country { get; set; }
|
||||||
|
public string Format { get; set; }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
using NzbDrone.Core.Messaging.Commands;
|
||||||
|
using NzbDrone.Core.Messaging.Events;
|
||||||
|
using NzbDrone.Core.Music.Commands;
|
||||||
|
using NzbDrone.Core.Music.Events;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Music
|
||||||
|
{
|
||||||
|
public class AlbumEditedService : IHandle<AlbumEditedEvent>
|
||||||
|
{
|
||||||
|
private readonly IManageCommandQueue _commandQueueManager;
|
||||||
|
|
||||||
|
public AlbumEditedService(IManageCommandQueue commandQueueManager)
|
||||||
|
{
|
||||||
|
_commandQueueManager = commandQueueManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Handle(AlbumEditedEvent message)
|
||||||
|
{
|
||||||
|
if (message.Album.CurrentRelease.Id != message.OldAlbum.CurrentRelease.Id)
|
||||||
|
{
|
||||||
|
_commandQueueManager.Push(new RefreshAlbumCommand(message.Album.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using NzbDrone.Core.Datastore;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Music
|
||||||
|
{
|
||||||
|
public class AlbumRelease : IEmbeddedDocument
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public DateTime? ReleaseDate { get; set; }
|
||||||
|
public int TrackCount { get; set; }
|
||||||
|
public int MediaCount { get; set; }
|
||||||
|
public string Disambiguation { get; set; }
|
||||||
|
public List<string> Country { get; set; }
|
||||||
|
public string Format { get; set; }
|
||||||
|
public List<string> Label { get; set; }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
using NzbDrone.Core.Messaging.Commands;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Music.Commands
|
||||||
|
{
|
||||||
|
public class RefreshAlbumCommand : Command
|
||||||
|
{
|
||||||
|
public int? AlbumId { get; set; }
|
||||||
|
|
||||||
|
public RefreshAlbumCommand()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public RefreshAlbumCommand(int? albumId)
|
||||||
|
{
|
||||||
|
AlbumId = albumId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool SendUpdatesToClient => true;
|
||||||
|
|
||||||
|
public override bool UpdateScheduledTask => !AlbumId.HasValue;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using NzbDrone.Common.Messaging;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Music.Events
|
||||||
|
{
|
||||||
|
public class AlbumsAddedEvent : IEvent
|
||||||
|
{
|
||||||
|
public List<int> AlbumIds { get; private set; }
|
||||||
|
|
||||||
|
public AlbumsAddedEvent(List<int> albumIds)
|
||||||
|
{
|
||||||
|
AlbumIds = albumIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue