Medium Support (Multi-disc Albums), Quality Grouping (#121)

* Multi Disc Stage 1 - Backend Work

* Quality Group Functionality

* Fixed: Only show wanted album types on ArtistDetail page

* Add Media Count Column to ArtistDetail Page

* Parser updates for multidisc cases, other usenet release title formats

* Search for Tracks by Medium Number in Addition to Title and TrackNumber

* Medium Renaming Token for Track Naming

* fixup Codacy and Comment Cleanup

* fixup remove comments
pull/123/head
Qstick 7 years ago committed by GitHub
parent e1e7cad951
commit 21428cba6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -43,7 +43,11 @@ class HistoryConnector extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) { if (hasDifferentItems(prevProps.items, this.props.items)) {
const albumIds = selectUniqueIds(this.props.items, 'albumId'); const albumIds = selectUniqueIds(this.props.items, 'albumId');
this.props.fetchEpisodes({ albumIds }); if (albumIds.length) {
this.props.fetchEpisodes({ albumIds });
} else {
this.props.clearEpisodes();
}
} }
} }

@ -52,7 +52,11 @@ class QueueConnector extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) { if (hasDifferentItems(prevProps.items, this.props.items)) {
const albumIds = selectUniqueIds(this.props.items, 'albumId'); const albumIds = selectUniqueIds(this.props.items, 'albumId');
this.props.fetchEpisodes({ albumIds }); if (albumIds.length) {
this.props.fetchEpisodes({ albumIds });
} else {
this.props.clearEpisodes();
}
} }
} }

@ -44,7 +44,7 @@ class AddNewArtistSearchResult extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (!prevProps.isExistingArtist && this.props.isExistingArtist) { if (!prevProps.isExistingArtist && this.props.isExistingArtist) {
this.onAddSerisModalClose(); this.onAddArtistModalClose();
} }
} }
@ -55,7 +55,7 @@ class AddNewArtistSearchResult extends Component {
this.setState({ isNewAddArtistModalOpen: true }); this.setState({ isNewAddArtistModalOpen: true });
} }
onAddSerisModalClose = () => { onAddArtistModalClose = () => {
this.setState({ isNewAddArtistModalOpen: false }); this.setState({ isNewAddArtistModalOpen: false });
} }
@ -183,7 +183,7 @@ class AddNewArtistSearchResult extends Component {
year={year} year={year}
overview={overview} overview={overview}
images={images} images={images}
onModalClose={this.onAddSerisModalClose} onModalClose={this.onAddArtistModalClose}
/> />
</Link> </Link>
); );

@ -2,13 +2,12 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { clearReleases } from 'Store/Actions/releaseActions'; import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions'; import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector';
import episodeEntities from 'Album/episodeEntities'; import episodeEntities from 'Album/episodeEntities';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent'; import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
function createMapStateToProps() { function createMapStateToProps() {
@ -32,14 +31,38 @@ function createMapStateToProps() {
); );
} }
const mapDispatchToProps = { function createMapDispatchToProps(dispatch, props) {
clearReleases, return {
fetchTracks, dispatchCancelFetchReleases() {
clearTracks, dispatch(cancelFetchReleases());
fetchTrackFiles, },
clearTrackFiles,
toggleEpisodeMonitored dispatchClearReleases() {
}; dispatch(clearReleases());
},
dispatchFetchTracks({ artistId, albumId }) {
dispatch(fetchTracks({ artistId, albumId }));
},
dispatchClearTracks() {
dispatch(clearTracks());
},
onMonitorAlbumPress(monitored) {
const {
albumId,
episodeEntity
} = this.props;
dispatch(toggleEpisodeMonitored({
episodeEntity,
albumId,
monitored
}));
}
};
}
class EpisodeDetailsModalContentConnector extends Component { class EpisodeDetailsModalContentConnector extends Component {
@ -53,7 +76,8 @@ class EpisodeDetailsModalContentConnector extends Component {
// Clear pending releases here so we can reshow the search // Clear pending releases here so we can reshow the search
// results even after switching tabs. // results even after switching tabs.
this._unpopulate(); this._unpopulate();
this.props.clearReleases(); this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
} }
// //
@ -62,40 +86,24 @@ class EpisodeDetailsModalContentConnector extends Component {
_populate() { _populate() {
const artistId = this.props.artistId; const artistId = this.props.artistId;
const albumId = this.props.albumId; const albumId = this.props.albumId;
this.props.fetchTracks({ artistId, albumId }); this.props.dispatchFetchTracks({ artistId, albumId });
// this.props.fetchTrackFiles({ artistId, albumId });
} }
_unpopulate() { _unpopulate() {
this.props.clearTracks(); this.props.dispatchClearTracks();
// this.props.clearTrackFiles();
} }
// //
// Listeners // Render
onMonitorAlbumPress = (monitored) => { render() {
const { const {
albumId, dispatchClearReleases,
episodeEntity ...otherProps
} = this.props; } = this.props;
this.props.toggleEpisodeMonitored({
episodeEntity,
albumId,
monitored
});
}
//
// Render
render() {
return ( return (
<EpisodeDetailsModalContent <EpisodeDetailsModalContent {...otherProps} />
{...this.props}
onMonitorAlbumPress={this.onMonitorAlbumPress}
/>
); );
} }
} }
@ -104,16 +112,14 @@ EpisodeDetailsModalContentConnector.propTypes = {
albumId: PropTypes.number.isRequired, albumId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired, episodeEntity: PropTypes.string.isRequired,
artistId: PropTypes.number.isRequired, artistId: PropTypes.number.isRequired,
fetchTracks: PropTypes.func.isRequired, dispatchFetchTracks: PropTypes.func.isRequired,
clearTracks: PropTypes.func.isRequired, dispatchClearTracks: PropTypes.func.isRequired,
fetchTrackFiles: PropTypes.func.isRequired, dispatchCancelFetchReleases: PropTypes.func.isRequired,
clearTrackFiles: PropTypes.func.isRequired, dispatchClearReleases: PropTypes.func.isRequired
clearReleases: PropTypes.func.isRequired,
toggleEpisodeMonitored: PropTypes.func.isRequired
}; };
EpisodeDetailsModalContentConnector.defaultProps = { EpisodeDetailsModalContentConnector.defaultProps = {
episodeEntity: episodeEntities.EPISODES episodeEntity: episodeEntities.EPISODES
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeDetailsModalContentConnector); export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);

@ -3,20 +3,24 @@ import React from 'react';
import Label from 'Components/Label'; import Label from 'Components/Label';
function EpisodeLanguage(props) { function EpisodeLanguage(props) {
const language = props.language; const {
className,
language
} = props;
if (!language) { if (!language) {
return null; return null;
} }
return ( return (
<Label> <Label className={className}>
{language.name} {language.name}
</Label> </Label>
); );
} }
EpisodeLanguage.propTypes = { EpisodeLanguage.propTypes = {
className: PropTypes.string,
language: PropTypes.object language: PropTypes.object
}; };

@ -24,6 +24,7 @@ function getTooltip(title, quality, size) {
function EpisodeQuality(props) { function EpisodeQuality(props) {
const { const {
className,
title, title,
quality, quality,
size, size,
@ -32,6 +33,7 @@ function EpisodeQuality(props) {
return ( return (
<Label <Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT} kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
title={getTooltip(title, quality, size)} title={getTooltip(title, quality, size)}
> >
@ -41,6 +43,7 @@ function EpisodeQuality(props) {
} }
EpisodeQuality.propTypes = { EpisodeQuality.propTypes = {
className: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
size: PropTypes.number, size: PropTypes.number,

@ -1,6 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import titleCase from 'Utilities/String/titleCase';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
@ -14,6 +13,18 @@ import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConn
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import styles from './AlbumHistoryRow.css'; import styles from './AlbumHistoryRow.css';
function getTitle(eventType) {
switch (eventType) {
case 'grabbed': return 'Grabbed';
case 'artistFolderImported': return 'Artist Folder Imported';
case 'downloadFolderImported': return 'Download Folder Imported';
case 'downloadFailed': return 'Download Failed';
case 'trackFileDeleted': return 'Track File Deleted';
case 'trackFileRenamed': return 'Track File Renamed';
default: return 'Unknown';
}
}
class AlbumHistoryRow extends Component { class AlbumHistoryRow extends Component {
// //
@ -89,7 +100,7 @@ class AlbumHistoryRow extends Component {
name={icons.INFO} name={icons.INFO}
/> />
} }
title={titleCase(eventType)} title={getTitle(eventType)}
body={ body={
<HistoryDetailsConnector <HistoryDetailsConnector
eventType={eventType} eventType={eventType}

@ -15,7 +15,9 @@ function createMapStateToProps() {
createCommandsSelector(), createCommandsSelector(),
createDimensionsSelector(), createDimensionsSelector(),
(tracks, episode, commands, dimensions) => { (tracks, episode, commands, dimensions) => {
const items = _.filter(tracks.items, { albumId: episode.id }); const filteredItems = _.filter(tracks.items, { albumId: episode.id });
const mediumSortedItems = _.orderBy(filteredItems, 'absoluteTrackNumber');
const items = _.orderBy(mediumSortedItems, 'mediumNumber');
return { return {
network: episode.label, network: episode.label,

@ -24,7 +24,8 @@ class TrackDetailRow extends Component {
const { const {
id, id,
title, title,
trackNumber, mediumNumber,
absoluteTrackNumber,
duration, duration,
columns, columns,
trackFileId trackFileId
@ -43,13 +44,24 @@ class TrackDetailRow extends Component {
return null; return null;
} }
if (name === 'trackNumber') { if (name === 'medium') {
return ( return (
<TableRowCell <TableRowCell
key={name} key={name}
className={styles.trackNumber} className={styles.trackNumber}
> >
{trackNumber} {mediumNumber}
</TableRowCell>
);
}
if (name === 'absoluteTrackNumber') {
return (
<TableRowCell
key={name}
className={styles.trackNumber}
>
{absoluteTrackNumber}
</TableRowCell> </TableRowCell>
); );
} }
@ -117,7 +129,8 @@ TrackDetailRow.propTypes = {
duration: PropTypes.number.isRequired, duration: PropTypes.number.isRequired,
trackFileId: PropTypes.number.isRequired, trackFileId: PropTypes.number.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
trackNumber: PropTypes.number.isRequired mediumNumber: PropTypes.number.isRequired,
absoluteTrackNumber: PropTypes.number.isRequired
}; };
export default TrackDetailRow; export default TrackDetailRow;

@ -63,6 +63,7 @@ class AlbumRow extends Component {
statistics, statistics,
duration, duration,
releaseDate, releaseDate,
mediumCount,
title, title,
isSaving, isSaving,
artistMonitored, artistMonitored,
@ -131,6 +132,16 @@ class AlbumRow extends Component {
); );
} }
if (name === 'mediumCount') {
return (
<TableRowCell key={name}>
{
mediumCount
}
</TableRowCell>
);
}
if (name === 'trackCount') { if (name === 'trackCount') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
@ -203,6 +214,7 @@ AlbumRow.propTypes = {
artistId: PropTypes.number.isRequired, artistId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired, monitored: PropTypes.bool.isRequired,
releaseDate: PropTypes.string.isRequired, releaseDate: PropTypes.string.isRequired,
mediumCount: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired, duration: PropTypes.number.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
isSaving: PropTypes.bool, isSaving: PropTypes.bool,

@ -47,10 +47,18 @@
} }
.info { .info {
display: flex;
flex-direction: column;
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow: hidden;
} }
.titleRow {
display: flex;
justify-content: space-between;
flex: 0 0 auto;
}
.titleContainer { .titleContainer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -111,6 +119,11 @@
font-family: $monoSpaceFontFamily; font-family: $monoSpaceFontFamily;
} }
.overview {
flex: 1 0 auto;
min-height: 0;
}
.contentContainer { .contentContainer {
padding: 20px; padding: 20px;
} }

@ -6,6 +6,7 @@ import formatBytes from 'Utilities/Number/formatBytes';
import selectAll from 'Utilities/Table/selectAll'; import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected'; import toggleSelected from 'Utilities/Table/toggleSelected';
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import fonts from 'Styles/Variables/fonts';
import HeartRating from 'Components/HeartRating'; import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
@ -31,33 +32,8 @@ import ArtistTagsConnector from './ArtistTagsConnector';
import ArtistDetailsLinks from './ArtistDetailsLinks'; import ArtistDetailsLinks from './ArtistDetailsLinks';
import styles from './ArtistDetails.css'; import styles from './ArtistDetails.css';
const albumTypes = [ const defaultFontSize = parseInt(fonts.defaultFontSize);
{ const lineHeight = parseFloat(fonts.lineHeight);
name: 'album',
label: 'Album',
isVisible: true
},
{
name: 'ep',
label: 'EP',
isVisible: true
},
{
name: 'single',
label: 'Single',
isVisible: true
},
{
name: 'broadcast',
label: 'Broadcast',
isVisible: true
},
{
name: 'other',
label: 'Other',
isVisible: true
}
];
function getFanartUrl(images) { function getFanartUrl(images) {
const fanartImage = _.find(images, { coverType: 'fanart' }); const fanartImage = _.find(images, { coverType: 'fanart' });
@ -174,6 +150,7 @@ class ArtistDetails extends Component {
links, links,
images, images,
albums, albums,
primaryAlbumTypes,
alternateTitles, alternateTitles,
tags, tags,
isRefreshing, isRefreshing,
@ -475,11 +452,9 @@ class ArtistDetails extends Component {
} }
</div> </div>
<div className={styles.overview}>
<div>
<TextTruncate <TextTruncate
truncateText="…" line={Math.floor(200 / (defaultFontSize * lineHeight))}
line={8}
text={overview} text={overview}
/> />
</div> </div>
@ -495,26 +470,27 @@ class ArtistDetails extends Component {
{ {
!isFetching && episodesError && !isFetching && episodesError &&
<div>Loading episodes failed</div> <div>Loading albums failed</div>
} }
{ {
!isFetching && trackFilesError && !isFetching && trackFilesError &&
<div>Loading episode files failed</div> <div>Loading track files failed</div>
} }
{ {
isPopulated && !!albumTypes.length && isPopulated && !!primaryAlbumTypes.length &&
<div> <div>
{ {
albumTypes.slice(0).map((season) => { primaryAlbumTypes.slice(0).map((albumType) => {
return ( return (
<ArtistDetailsSeasonConnector <ArtistDetailsSeasonConnector
key={season.name} key={albumType}
artistId={id} artistId={id}
label={season.label} name={albumType}
{...season} label={albumType}
isExpanded={expandedState[season.name]} {...albumType}
isExpanded={expandedState[albumType]}
onExpandPress={this.onExpandPress} onExpandPress={this.onExpandPress}
/> />
); );
@ -570,6 +546,7 @@ ArtistDetails.propTypes = {
links: PropTypes.arrayOf(PropTypes.object).isRequired, links: PropTypes.arrayOf(PropTypes.object).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired,
albums: PropTypes.arrayOf(PropTypes.object).isRequired, albums: PropTypes.arrayOf(PropTypes.object).isRequired,
primaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isRefreshing: PropTypes.bool.isRequired, isRefreshing: PropTypes.bool.isRequired,

@ -47,7 +47,9 @@ $hoverScale: 1.05;
} }
.info { .info {
display: flex;
flex: 1 0 1px; flex: 1 0 1px;
flex-direction: column;
overflow: hidden; overflow: hidden;
padding-left: 10px; padding-left: 10px;
} }
@ -75,6 +77,7 @@ $hoverScale: 1.05;
.details { .details {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex: 1 0 auto;
} }
.overview { .overview {
@ -82,6 +85,7 @@ $hoverScale: 1.05;
flex: 0 1 1000px; flex: 0 1 1000px;
overflow: hidden; overflow: hidden;
min-height: 0;
} }
@media only screen and (max-width: $breakpointSmall) { @media only screen and (max-width: $breakpointSmall) {

@ -1,6 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Truncate from 'react-truncate'; import TextTruncate from 'react-text-truncate';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts'; import fonts from 'Styles/Variables/fonts';
@ -176,16 +176,15 @@ class ArtistIndexOverview extends Component {
</div> </div>
<div className={styles.details}> <div className={styles.details}>
<Link <Link
className={styles.overview} className={styles.overview}
style={{
maxHeight: `${height}px`
}}
to={link} to={link}
> >
<Truncate lines={Math.floor(height / (defaultFontSize * lineHeight))}> <TextTruncate
{overview} line={Math.floor(height / (defaultFontSize * lineHeight))}
</Truncate> text={overview}
/>
</Link> </Link>
<ArtistIndexOverviewInfo <ArtistIndexOverviewInfo

@ -56,7 +56,9 @@ class CalendarConnector extends Component {
const albumIds = selectUniqueIds(items, 'id'); const albumIds = selectUniqueIds(items, 'id');
// const trackFileIds = selectUniqueIds(items, 'trackFileId'); // const trackFileIds = selectUniqueIds(items, 'trackFileId');
this.props.fetchQueueDetails({ albumIds }); if (items.length) {
this.props.fetchQueueDetails({ albumIds });
}
// if (trackFileIds.length) { // if (trackFileIds.length) {
// this.props.fetchTrackFiles({ trackFileIds }); // this.props.fetchTrackFiles({ trackFileIds });

@ -27,6 +27,10 @@
background-color: #aaa; background-color: #aaa;
} }
.isHidden {
display: none;
}
.isMobile { .isMobile {
height: 50px; height: 50px;
border-bottom: 1px solid $borderColor; border-bottom: 1px solid $borderColor;

@ -28,6 +28,7 @@ class EnhancedSelectInputOption extends Component {
className, className,
isSelected, isSelected,
isDisabled, isDisabled,
isHidden,
isMobile, isMobile,
children children
} = this.props; } = this.props;
@ -38,6 +39,7 @@ class EnhancedSelectInputOption extends Component {
className, className,
isSelected && styles.isSelected, isSelected && styles.isSelected,
isDisabled && styles.isDisabled, isDisabled && styles.isDisabled,
isHidden && styles.isHidden,
isMobile && styles.isMobile isMobile && styles.isMobile
)} )}
component="div" component="div"
@ -64,6 +66,7 @@ EnhancedSelectInputOption.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isHidden: PropTypes.bool.isRequired,
isMobile: PropTypes.bool.isRequired, isMobile: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
onSelect: PropTypes.func.isRequired onSelect: PropTypes.func.isRequired
@ -71,7 +74,8 @@ EnhancedSelectInputOption.propTypes = {
EnhancedSelectInputOption.defaultProps = { EnhancedSelectInputOption.defaultProps = {
className: styles.option, className: styles.option,
isDisabled: false isDisabled: false,
isHidden: false
}; };
export default EnhancedSelectInputOption; export default EnhancedSelectInputOption;

@ -5,6 +5,10 @@
/* Sizes */ /* Sizes */
.extraSmall {
max-width: $formGroupExtraSmallWidth;
}
.small { .small {
max-width: $formGroupSmallWidth; max-width: $formGroupSmallWidth;
} }

@ -41,7 +41,7 @@ function FormGroup(props) {
FormGroup.propTypes = { FormGroup.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
size: PropTypes.string.isRequired, size: PropTypes.oneOf(sizes.all).isRequired,
advancedSettings: PropTypes.bool.isRequired, advancedSettings: PropTypes.bool.isRequired,
isAdvanced: PropTypes.bool.isRequired isAdvanced: PropTypes.bool.isRequired
}; };

@ -1,7 +1,6 @@
.label { .label {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
flex: 0 0 $formLabelWidth;
margin-right: $formLabelRightMarginWidth; margin-right: $formLabelRightMarginWidth;
font-weight: bold; font-weight: bold;
line-height: 35px; line-height: 35px;
@ -20,3 +19,12 @@
justify-content: flex-start; justify-content: flex-start;
} }
} }
.small {
flex: 0 0 $formLabelSmallWidth;
}
.large {
flex: 0 0 $formLabelLargeWidth;
}

@ -1,12 +1,14 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { sizes } from 'Helpers/Props';
import styles from './FormLabel.css'; import styles from './FormLabel.css';
function FormLabel({ function FormLabel({
children, children,
className, className,
errorClassName, errorClassName,
size,
name, name,
hasError, hasError,
isAdvanced, isAdvanced,
@ -17,6 +19,7 @@ function FormLabel({
{...otherProps} {...otherProps}
className={classNames( className={classNames(
className, className,
styles[size],
hasError && errorClassName, hasError && errorClassName,
isAdvanced && styles.isAdvanced isAdvanced && styles.isAdvanced
)} )}
@ -31,6 +34,7 @@ FormLabel.propTypes = {
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
className: PropTypes.string, className: PropTypes.string,
errorClassName: PropTypes.string, errorClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all),
name: PropTypes.string, name: PropTypes.string,
hasError: PropTypes.bool, hasError: PropTypes.bool,
isAdvanced: PropTypes.bool.isRequired isAdvanced: PropTypes.bool.isRequired
@ -39,7 +43,8 @@ FormLabel.propTypes = {
FormLabel.defaultProps = { FormLabel.defaultProps = {
className: styles.label, className: styles.label,
errorClassName: styles.hasError, errorClassName: styles.hasError,
isAdvanced: false isAdvanced: false,
size: sizes.LARGE
}; };
export default FormLabel; export default FormLabel;

@ -22,20 +22,19 @@ class RootFolderSelectInput extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { const {
name, name,
values,
isSaving, isSaving,
saveError, saveError,
onChange onChange
} = this.props; } = this.props;
const newRootFolderPath = this.state.newRootFolderPath;
if ( if (
prevProps.isSaving && prevProps.isSaving &&
!isSaving && !isSaving &&
!saveError && !saveError &&
values.length - prevProps.values.length === 1 newRootFolderPath
) { ) {
const newRootFolderPath = this.state.newRootFolderPath;
onChange({ name, value: newRootFolderPath }); onChange({ name, value: newRootFolderPath });
this.setState({ newRootFolderPath: '' }); this.setState({ newRootFolderPath: '' });
} }

@ -33,7 +33,8 @@ function createMapStateToProps() {
values.push({ values.push({
key: '', key: '',
value: '', value: '',
isDisabled: true isDisabled: true,
isHidden: true
}); });
} }
@ -64,6 +65,18 @@ class RootFolderSelectInputConnector extends Component {
// //
// Lifecycle // Lifecycle
componentWillMount() {
const {
value,
values,
onChange
} = this.props;
if (value == null && values[0].key === '') {
onChange({ name, value: '' });
}
}
componentDidMount() { componentDidMount() {
const { const {
name, name,

@ -12,9 +12,9 @@ const messages = [
'Hum something loud while others stare', 'Hum something loud while others stare',
'Loading humorous message... Please Wait', 'Loading humorous message... Please Wait',
'I could\'ve been faster in Python', 'I could\'ve been faster in Python',
'Don\'t forget to rewind your episodes', 'Don\'t forget to rewind your tracks',
'Congratulations! you are the 1000th visitor.', 'Congratulations! you are the 1000th visitor.',
'HELP!, I\'m being held hostage and forced to write these stupid lines!', 'HELP! I\'m being held hostage and forced to write these stupid lines!',
'RE-calibrating the internet...', 'RE-calibrating the internet...',
'I\'ll be here all week', 'I\'ll be here all week',
'Don\'t forget to tip your waitress', 'Don\'t forget to tip your waitress',

@ -51,6 +51,18 @@
width: 1080px; width: 1080px;
} }
.extraLarge {
composes: modal;
width: 1440px;
}
@media only screen and (max-width: $breakpointExtraLarge) {
.modal.extraLarge {
width: 90%;
}
}
@media only screen and (max-width: $breakpointLarge) { @media only screen and (max-width: $breakpointLarge) {
.modal.large { .modal.large {
width: 90%; width: 90%;
@ -71,9 +83,10 @@
.modal.small, .modal.small,
.modal.medium, .modal.medium,
.modal.large { .modal.large,
.modal.extraLarge {
max-height: 100%; max-height: 100%;
width: 100%; width: 100%;
height: 100%; height: 100% !important;
} }
} }

@ -139,6 +139,7 @@ class Modal extends Component {
render() { render() {
const { const {
className, className,
style,
backdropClassName, backdropClassName,
size, size,
children, children,
@ -166,6 +167,7 @@ class Modal extends Component {
className, className,
styles[size] styles[size]
)} )}
style={style}
> >
{children} {children}
</div> </div>
@ -180,6 +182,7 @@ class Modal extends Component {
Modal.propTypes = { Modal.propTypes = {
className: PropTypes.string, className: PropTypes.string,
style: PropTypes.object,
backdropClassName: PropTypes.string, backdropClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all), size: PropTypes.oneOf(sizes.all),
children: PropTypes.node, children: PropTypes.node,

@ -1,5 +1,3 @@
$modalBodyPadding: 30px;
.modalBody { .modalBody {
flex: 1 0 1px; flex: 1 0 1px;
padding: $modalBodyPadding; padding: $modalBodyPadding;

@ -23,13 +23,13 @@ class PageHeader extends Component {
} }
componentDidMount() { componentDidMount() {
this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.openKeyboardShortcutsModal); this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
} }
// //
// Control // Control
openKeyboardShortcutsModal = () => { onOpenKeyboardShortcutsModal = () => {
this.setState({ isKeyboardShortcutsModalOpen: true }); this.setState({ isKeyboardShortcutsModalOpen: true });
} }
@ -76,7 +76,9 @@ class PageHeader extends Component {
name={icons.HEART} name={icons.HEART}
to="https://lidarr.audio/donate.html" to="https://lidarr.audio/donate.html"
/> />
<PageHeaderActionsMenuConnector /> <PageHeaderActionsMenuConnector
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div> </div>
<KeyboardShortcutsModal <KeyboardShortcutsModal

@ -11,6 +11,7 @@ import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) { function PageHeaderActionsMenu(props) {
const { const {
formsAuth, formsAuth,
onKeyboardShortcutsPress,
onRestartPress, onRestartPress,
onShutdownPress onShutdownPress
} = props; } = props;
@ -25,6 +26,16 @@ function PageHeaderActionsMenu(props) {
</MenuButton> </MenuButton>
<MenuContent> <MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon
className={styles.itemIcon}
name={icons.KEYBOARD}
/>
Keyboard Shortcuts
</MenuItem>
<div className={styles.separator} />
<MenuItem onPress={onRestartPress}> <MenuItem onPress={onRestartPress}>
<Icon <Icon
className={styles.itemIcon} className={styles.itemIcon}
@ -68,6 +79,7 @@ function PageHeaderActionsMenu(props) {
PageHeaderActionsMenu.propTypes = { PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired, formsAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: PropTypes.func.isRequired, onRestartPress: PropTypes.func.isRequired,
onShutdownPress: PropTypes.func.isRequired onShutdownPress: PropTypes.func.isRequired
}; };

@ -26,6 +26,14 @@ function getState(status) {
} }
} }
function isAppDisconnected(disconnectedTime) {
if (!disconnectedTime) {
return false;
}
return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180;
}
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.app.isReconnecting, (state) => state.app.isReconnecting,
@ -66,6 +74,7 @@ class SignalRConnector extends Component {
this.signalRconnection = null; this.signalRconnection = null;
this.retryInterval = 5; this.retryInterval = 5;
this.retryTimeoutId = null; this.retryTimeoutId = null;
this.disconnectedTime = null;
} }
componentDidMount() { componentDidMount() {
@ -90,7 +99,7 @@ class SignalRConnector extends Component {
// Control // Control
retryConnection = () => { retryConnection = () => {
if (this.retryInterval >= 30) { if (isAppDisconnected(this.disconnectedTime)) {
this.setState({ this.setState({
isDisconnected: true isDisconnected: true
}); });
@ -290,6 +299,9 @@ class SignalRConnector extends Component {
console.log(`SignalR: ${state}`); console.log(`SignalR: ${state}`);
if (state === 'connected') { if (state === 'connected') {
// Clear disconnected time
this.disconnectedTime = null;
// Repopulate the page (if a repopulator is set) to ensure things // Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting. // are in sync after reconnecting.
@ -322,6 +334,10 @@ class SignalRConnector extends Component {
return; return;
} }
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.setAppValue({ this.props.setAppValue({
isReconnecting: true isReconnecting: true
}); });
@ -332,11 +348,14 @@ class SignalRConnector extends Component {
return; return;
} }
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.setAppValue({ this.props.setAppValue({
isConnected: false, isConnected: false,
isReconnecting: true isReconnecting: true,
// Don't set isDisconnected yet, it'll be set it if it's disconnected isDisconnected: isAppDisconnected(this.disconnectedTime)
// for ~105 seconds (retry interval reaches 30 seconds)
}); });
this.retryConnection(); this.retryConnection();

@ -8,7 +8,7 @@ import TableOptionsColumn from './TableOptionsColumn';
import styles from './TableOptionsColumnDragPreview.css'; import styles from './TableOptionsColumnDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth); const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth); const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class TableOptionsColumnDragPreview extends Component {
// list item and the preview is wider than the drag handle. // list item and the preview is wider than the drag handle.
const { x, y } = currentOffset; const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth; const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = { const style = {

@ -53,6 +53,14 @@ class Popover extends Component {
this.state = { this.state = {
isOpen: false isOpen: false
}; };
this._closeTimeout = null;
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
} }
// //
@ -63,11 +71,17 @@ class Popover extends Component {
} }
onMouseEnter = () => { onMouseEnter = () => {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
this.setState({ isOpen: true }); this.setState({ isOpen: true });
} }
onMouseLeave = () => { onMouseLeave = () => {
this.setState({ isOpen: false }); this._closeTimeout = setTimeout(() => {
this.setState({ isOpen: false });
}, 100);
} }
// //
@ -98,24 +112,28 @@ class Popover extends Component {
{ {
this.state.isOpen && this.state.isOpen &&
<div className={styles.popoverContainer}> <div
<div className={styles.popover}> className={styles.popoverContainer}
<div onMouseEnter={this.onMouseEnter}
className={classNames( onMouseLeave={this.onMouseLeave}
styles.arrow, >
styles[position] <div className={styles.popover}>
)} <div
/> className={classNames(
styles.arrow,
<div className={styles.title}> styles[position]
{title} )}
</div> />
<div className={styles.body}> <div className={styles.title}>
{body} {title}
</div> </div>
<div className={styles.body}>
{body}
</div> </div>
</div> </div>
</div>
} }
</TetherComponent> </TetherComponent>
); );

@ -50,11 +50,17 @@ class Tooltip extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this._closeTimeout = null;
this.state = { this.state = {
isOpen: false isOpen: false
}; };
this._closeTimeout = null;
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
} }
// //
@ -83,6 +89,7 @@ class Tooltip extends Component {
render() { render() {
const { const {
className,
anchor, anchor,
tooltip, tooltip,
kind, kind,
@ -97,6 +104,7 @@ class Tooltip extends Component {
{...tetherOptions[position]} {...tetherOptions[position]}
> >
<span <span
className={className}
// onClick={this.onClick} // onClick={this.onClick}
onMouseEnter={this.onMouseEnter} onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave} onMouseLeave={this.onMouseLeave}
@ -137,6 +145,7 @@ class Tooltip extends Component {
} }
Tooltip.propTypes = { Tooltip.propTypes = {
className: PropTypes.string,
anchor: PropTypes.node.isRequired, anchor: PropTypes.node.isRequired,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]), kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),

@ -36,11 +36,13 @@ export const FILE = 'fa fa-file-o';
export const FILTER = 'fa fa-filter'; export const FILTER = 'fa fa-filter';
export const FOLDER = 'fa fa-folder-o'; export const FOLDER = 'fa fa-folder-o';
export const FOLDER_OPEN = 'fa fa-folder-open'; export const FOLDER_OPEN = 'fa fa-folder-open';
export const GROUP = 'fa fa-object-group';
export const HEALTH = 'fa fa-medkit'; export const HEALTH = 'fa fa-medkit';
export const HEART = 'fa fa-heart'; export const HEART = 'fa fa-heart';
export const HOUSEKEEPING = 'fa fa-home'; export const HOUSEKEEPING = 'fa fa-home';
export const INFO = 'fa fa-info-circle'; export const INFO = 'fa fa-info-circle';
export const INTERACTIVE = 'fa fa-user'; export const INTERACTIVE = 'fa fa-user';
export const KEYBOARD = 'fa fa-keyboard-o';
export const LOGOUT = 'fa fa-sign-out'; export const LOGOUT = 'fa fa-sign-out';
export const MISSING = 'fa fa-exclamation-triangle'; export const MISSING = 'fa fa-exclamation-triangle';
export const MONITORED = 'fa fa-bookmark'; export const MONITORED = 'fa fa-bookmark';
@ -82,6 +84,7 @@ export const SUBTRACT = 'fa fa-minus';
export const SYSTEM = 'fa fa-laptop'; export const SYSTEM = 'fa fa-laptop';
export const TAGS = 'fa fa-tags'; export const TAGS = 'fa fa-tags';
export const TBA = 'fa fa-question-circle'; export const TBA = 'fa fa-question-circle';
export const UNGROUP = 'fa fa-object-ungroup';
export const UNKNOWN = 'fa fa-question'; export const UNKNOWN = 'fa fa-question';
export const UNMONITORED = 'fa fa-bookmark-o'; export const UNMONITORED = 'fa fa-bookmark-o';
export const UPDATE = 'fa fa-retweet'; export const UPDATE = 'fa fa-retweet';

@ -1,5 +1,7 @@
export const EXTRA_SMALL = 'extraSmall';
export const SMALL = 'small'; export const SMALL = 'small';
export const MEDIUM = 'medium'; export const MEDIUM = 'medium';
export const LARGE = 'large'; export const LARGE = 'large';
export const EXTRA_LARGE = 'extraLarge';
export const all = [SMALL, MEDIUM, LARGE]; export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE];

@ -4,8 +4,15 @@
word-break: break-all; word-break: break-all;
} }
.quality { .quality,
.language {
composes: cell from 'Components/Table/Cells/TableRowCell.css'; composes: cell from 'Components/Table/Cells/TableRowCell.css';
text-align: center; text-align: center;
} }
.label {
composes: label from 'Components/Label.css';
pointer-events: none;
}

@ -238,6 +238,7 @@ class InteractiveImportRow extends Component {
onPress={this.onSelectQualityPress} onPress={this.onSelectQualityPress}
> >
<EpisodeQuality <EpisodeQuality
className={styles.label}
quality={quality} quality={quality}
/> />
</TableRowCellButton> </TableRowCellButton>
@ -247,6 +248,7 @@ class InteractiveImportRow extends Component {
onPress={this.onSelectLanguagePress} onPress={this.onSelectLanguagePress}
> >
<EpisodeLanguage <EpisodeLanguage
className={styles.label}
language={language} language={language}
/> />
</TableRowCellButton> </TableRowCellButton>

@ -70,10 +70,10 @@ class SelectQualityModalContent extends Component {
real real
} = this.state; } = this.state;
const qualityOptions = items.map(({ quality }) => { const qualityOptions = items.map(({ id, name }) => {
return { return {
key: quality.id, key: id,
value: quality.name value: name
}; };
}); });

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import getQualities from 'Utilities/Quality/getQualities';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import SelectQualityModalContent from './SelectQualityModalContent'; import SelectQualityModalContent from './SelectQualityModalContent';
@ -22,7 +23,7 @@ function createMapStateToProps() {
isFetching, isFetching,
isPopulated, isPopulated,
error, error,
items: schema.items || [] items: getQualities(schema.items)
}; };
} }
); );

@ -90,6 +90,15 @@ class NamingModal extends Component {
{ token: '{Album_CleanTitle}', example: 'Album_Title' } { token: '{Album_CleanTitle}', example: 'Album_Title' }
]; ];
const mediumTokens = [
{ token: '{medium:0}', example: '1' },
{ token: '{medium:00}', example: '01' }
];
const mediumFormatTokens = [
{ token: '{Medium Format}', example: 'CD' }
];
const trackTokens = [ const trackTokens = [
{ token: '{track:0}', example: '1' }, { token: '{track:0}', example: '1' },
{ token: '{track:00}', example: '01' } { token: '{track:00}', example: '01' }
@ -260,6 +269,48 @@ class NamingModal extends Component {
{ {
track && track &&
<div> <div>
<FieldSet legend="Medium">
<div className={styles.groups}>
{
mediumTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenCase={this.state.case}
onInputChange={onInputChange}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend="Medium Format">
<div className={styles.groups}>
{
mediumFormatTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenCase={this.state.case}
onInputChange={onInputChange}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend="Track"> <FieldSet legend="Track">
<div className={styles.groups}> <div className={styles.groups}>
{ {

@ -8,7 +8,7 @@ import LanguageProfileItem from './LanguageProfileItem';
import styles from './LanguageProfileItemDragPreview.css'; import styles from './LanguageProfileItemDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth); const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth); const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class LanguageProfileItemDragPreview extends Component {
// list item and the preview is wider than the drag handle. // list item and the preview is wider than the drag handle.
const { x, y } = currentOffset; const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth; const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = { const style = {

@ -1,20 +1,56 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector'; import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
function EditQualityProfileModal({ isOpen, onModalClose, ...otherProps }) { class EditQualityProfileModal extends Component {
return (
<Modal //
isOpen={isOpen} // Lifecycle
onModalClose={onModalClose}
> constructor(props, context) {
<EditQualityProfileModalContentConnector super(props, context);
{...otherProps}
this.state = {
height: 'auto'
};
}
//
// Listeners
onContentHeightChange = (height) => {
if (this.state.height === 'auto' || height > this.state.height) {
this.setState({ height });
}
}
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
style={{ height: `${this.state.height}px` }}
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose} onModalClose={onModalClose}
/> >
</Modal> <EditQualityProfileModalContentConnector
); {...otherProps}
onContentHeightChange={this.onContentHeightChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
} }
EditQualityProfileModal.propTypes = { EditQualityProfileModal.propTypes = {

@ -1,3 +1,18 @@
.formGroupsContainer {
display: flex;
flex-wrap: wrap;
}
.formGroupWrapper {
flex: 0 0 calc($formGroupSmallWidth - 100px);
}
.deleteButtonContainer { .deleteButtonContainer {
margin-right: auto; margin-right: auto;
} }
@media only screen and (max-width: $breakpointLarge) {
.formGroupsContainer {
display: block;
}
}

@ -1,6 +1,8 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React, { Component } from 'react';
import { inputTypes, kinds } from 'Helpers/Props'; import Measure from 'react-measure';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -15,123 +17,223 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import QualityProfileItems from './QualityProfileItems'; import QualityProfileItems from './QualityProfileItems';
import styles from './EditQualityProfileModalContent.css'; import styles from './EditQualityProfileModalContent.css';
function EditQualityProfileModalContent(props) { const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
const {
isFetching, class EditQualityProfileModalContent extends Component {
error,
isSaving, //
saveError, // Lifecycle
qualities,
item, constructor(props, context) {
isInUse, super(props, context);
onInputChange,
onCutoffChange, this.state = {
onSavePress, headerHeight: 0,
onModalClose, bodyHeight: 0,
onDeleteQualityProfilePress, footerHeight: 0
...otherProps };
} = props; }
const { componentDidUpdate(prevProps, prevState) {
id, const {
name, headerHeight,
cutoff, bodyHeight,
items footerHeight
} = item; } = this.state;
return ( if (
<ModalContent onModalClose={onModalClose}> headerHeight > 0 &&
<ModalHeader> bodyHeight > 0 &&
{id ? 'Edit Quality Profile' : 'Add Quality Profile'} footerHeight > 0 &&
</ModalHeader> (
headerHeight !== prevState.headerHeight ||
<ModalBody> bodyHeight !== prevState.bodyHeight ||
{ footerHeight !== prevState.footerHeight
isFetching && )
<LoadingIndicator /> ) {
} const padding = MODAL_BODY_PADDING * 2;
{ this.props.onContentHeightChange(
!isFetching && !!error && headerHeight + bodyHeight + footerHeight + padding
<div>Unable to add a new quality profile, please try again.</div> );
} }
}
{
!isFetching && !error && //
<Form // Listeners
{...otherProps}
> onHeaderMeasure = ({ height }) => {
<FormGroup> if (height > this.state.headerHeight) {
<FormLabel>Name</FormLabel> this.setState({ headerHeight: height });
}
<FormInputGroup }
type={inputTypes.TEXT}
name="name" onBodyMeasure = ({ height }) => {
{...name}
onChange={onInputChange} if (height > this.state.bodyHeight) {
/> this.setState({ bodyHeight: height });
</FormGroup> }
}
<FormGroup>
<FormLabel>Cutoff</FormLabel> onFooterMeasure = ({ height }) => {
if (height > this.state.footerHeight) {
<FormInputGroup this.setState({ footerHeight: height });
type={inputTypes.SELECT} }
name="cutoff" }
{...cutoff}
value={cutoff ? cutoff.value.id : 0} //
values={qualities} // Render
helpText="Once this quality is reached Lidarr will no longer download episodes"
onChange={onCutoffChange}
/>
</FormGroup>
<QualityProfileItems
qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
{...otherProps}
/>
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<div
className={styles.deleteButtonContainer}
title={isInUse && 'Can\'t delete a quality profile that is attached to a artist'}
>
<Button
kind={kinds.DANGER}
isDisabled={isInUse}
onPress={onDeleteQualityProfilePress}
>
Delete
</Button>
</div>
}
<Button render() {
onPress={onModalClose} const {
editGroups,
isFetching,
error,
isSaving,
saveError,
qualities,
item,
isInUse,
onInputChange,
onCutoffChange,
onSavePress,
onModalClose,
onDeleteQualityProfilePress,
...otherProps
} = this.props;
const {
id,
name,
cutoff,
items
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onHeaderMeasure}
> >
Cancel <ModalHeader>
</Button> {id ? 'Edit Quality Profile' : 'Add Quality Profile'}
</ModalHeader>
</Measure>
<ModalBody>
<Measure
whitelist={['height']}
onMeasure={this.onBodyMeasure}
>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new quality profile, please try again.</div>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<div className={styles.formGroupsContainer}>
<div className={styles.formGroupWrapper}>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.small}>
Name
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.small}>
Cutoff
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
values={qualities}
helpText="Once this quality is reached Sonarr will no longer download episodes"
onChange={onCutoffChange}
/>
</FormGroup>
</div>
<SpinnerErrorButton <div className={styles.formGroupWrapper}>
isSpinning={isSaving} <QualityProfileItems
error={saveError} editGroups={editGroups}
onPress={onSavePress} qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
{...otherProps}
/>
</div>
</div>
</Form>
}
</div>
</Measure>
</ModalBody>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onFooterMeasure}
> >
Save <ModalFooter>
</SpinnerErrorButton> {
</ModalFooter> id &&
</ModalContent> <div
); className={styles.deleteButtonContainer}
title={isInUse && 'Can\'t delete a quality profile that is attached to a series'}
>
<Button
kind={kinds.DANGER}
isDisabled={isInUse}
onPress={onDeleteQualityProfilePress}
>
Delete
</Button>
</div>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</Measure>
</ModalContent>
);
}
} }
EditQualityProfileModalContent.propTypes = { EditQualityProfileModalContent.propTypes = {
editGroups: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
@ -142,6 +244,7 @@ EditQualityProfileModalContent.propTypes = {
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onCutoffChange: PropTypes.func.isRequired, onCutoffChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired,
onContentHeightChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired,
onDeleteQualityProfilePress: PropTypes.func onDeleteQualityProfilePress: PropTypes.func
}; };

@ -8,6 +8,29 @@ import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile }
import connectSection from 'Store/connectSection'; import connectSection from 'Store/connectSection';
import EditQualityProfileModalContent from './EditQualityProfileModalContent'; import EditQualityProfileModalContent from './EditQualityProfileModalContent';
function getQualityItemGroupId(qualityProfile) {
// Get items with an `id` and filter out null/undefined values
const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
return Math.max(1000, ...ids) + 1;
}
function parseIndex(index) {
const split = index.split('.');
if (split.length === 1) {
return [
null,
parseInt(split[0]) - 1
];
}
return [
parseInt(split[0]) - 1,
parseInt(split[1]) - 1
];
}
function createQualitiesSelector() { function createQualitiesSelector() {
return createSelector( return createSelector(
createProviderSettingsSelector(), createProviderSettingsSelector(),
@ -17,12 +40,19 @@ function createQualitiesSelector() {
return []; return [];
} }
return _.reduceRight(items.value, (result, { allowed, quality }) => { return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
if (allowed) { if (allowed) {
result.push({ if (id) {
key: quality.id, result.push({
value: quality.name key: id,
}); value: name
});
} else {
result.push({
key: quality.id,
value: quality.name
});
}
} }
return result; return result;
@ -61,8 +91,10 @@ class EditQualityProfileModalContentConnector extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
dragIndex: null, dragQualityIndex: null,
dropIndex: null dropQualityIndex: null,
dropPosition: null,
editGroups: true
}; };
} }
@ -78,6 +110,33 @@ class EditQualityProfileModalContentConnector extends Component {
} }
} }
//
// Control
ensureCutoff = (qualityProfile) => {
const cutoff = qualityProfile.cutoff.value;
const cutoffItem = _.find(qualityProfile.items.value, (i) => {
if (!cutoff) {
return false;
}
return i.id === cutoff || (i.quality && i.quality.id === cutoff);
});
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
let cutoffId = null;
if (firstAllowed) {
cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
}
this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
}
}
// //
// Listeners // Listeners
@ -87,9 +146,17 @@ class EditQualityProfileModalContentConnector extends Component {
onCutoffChange = ({ name, value }) => { onCutoffChange = ({ name, value }) => {
const id = parseInt(value); const id = parseInt(value);
const item = _.find(this.props.item.items.value, (i) => i.quality.id === id); const item = _.find(this.props.item.items.value, (i) => {
if (i.quality) {
return i.quality.id === id;
}
return i.id === id;
});
this.props.setQualityProfileValue({ name, value: item.quality }); const cutoffId = item.quality ? item.quality.id : item.id;
this.props.setQualityProfileValue({ name, value: cutoffId });
} }
onSavePress = () => { onSavePress = () => {
@ -98,58 +165,239 @@ class EditQualityProfileModalContentConnector extends Component {
onQualityProfileItemAllowedChange = (id, allowed) => { onQualityProfileItemAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item); const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
const item = _.find(qualityProfile.items.value, (i) => i.quality.id === id);
item.allowed = allowed; item.allowed = allowed;
this.props.setQualityProfileValue({ this.props.setQualityProfileValue({
name: 'items', name: 'items',
value: qualityProfile.items.value value: items
}); });
const cutoff = qualityProfile.cutoff.value; this.ensureCutoff(qualityProfile);
}
// If the cutoff isn't allowed anymore or there isn't a cutoff set one onItemGroupAllowedChange = (id, allowed) => {
if (!cutoff || !_.find(qualityProfile.items.value, (i) => i.quality.id === cutoff.id).allowed) { const qualityProfile = _.cloneDeep(this.props.item);
const firstAllowed = _.find(qualityProfile.items.value, { allowed: true }); const items = qualityProfile.items.value;
const item = _.find(qualityProfile.items.value, (i) => i.id === id);
this.props.setQualityProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.quality : null }); item.allowed = allowed;
}
// Update each item in the group (for consistency only)
item.items.forEach((i) => {
i.allowed = allowed;
});
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onItemGroupNameChange = (id, name) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const group = _.find(items, (i) => i.id === id);
group.name = name;
this.props.setQualityProfileValue({
name: 'items',
value: items
});
}
onCreateGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(items, (i) => i.quality && i.quality.id === id);
const index = items.indexOf(item);
const groupId = getQualityItemGroupId(qualityProfile);
const group = {
id: groupId,
name: item.quality.name,
allowed: item.allowed,
items: [
item
]
};
// Add the group in the same location the quality item was in.
items.splice(index, 1, group);
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onDeleteGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const group = _.find(items, (i) => i.id === id);
const index = items.indexOf(group);
// Add the items in the same location the group was in
items.splice(index, 1, ...group.items);
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
} }
onQualityProfileItemDragMove = (dragIndex, dropIndex) => { onQualityProfileItemDragMove = (options) => {
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { const {
dragQualityIndex,
dropQualityIndex,
dropPosition
} = options;
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
if (
(dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
(dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
) {
if (
this.state.dragQualityIndex != null &&
this.state.dropQualityIndex != null &&
this.state.dropPosition != null
) {
this.setState({
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null
});
}
return;
}
let adjustedDropQualityIndex = dropQualityIndex;
// Correct dragging out of a group to the position above
if (
dropPosition === 'above' &&
dragGroupIndex !== dropGroupIndex &&
dropGroupIndex != null
) {
// Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
}
// Correct inserting above outside a group
if (
dropPosition === 'above' &&
dragGroupIndex !== dropGroupIndex &&
dropGroupIndex == null
) {
// Add 2 to the item index so it's entered in the correct place
adjustedDropQualityIndex = `${dropItemIndex + 2}`;
}
// Correct inserting below a quality within the same group (when moving a lower item)
if (
dropPosition === 'below' &&
dragGroupIndex === dropGroupIndex &&
dropGroupIndex != null &&
dragItemIndex < dropItemIndex
) {
// Add 1 to the group index leave the item index
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
}
// Correct inserting below a quality outside a group (when moving a lower item)
if (
dropPosition === 'below' &&
dragGroupIndex === dropGroupIndex &&
dropGroupIndex == null &&
dragItemIndex < dropItemIndex
) {
// Leave the item index so it's inserted below the item
adjustedDropQualityIndex = `${dropItemIndex}`;
}
if (
dragQualityIndex !== this.state.dragQualityIndex ||
adjustedDropQualityIndex !== this.state.dropQualityIndex ||
dropPosition !== this.state.dropPosition
) {
this.setState({ this.setState({
dragIndex, dragQualityIndex,
dropIndex dropQualityIndex: adjustedDropQualityIndex,
dropPosition
}); });
} }
} }
onQualityProfileItemDragEnd = ({ id }, didDrop) => { onQualityProfileItemDragEnd = (didDrop) => {
const { const {
dragIndex, dragQualityIndex,
dropIndex dropQualityIndex
} = this.state; } = this.state;
if (didDrop && dropIndex !== null) { if (didDrop && dropQualityIndex != null) {
const qualityProfile = _.cloneDeep(this.props.item); const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
const items = qualityProfile.items.value.splice(dragIndex, 1); let item = null;
qualityProfile.items.value.splice(dropIndex, 0, items[0]); let dropGroup = null;
// Get the group before moving anything so we know the correct place to drop it.
if (dropGroupIndex != null) {
dropGroup = items[dropGroupIndex];
}
if (dragGroupIndex == null) {
item = items.splice(dragItemIndex, 1)[0];
} else {
const group = items[dragGroupIndex];
item = group.items.splice(dragItemIndex, 1)[0];
// If the group is now empty, destroy it.
if (!group.items.length) {
items.splice(dragGroupIndex, 1);
}
}
if (dropGroupIndex == null) {
items.splice(dropItemIndex, 0, item);
} else {
dropGroup.items.splice(dropItemIndex, 0, item);
}
this.props.setQualityProfileValue({ this.props.setQualityProfileValue({
name: 'items', name: 'items',
value: qualityProfile.items.value value: items
}); });
this.ensureCutoff(qualityProfile);
} }
this.setState({ this.setState({
dragIndex: null, dragQualityIndex: null,
dropIndex: null dropQualityIndex: null,
dropPosition: null
}); });
} }
onToggleEditGroupsMode = () => {
this.setState({ editGroups: !this.state.editGroups });
}
// //
// Render // Render
@ -165,9 +413,14 @@ class EditQualityProfileModalContentConnector extends Component {
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
onInputChange={this.onInputChange} onInputChange={this.onInputChange}
onCutoffChange={this.onCutoffChange} onCutoffChange={this.onCutoffChange}
onCreateGroupPress={this.onCreateGroupPress}
onDeleteGroupPress={this.onDeleteGroupPress}
onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange} onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange}
onItemGroupAllowedChange={this.onItemGroupAllowedChange}
onItemGroupNameChange={this.onItemGroupNameChange}
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove} onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd} onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onToggleEditGroupsMode={this.onToggleEditGroupsMode}
/> />
); );
} }

@ -17,3 +17,10 @@
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 5px; margin-top: 5px;
} }
.tooltipLabel {
composes: label from 'Components/Label.css';
margin: 0;
border: none;
}

@ -1,9 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { kinds } from 'Helpers/Props'; import { kinds, tooltipPositions } from 'Helpers/Props';
import Card from 'Components/Card'; import Card from 'Components/Card';
import Label from 'Components/Label'; import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import Tooltip from 'Components/Tooltip/Tooltip';
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
import styles from './QualityProfile.css'; import styles from './QualityProfile.css';
@ -75,16 +76,54 @@ class QualityProfile extends Component {
return null; return null;
} }
const isCutoff = item.quality.id === cutoff.id; if (item.quality) {
const isCutoff = item.quality.id === cutoff;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{item.quality.name}
</Label>
);
}
const isCutoff = item.id === cutoff;
return ( return (
<Label <Tooltip
key={item.quality.id} key={item.id}
kind={isCutoff ? kinds.INFO : kinds.default} className={styles.tooltipLabel}
title={isCutoff ? 'Cutoff' : null} anchor={
> <Label
{item.quality.name} kind={isCutoff ? kinds.INFO : kinds.default}
</Label> title={isCutoff ? 'Cutoff' : null}
>
{item.name}
</Label>
}
tooltip={
<div>
{
item.items.map((groupItem) => {
return (
<Label
key={groupItem.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{groupItem.quality.name}
</Label>
);
})
}
</div>
}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
); );
}) })
} }
@ -115,7 +154,7 @@ class QualityProfile extends Component {
QualityProfile.propTypes = { QualityProfile.propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
cutoff: PropTypes.object.isRequired, cutoff: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired, isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteQualityProfile: PropTypes.func.isRequired onConfirmDeleteQualityProfile: PropTypes.func.isRequired

@ -5,25 +5,56 @@
border: 1px solid #aaa; border: 1px solid #aaa;
border-radius: 4px; border-radius: 4px;
background: #fafafa; background: #fafafa;
&.isInGroup {
border-style: dashed;
}
} }
.checkContainer { .checkInputContainer {
position: relative; position: relative;
margin-right: 4px; margin-right: 4px;
margin-bottom: 7px; margin-bottom: 5px;
margin-left: 8px; margin-left: 8px;
} }
.qualityName { .checkInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 5px;
}
.qualityNameContainer {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
margin-bottom: 0; margin-bottom: 0;
margin-left: 2px; margin-left: 2px;
font-weight: normal; font-weight: normal;
line-height: 36px; line-height: $qualityProfileItemHeight;
cursor: pointer; cursor: pointer;
} }
.qualityName {
&.isInGroup {
margin-left: 14px;
}
&.notAllowed {
color: #c6c6c6;
}
}
.createGroupButton {
composes: buton from 'Components/Link/IconButton.css';
display: flex;
justify-content: center;
flex-shrink: 0;
margin-right: 5px;
margin-left: 8px;
width: 20px;
}
.dragHandle { .dragHandle {
display: flex; display: flex;
align-items: center; align-items: center;
@ -42,3 +73,13 @@
.isDragging { .isDragging {
opacity: 0.25; opacity: 0.25;
} }
.isPreview {
.qualityName {
margin-left: 14px;
&.isInGroup {
margin-left: 28px;
}
}
}

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import CheckInput from 'Components/Form/CheckInput'; import CheckInput from 'Components/Form/CheckInput';
import styles from './QualityProfileItem.css'; import styles from './QualityProfileItem.css';
@ -20,14 +21,27 @@ class QualityProfileItem extends Component {
onQualityProfileItemAllowedChange(qualityId, value); onQualityProfileItemAllowedChange(qualityId, value);
} }
onCreateGroupPress = () => {
const {
qualityId,
onCreateGroupPress
} = this.props;
onCreateGroupPress(qualityId);
}
// //
// Render // Render
render() { render() {
const { const {
editGroups,
isPreview,
groupId,
name, name,
allowed, allowed,
isDragging, isDragging,
isOverCurrent,
connectDragSource connectDragSource
} = this.props; } = this.props;
@ -36,18 +50,44 @@ class QualityProfileItem extends Component {
className={classNames( className={classNames(
styles.qualityProfileItem, styles.qualityProfileItem,
isDragging && styles.isDragging, isDragging && styles.isDragging,
isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent,
groupId && styles.isInGroup
)} )}
> >
<label <label
className={styles.qualityName} className={styles.qualityNameContainer}
> >
<CheckInput {
containerClassName={styles.checkContainer} editGroups && !groupId && !isPreview &&
name={name} <IconButton
value={allowed} className={styles.createGroupButton}
onChange={this.onAllowedChange} name={icons.GROUP}
/> title="Group"
{name} onPress={this.onCreateGroupPress}
/>
}
{
!editGroups &&
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name={name}
value={allowed}
isDisabled={!!groupId}
onChange={this.onAllowedChange}
/>
}
<div className={classNames(
styles.qualityName,
groupId && styles.isInGroup,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</label> </label>
{ {
@ -55,6 +95,7 @@ class QualityProfileItem extends Component {
<div className={styles.dragHandle}> <div className={styles.dragHandle}>
<Icon <Icon
className={styles.dragIcon} className={styles.dragIcon}
title="Create group"
name={icons.REORDER} name={icons.REORDER}
/> />
</div> </div>
@ -66,16 +107,23 @@ class QualityProfileItem extends Component {
} }
QualityProfileItem.propTypes = { QualityProfileItem.propTypes = {
editGroups: PropTypes.bool,
isPreview: PropTypes.bool,
groupId: PropTypes.number,
qualityId: PropTypes.number.isRequired, qualityId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired, allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired,
isDragging: PropTypes.bool.isRequired, isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func, connectDragSource: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func onQualityProfileItemAllowedChange: PropTypes.func
}; };
QualityProfileItem.defaultProps = { QualityProfileItem.defaultProps = {
isPreview: false,
isOverCurrent: false,
// The drag preview will not connect the drag handle. // The drag preview will not connect the drag handle.
connectDragSource: (node) => node connectDragSource: (node) => node
}; };

@ -7,8 +7,8 @@ import DragPreviewLayer from 'Components/DragPreviewLayer';
import QualityProfileItem from './QualityProfileItem'; import QualityProfileItem from './QualityProfileItem';
import styles from './QualityProfileItemDragPreview.css'; import styles from './QualityProfileItemDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth); const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth); const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class QualityProfileItemDragPreview extends Component {
// list item and the preview is wider than the drag handle. // list item and the preview is wider than the drag handle.
const { x, y } = currentOffset; const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth; const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = { const style = {
@ -51,12 +51,15 @@ class QualityProfileItemDragPreview extends Component {
}; };
const { const {
editGroups,
groupId,
qualityId, qualityId,
name, name,
allowed, allowed
sortIndex
} = item; } = item;
// TODO: Show a different preview for groups
return ( return (
<DragPreviewLayer> <DragPreviewLayer>
<div <div
@ -64,10 +67,11 @@ class QualityProfileItemDragPreview extends Component {
style={style} style={style}
> >
<QualityProfileItem <QualityProfileItem
qualityId={qualityId} editGroups={editGroups}
isPreview={true}
qualityId={groupId || qualityId}
name={name} name={name}
allowed={allowed} allowed={allowed}
sortIndex={sortIndex}
isDragging={false} isDragging={false}
/> />
</div> </div>

@ -1,10 +1,10 @@
.qualityProfileItemDragSource { .qualityProfileItemDragSource {
padding: 4px 0; padding: $qualityProfileItemDragSourcePadding 0;
} }
.qualityProfileItemPlaceholder { .qualityProfileItemPlaceholder {
width: 100%; width: 100%;
height: 36px; height: $qualityProfileItemHeight;
border: 1px dotted #aaa; border: 1px dotted #aaa;
border-radius: 4px; border-radius: 4px;
} }

@ -5,44 +5,86 @@ import { DragSource, DropTarget } from 'react-dnd';
import classNames from 'classnames'; import classNames from 'classnames';
import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
import QualityProfileItem from './QualityProfileItem'; import QualityProfileItem from './QualityProfileItem';
import QualityProfileItemGroup from './QualityProfileItemGroup';
import styles from './QualityProfileItemDragSource.css'; import styles from './QualityProfileItemDragSource.css';
const qualityProfileItemDragSource = { const qualityProfileItemDragSource = {
beginDrag({ qualityId, name, allowed, sortIndex }) { beginDrag(props) {
const {
editGroups,
qualityIndex,
groupId,
qualityId,
name,
allowed
} = props;
return { return {
editGroups,
qualityIndex,
groupId,
qualityId, qualityId,
isGroup: !qualityId,
name, name,
allowed, allowed
sortIndex
}; };
}, },
endDrag(props, monitor, component) { endDrag(props, monitor, component) {
props.onQualityProfileItemDragEnd(monitor.getItem(), monitor.didDrop()); props.onQualityProfileItemDragEnd(monitor.didDrop());
} }
}; };
const qualityProfileItemDropTarget = { const qualityProfileItemDropTarget = {
hover(props, monitor, component) { hover(props, monitor, component) {
const dragIndex = monitor.getItem().sortIndex; const {
const hoverIndex = props.sortIndex; qualityIndex: dragQualityIndex,
isGroup: isDragGroup
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); } = monitor.getItem();
const dropQualityIndex = props.qualityIndex;
const isDropGroupItem = !!(props.qualityId && props.groupId);
// Use childNodeIndex to select the correct node to get the middle of so
// we don't bounce between above and below causing rapid setState calls.
const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0;
const componentDOMNode = findDOMNode(component).children[childNodeIndex];
const hoverBoundingRect = componentDOMNode.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset(); const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top; const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Moving up, only trigger if drag position is above 50% // If we're hovering over a child don't trigger on the parent
if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) { if (!monitor.isOver({ shallow: true })) {
return;
}
// Don't show targets for dropping on self
if (dragQualityIndex === dropQualityIndex) {
return;
}
// Don't allow a group to be dropped inside a group
if (isDragGroup && isDropGroupItem) {
return; return;
} }
// Moving down, only trigger if drag position is below 50% let dropPosition = null;
if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
// Determine drop position based on position over target
if (hoverClientY > hoverMiddleY) {
dropPosition = 'below';
} else if (hoverClientY < hoverMiddleY) {
dropPosition = 'above';
} else {
return; return;
} }
props.onQualityProfileItemDragMove(dragIndex, hoverIndex); props.onQualityProfileItemDragMove({
dragQualityIndex,
dropQualityIndex,
dropPosition
});
} }
}; };
@ -56,7 +98,8 @@ function collectDragSource(connect, monitor) {
function collectDropTarget(connect, monitor) { function collectDropTarget(connect, monitor) {
return { return {
connectDropTarget: connect.dropTarget(), connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver() isOver: monitor.isOver(),
isOverCurrent: monitor.isOver({ shallow: true })
}; };
} }
@ -67,25 +110,30 @@ class QualityProfileItemDragSource extends Component {
render() { render() {
const { const {
editGroups,
groupId,
qualityId, qualityId,
name, name,
allowed, allowed,
sortIndex, items,
qualityIndex,
isDragging, isDragging,
isDraggingUp, isDraggingUp,
isDraggingDown, isDraggingDown,
isOver, isOverCurrent,
connectDragSource, connectDragSource,
connectDropTarget, connectDropTarget,
onQualityProfileItemAllowedChange onCreateGroupPress,
onDeleteGroupPress,
onQualityProfileItemAllowedChange,
onItemGroupAllowedChange,
onItemGroupNameChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
} = this.props; } = this.props;
const isBefore = !isDragging && isDraggingUp && isOver; const isBefore = !isDragging && isDraggingUp && isOverCurrent;
const isAfter = !isDragging && isDraggingDown && isOver; const isAfter = !isDragging && isDraggingDown && isOverCurrent;
// if (isDragging && !isOver) {
// return null;
// }
return connectDropTarget( return connectDropTarget(
<div <div
@ -105,16 +153,44 @@ class QualityProfileItemDragSource extends Component {
/> />
} }
<QualityProfileItem {
qualityId={qualityId} !!groupId && qualityId == null &&
name={name} <QualityProfileItemGroup
allowed={allowed} editGroups={editGroups}
sortIndex={sortIndex} groupId={groupId}
isDragging={isDragging} name={name}
isOver={isOver} allowed={allowed}
connectDragSource={connectDragSource} items={items}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange} qualityIndex={qualityIndex}
/> isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
connectDragSource={connectDragSource}
onDeleteGroupPress={onDeleteGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onItemGroupAllowedChange={onItemGroupAllowedChange}
onItemGroupNameChange={onItemGroupNameChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
/>
}
{
qualityId != null &&
<QualityProfileItem
editGroups={editGroups}
groupId={groupId}
qualityId={qualityId}
name={name}
allowed={allowed}
qualityIndex={qualityIndex}
isDragging={isDragging}
isOverCurrent={isOverCurrent}
connectDragSource={connectDragSource}
onCreateGroupPress={onCreateGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
/>
}
{ {
isAfter && isAfter &&
@ -131,17 +207,25 @@ class QualityProfileItemDragSource extends Component {
} }
QualityProfileItemDragSource.propTypes = { QualityProfileItemDragSource.propTypes = {
qualityId: PropTypes.number.isRequired, editGroups: PropTypes.bool.isRequired,
groupId: PropTypes.number,
qualityId: PropTypes.number,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired, allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object),
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool, isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool, isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool, isDraggingDown: PropTypes.bool,
isOver: PropTypes.bool, isOverCurrent: PropTypes.bool,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func, connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func, connectDropTarget: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onDeleteGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func.isRequired, onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
onItemGroupAllowedChange: PropTypes.func,
onItemGroupNameChange: PropTypes.func,
onQualityProfileItemDragMove: PropTypes.func.isRequired, onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired onQualityProfileItemDragEnd: PropTypes.func.isRequired
}; };

@ -0,0 +1,105 @@
.qualityProfileItemGroup {
width: 100%;
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
&.editGroups {
background: #fcfcfc;
}
}
.qualityProfileItemGroupInfo {
display: flex;
align-items: stretch;
width: 100%;
}
.checkInputContainer {
composes: checkInputContainer from './QualityProfileItem.css';
display: flex;
align-items: center;
}
.checkInput {
composes: checkInput from './QualityProfileItem.css';
}
.nameInput {
composes: text from 'Components/Form/TextInput.css';
margin-top: 4px;
margin-right: 10px;
}
.nameContainer {
display: flex;
align-items: center;
flex-grow: 1;
}
.name {
flex-shrink: 0;
&.notAllowed {
color: #c6c6c6;
}
}
.groupQualities {
display: flex;
justify-content: flex-end;
flex-grow: 1;
flex-wrap: wrap;
margin: 2px 0 2px 10px;
}
.qualityNameContainer {
display: flex;
align-items: stretch;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
}
.qualityNameLabel {
composes: qualityNameContainer;
cursor: pointer;
}
.deleteGroupButton {
composes: buton from 'Components/Link/IconButton.css';
display: flex;
justify-content: center;
flex-shrink: 0;
margin-right: 5px;
margin-left: 8px;
width: 20px;
}
.dragHandle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: $dragHandleWidth;
text-align: center;
cursor: grab;
}
.dragIcon {
top: 0;
}
.isDragging {
opacity: 0.25;
}
.items {
margin: 0 50px 0 35px;
}

@ -0,0 +1,200 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import CheckInput from 'Components/Form/CheckInput';
import TextInput from 'Components/Form/TextInput';
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
import styles from './QualityProfileItemGroup.css';
class QualityProfileItemGroup extends Component {
//
// Listeners
onAllowedChange = ({ value }) => {
const {
groupId,
onItemGroupAllowedChange
} = this.props;
onItemGroupAllowedChange(groupId, value);
}
onNameChange = ({ value }) => {
const {
groupId,
onItemGroupNameChange
} = this.props;
onItemGroupNameChange(groupId, value);
}
onDeleteGroupPress = ({ value }) => {
const {
groupId,
onDeleteGroupPress
} = this.props;
onDeleteGroupPress(groupId, value);
}
//
// Render
render() {
const {
editGroups,
groupId,
name,
allowed,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
connectDragSource,
onQualityProfileItemAllowedChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItemGroup,
editGroups && styles.editGroups,
isDragging && styles.isDragging,
)}
>
<div className={styles.qualityProfileItemGroupInfo}>
{
editGroups &&
<div className={styles.qualityNameContainer}>
<IconButton
className={styles.deleteGroupButton}
name={icons.UNGROUP}
title="Ungroup"
onPress={this.onDeleteGroupPress}
/>
<TextInput
className={styles.nameInput}
name="name"
value={name}
onChange={this.onNameChange}
/>
</div>
}
{
!editGroups &&
<label
className={styles.qualityNameLabel}
>
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name="allowed"
value={allowed}
onChange={this.onAllowedChange}
/>
<div className={styles.nameContainer}>
<div className={classNames(
styles.name,
!allowed && styles.notAllowed
)}
>
{name}
</div>
<div className={styles.groupQualities}>
{
items.map(({ quality }) => {
return (
<Label key={quality.id}>
{quality.name}
</Label>
);
}).reverse()
}
</div>
</div>
</label>
}
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
title="Reorder"
/>
</div>
)
}
</div>
{
editGroups &&
<div className={styles.items}>
{
items.map(({ quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
editGroups={editGroups}
groupId={groupId}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
items={items}
qualityIndex={`${qualityIndex}.${index + 1}`}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
isInGroup={true}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
/>
);
}).reverse()
}
</div>
}
</div>
);
}
}
QualityProfileItemGroup.propTypes = {
editGroups: PropTypes.bool,
groupId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool.isRequired,
isDraggingUp: PropTypes.bool.isRequired,
isDraggingDown: PropTypes.bool.isRequired,
connectDragSource: PropTypes.func,
onItemGroupAllowedChange: PropTypes.func.isRequired,
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
onItemGroupNameChange: PropTypes.func.isRequired,
onDeleteGroupPress: PropTypes.func.isRequired,
onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired
};
QualityProfileItemGroup.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default QualityProfileItemGroup;

@ -1,6 +1,15 @@
.editGroupsButton {
composes: button from 'Components/Link/Button.css';
margin-top: 10px;
}
.editGroupsButtonIcon {
margin-right: 8px;
}
.qualities { .qualities {
margin-top: 10px; margin-top: 10px;
/* TODO: This should consider the number of qualities in the list */ transition: min-height 200ms;
min-height: 550px;
user-select: none; user-select: none;
} }

@ -1,5 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Measure from 'react-measure';
import { icons, kinds, sizes } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import FormInputHelpText from 'Components/Form/FormInputHelpText'; import FormInputHelpText from 'Components/Form/FormInputHelpText';
@ -9,26 +13,69 @@ import styles from './QualityProfileItems.css';
class QualityProfileItems extends Component { class QualityProfileItems extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
qualitiesHeight: 0,
qualitiesHeightEditGroups: 0
};
}
componentDidMount() {
this.props.onToggleEditGroupsMode();
}
//
// Listeners
onMeasure = ({ height }) => {
if (this.props.editGroups) {
this.setState({
qualitiesHeightEditGroups: height
});
} else {
this.setState({ qualitiesHeight: height });
}
}
onToggleEditGroupsMode = () => {
this.props.onToggleEditGroupsMode();
}
// //
// Render // Render
render() { render() {
const { const {
dragIndex, editGroups,
dropIndex, dropQualityIndex,
dropPosition,
qualityProfileItems, qualityProfileItems,
errors, errors,
warnings, warnings,
...otherProps ...otherProps
} = this.props; } = this.props;
const isDragging = dropIndex !== null; const {
const isDraggingUp = isDragging && dropIndex > dragIndex; qualitiesHeight,
const isDraggingDown = isDragging && dropIndex < dragIndex; qualitiesHeightEditGroups
} = this.state;
const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
return ( return (
<FormGroup> <FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel>Qualities</FormLabel> <FormLabel size={sizes.SMALL}>
Qualities
</FormLabel>
<div> <div>
<FormInputHelpText <FormInputHelpText
text="Qualities higher in the list are more preferred. Only checked qualities are wanted" text="Qualities higher in the list are more preferred. Only checked qualities are wanted"
@ -60,27 +107,59 @@ class QualityProfileItems extends Component {
}) })
} }
<div className={styles.qualities}> <Button
{ className={styles.editGroupsButton}
qualityProfileItems.map(({ allowed, quality }, index) => { kind={kinds.PRIMARY}
return ( onPress={this.onToggleEditGroupsMode}
<QualityProfileItemDragSource >
key={quality.id} <div>
qualityId={quality.id} <Icon
name={quality.name} className={styles.editGroupsButtonIcon}
allowed={allowed} name={editGroups ? icons.REORDER : icons.GROUP}
sortIndex={index} />
isDragging={isDragging}
isDraggingUp={isDraggingUp} {
isDraggingDown={isDraggingDown} editGroups ? 'Done Editing Groups' : 'Edit Groups'
{...otherProps} }
/> </div>
); </Button>
}).reverse()
} <Measure
whitelist={['height']}
<QualityProfileItemDragPreview /> includeMargin={false}
</div> onMeasure={this.onMeasure}
>
<div
className={styles.qualities}
style={{ minHeight: `${minHeight}px` }}
>
{
qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => {
const identifier = quality ? quality.id : id;
return (
<QualityProfileItemDragSource
key={identifier}
editGroups={editGroups}
groupId={id}
qualityId={quality && quality.id}
name={quality ? quality.name : name}
allowed={allowed}
items={items}
qualityIndex={`${index + 1}`}
isInGroup={false}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
}).reverse()
}
<QualityProfileItemDragPreview />
</div>
</Measure>
</div> </div>
</FormGroup> </FormGroup>
); );
@ -88,11 +167,14 @@ class QualityProfileItems extends Component {
} }
QualityProfileItems.propTypes = { QualityProfileItems.propTypes = {
dragIndex: PropTypes.number, editGroups: PropTypes.bool.isRequired,
dropIndex: PropTypes.number, dragQualityIndex: PropTypes.string,
dropQualityIndex: PropTypes.string,
dropPosition: PropTypes.string,
qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object), errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object) warnings: PropTypes.arrayOf(PropTypes.object),
onToggleEditGroupsMode: PropTypes.func.isRequired
}; };
QualityProfileItems.defaultProps = { QualityProfileItems.defaultProps = {

@ -91,7 +91,6 @@ class QualityProfiles extends Component {
} }
QualityProfiles.propTypes = { QualityProfiles.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
isDeleting: PropTypes.bool.isRequired, isDeleting: PropTypes.bool.isRequired,

@ -7,11 +7,9 @@ import QualityProfiles from './QualityProfiles';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.settings.advancedSettings,
(state) => state.settings.qualityProfiles, (state) => state.settings.qualityProfiles,
(advancedSettings, qualityProfiles) => { (qualityProfiles) => {
return { return {
advancedSettings,
...qualityProfiles ...qualityProfiles
}; };
} }

@ -1,5 +1,5 @@
import $ from 'jquery';
import { batchActions } from 'redux-batched-actions'; import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, update, updateItem } from '../baseActions'; import { set, update, updateItem } from '../baseActions';
function createFetchHandler(section, url) { function createFetchHandler(section, url) {
@ -12,13 +12,13 @@ function createFetchHandler(section, url) {
...otherPayload ...otherPayload
} = payload; } = payload;
const promise = $.ajax({ const { request, abortRequest } = createAjaxRequest({
url: id == null ? url : `${url}/${id}`, url: id == null ? url : `${url}/${id}`,
data: otherPayload, data: otherPayload,
traditional: true traditional: true
}); });
promise.done((data) => { request.done((data) => {
dispatch(batchActions([ dispatch(batchActions([
id == null ? update({ section, data }) : updateItem({ section, ...data }), id == null ? update({ section, data }) : updateItem({ section, ...data }),
@ -31,14 +31,16 @@ function createFetchHandler(section, url) {
])); ]));
}); });
promise.fail((xhr) => { request.fail((xhr) => {
dispatch(set({ dispatch(set({
section, section,
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
error: xhr error: xhr.aborted ? null : xhr
})); }));
}); });
return abortRequest;
}; };
}; };
} }

@ -37,8 +37,8 @@ function createSaveProviderHandler(section, url, getFromState) {
ajaxOptions.method = 'PUT'; ajaxOptions.method = 'PUT';
} }
const { request, abortRequest } = createAjaxRequest()(ajaxOptions); const { request, abortRequest } = createAjaxRequest(ajaxOptions);
abortCurrentRequests[section] = abortRequest; abortCurrentRequests[section] = abortRequest;
request.done((data) => { request.done((data) => {

@ -30,8 +30,8 @@ function createTestProviderHandler(section, url, getFromState) {
data: JSON.stringify(testData) data: JSON.stringify(testData)
}; };
const { request, abortRequest } = createAjaxRequest()(ajaxOptions); const { request, abortRequest } = createAjaxRequest(ajaxOptions);
abortCurrentRequests[section] = abortRequest; abortCurrentRequests[section] = abortRequest;
request.done((data) => { request.done((data) => {

@ -113,6 +113,7 @@ export const ALBUM_HISTORY_MARK_AS_FAILED = 'ALBUM_HISTORY_MARK_AS_FAILED';
// Releases // Releases
export const FETCH_RELEASES = 'FETCH_RELEASES'; export const FETCH_RELEASES = 'FETCH_RELEASES';
export const CANCEL_FETCH_RELEASES = 'CANCEL_FETCH_RELEASES';
export const SET_RELEASES_SORT = 'SET_RELEASES_SORT'; export const SET_RELEASES_SORT = 'SET_RELEASES_SORT';
export const CLEAR_RELEASES = 'CLEAR_RELEASES'; export const CLEAR_RELEASES = 'CLEAR_RELEASES';
export const GRAB_RELEASE = 'GRAB_RELEASE'; export const GRAB_RELEASE = 'GRAB_RELEASE';

@ -18,7 +18,7 @@ const addArtistActionHandlers = {
abortCurrentRequest(); abortCurrentRequest();
} }
const { request, abortRequest } = createAjaxRequest()({ const { request, abortRequest } = createAjaxRequest({
url: '/artist/lookup', url: '/artist/lookup',
data: { data: {
term: payload.term term: payload.term

@ -3,10 +3,27 @@ import createFetchHandler from './Creators/createFetchHandler';
import * as types from './actionTypes'; import * as types from './actionTypes';
import { updateRelease } from './releaseActions'; import { updateRelease } from './releaseActions';
let abortCurrentRequest = null;
const section = 'releases'; const section = 'releases';
const fetchReleases = createFetchHandler(section, '/release');
const releaseActionHandlers = { const releaseActionHandlers = {
[types.FETCH_RELEASES]: createFetchHandler(section, '/release'), [types.FETCH_RELEASES]: function(payload) {
return function(dispatch, getState) {
const abortRequest = fetchReleases(payload)(dispatch, getState);
abortCurrentRequest = abortRequest;
};
},
[types.CANCEL_FETCH_RELEASES]: function(payload) {
return function(dispatch, getState) {
if (abortCurrentRequest) {
abortCurrentRequest = abortCurrentRequest();
}
};
},
[types.GRAB_RELEASE]: function(payload) { [types.GRAB_RELEASE]: function(payload) {
return function(dispatch, getState) { return function(dispatch, getState) {

@ -3,6 +3,7 @@ import * as types from './actionTypes';
import releaseActionHandlers from './releaseActionHandlers'; import releaseActionHandlers from './releaseActionHandlers';
export const fetchReleases = releaseActionHandlers[types.FETCH_RELEASES]; export const fetchReleases = releaseActionHandlers[types.FETCH_RELEASES];
export const cancelFetchReleases = releaseActionHandlers[types.CANCEL_FETCH_RELEASES];
export const setReleasesSort = createAction(types.SET_RELEASES_SORT); export const setReleasesSort = createAction(types.SET_RELEASES_SORT);
export const clearReleases = createAction(types.CLEAR_RELEASES); export const clearReleases = createAction(types.CLEAR_RELEASES);
export const grabRelease = releaseActionHandlers[types.GRAB_RELEASE]; export const grabRelease = releaseActionHandlers[types.GRAB_RELEASE];

@ -37,6 +37,11 @@ export const defaultState = {
label: 'Release Date', label: 'Release Date',
isVisible: true isVisible: true
}, },
{
name: 'mediumCount',
label: 'Media Count',
isVisible: false
},
{ {
name: 'trackCount', name: 'trackCount',
label: 'Track Count', label: 'Track Count',

@ -11,14 +11,19 @@ export const defaultState = {
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
error: null, error: null,
sortKey: 'trackNumber', sortKey: 'mediumNumber',
sortDirection: sortDirections.DESCENDING, sortDirection: sortDirections.DESCENDING,
items: [], items: [],
columns: [ columns: [
{ {
name: 'trackNumber', name: 'medium',
label: '#', label: 'Medium',
isVisible: true
},
{
name: 'absoluteTrackNumber',
label: 'Track',
isVisible: true isVisible: true
}, },
{ {

@ -19,16 +19,21 @@ module.exports = {
breakpointSmall: '768px', breakpointSmall: '768px',
breakpointMedium: '992px', breakpointMedium: '992px',
breakpointLarge: '1200px', breakpointLarge: '1200px',
breakpointExtraLarge: '1450px',
// Form // Form
formGroupExtraSmallWidth: '550px',
formGroupSmallWidth: '650px', formGroupSmallWidth: '650px',
formGroupMediumWidth: '800px', formGroupMediumWidth: '800px',
formGroupLargeWidth: '1200px', formGroupLargeWidth: '1200px',
formLabelWidth: '250px', formLabelSmallWidth: '150px',
formLabelLargeWidth: '250px',
formLabelRightMarginWidth: '20px', formLabelRightMarginWidth: '20px',
// Drag // Drag
dragHandleWidth: '40px', dragHandleWidth: '40px',
qualityProfileItemHeight: '30px',
qualityProfileItemDragSourcePadding: '4px',
// Progress Bar // Progress Bar
progressBarSmallHeight: '5px', progressBarSmallHeight: '5px',
@ -38,6 +43,9 @@ module.exports = {
// Jump Bar // Jump Bar
jumpBarItemHeight: '25px', jumpBarItemHeight: '25px',
// Modal
modalBodyPadding: '30px',
// Artist // Artist
artistIndexColumnPadding: '20px', artistIndexColumnPadding: '20px',
artistIndexColumnPaddingSmallScreen: '10px', artistIndexColumnPaddingSmallScreen: '10px',

@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import getQualities from 'Utilities/Quality/getQualities';
import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector';
import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions'; import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
@ -52,8 +53,8 @@ function createMapStateToProps() {
}); });
const languages = _.map(languageProfilesSchema.languages, 'language'); const languages = _.map(languageProfilesSchema.languages, 'language');
const qualities = _.map(qualityProfileSchema.items, 'quality'); const qualities = getQualities(qualityProfileSchema.items);
return { return {
items, items,
artistType: artist.artistType, artistType: artist.artistType,
@ -90,18 +91,6 @@ function createMapDispatchToProps(dispatch, props) {
onDeletePress(trackFileIds) { onDeletePress(trackFileIds) {
dispatch(deleteTrackFiles({ trackFileIds })); dispatch(deleteTrackFiles({ trackFileIds }));
},
onQualityChange(trackFileIds, qualityId) {
const quality = {
quality: _.find(this.props.qualities, { id: qualityId }),
revision: {
version: 1,
real: 0
}
};
dispatch(updateTrackFiles({ trackFileIds, quality }));
} }
}; };
} }

@ -53,7 +53,7 @@ function TrackFileEditorRow(props) {
TrackFileEditorRow.propTypes = { TrackFileEditorRow.propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
trackNumber: PropTypes.number.isRequired, trackNumber: PropTypes.string.isRequired,
relativePath: PropTypes.string.isRequired, relativePath: PropTypes.string.isRequired,
language: PropTypes.object.isRequired, language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,

@ -0,0 +1,16 @@
export default function getQualities(qualities) {
if (!qualities) {
return [];
}
return qualities.reduce((acc, item) => {
if (item.quality) {
acc.push(item.quality);
} else {
const groupQualities = item.items.map((i) => i.quality);
acc.push(...groupQualities);
}
return acc;
}, []);
}

@ -1,32 +1,30 @@
import $ from 'jquery'; import $ from 'jquery';
export default function createAjaxRequest() { export default function createAjaxRequest(ajaxOptions) {
return function(ajaxOptions) { const requestXHR = new window.XMLHttpRequest();
const requestXHR = new window.XMLHttpRequest(); let aborted = false;
let aborted = false; let complete = false;
let complete = false;
function abortRequest() { function abortRequest() {
if (!complete) { if (!complete) {
aborted = true; aborted = true;
requestXHR.abort(); requestXHR.abort();
}
} }
}
const request = $.ajax({ const request = $.ajax({
xhr: () => requestXHR, xhr: () => requestXHR,
...ajaxOptions ...ajaxOptions
}).then(null, (xhr, textStatus, errorThrown) => { }).then(null, (xhr, textStatus, errorThrown) => {
xhr.aborted = aborted; xhr.aborted = aborted;
return $.Deferred().reject(xhr, textStatus, errorThrown).promise(); return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
}).always(() => { }).always(() => {
complete = true; complete = true;
}); });
return { return {
request, request,
abortRequest abortRequest
};
}; };
} }

@ -93,7 +93,6 @@
"react-tag-autocomplete": "5.4.1", "react-tag-autocomplete": "5.4.1",
"react-tether": "0.5.7", "react-tether": "0.5.7",
"react-text-truncate": "0.12.0", "react-text-truncate": "0.12.0",
"react-truncate": "2.2.2",
"react-virtualized": "9.10.1", "react-virtualized": "9.10.1",
"redux": "3.7.2", "redux": "3.7.2",
"redux-actions": "2.2.1", "redux-actions": "2.2.1",

@ -20,9 +20,22 @@ namespace Lidarr.Api.V1.Albums
public int ProfileId { get; set; } public int ProfileId { get; set; }
public int Duration { get; set; } public int Duration { get; set; }
public string AlbumType { get; set; } public string AlbumType { get; set; }
public int MediumCount
{
get
{
if (Media == null)
{
return 0;
}
return Media.Where(s => s.MediumNumber > 0).Count();
}
}
public Ratings Ratings { get; set; } public Ratings Ratings { get; set; }
public DateTime? ReleaseDate { get; set; } public DateTime? ReleaseDate { get; set; }
public List<string> Genres { get; set; } public List<string> Genres { get; set; }
public List<MediumResource> Media { get; set; }
public ArtistResource Artist { get; set; } public ArtistResource Artist { get; set; }
public List<MediaCover> Images { get; set; } public List<MediaCover> Images { get; set; }
public AlbumStatisticsResource Statistics { get; set; } public AlbumStatisticsResource Statistics { get; set; }
@ -32,7 +45,7 @@ namespace Lidarr.Api.V1.Albums
public bool Grabbed { get; set; } public bool Grabbed { get; set; }
} }
public static class EpisodeResourceMapper public static class AlbumResourceMapper
{ {
public static AlbumResource ToResource(this Album model) public static AlbumResource ToResource(this Album model)
{ {
@ -53,7 +66,8 @@ namespace Lidarr.Api.V1.Albums
Images = model.Images, Images = model.Images,
Ratings = model.Ratings, Ratings = model.Ratings,
Duration = model.Duration, Duration = model.Duration,
AlbumType = model.AlbumType AlbumType = model.AlbumType,
Media = model.Media.ToResource(),
}; };
} }

@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Music;
namespace Lidarr.Api.V1.Albums
{
public class MediumResource
{
public int MediumNumber { get; set; }
public string MediumName { get; set; }
public string MediumFormat { get; set; }
}
public static class SeasonResourceMapper
{
public static MediumResource ToResource(this Medium model)
{
if (model == null)
{
return null;
}
return new MediumResource
{
MediumNumber = model.Number,
MediumName = model.Name,
MediumFormat = model.Format
};
}
public static Medium ToModel(this MediumResource resource)
{
if (resource == null)
{
return null;
}
return new Medium
{
Number = resource.MediumNumber,
Name = resource.MediumName,
Format = resource.MediumFormat
};
}
public static List<MediumResource> ToResource(this IEnumerable<Medium> models)
{
return models.Select(ToResource).ToList();
}
public static List<Medium> ToModel(this IEnumerable<MediumResource> resources)
{
return resources.Select(ToModel).ToList();
}
}
}

@ -1,4 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using Nancy; using Nancy;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
@ -9,6 +11,7 @@ using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.Tracks; using Lidarr.Api.V1.Tracks;
using Lidarr.Http; using Lidarr.Http;
using Lidarr.Http.Extensions; using Lidarr.Http.Extensions;
using Lidarr.Http.REST;
namespace Lidarr.Api.V1.History namespace Lidarr.Api.V1.History
{ {
@ -27,6 +30,7 @@ namespace Lidarr.Api.V1.History
_failedDownloadService = failedDownloadService; _failedDownloadService = failedDownloadService;
GetResourcePaged = GetHistory; GetResourcePaged = GetHistory;
Get["/since"] = x => GetHistorySince();
Post["/failed"] = x => MarkAsFailed(); Post["/failed"] = x => MarkAsFailed();
} }
@ -81,6 +85,30 @@ namespace Lidarr.Api.V1.History
return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeArtist, includeAlbum, includeTrack)); return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeArtist, includeAlbum, includeTrack));
} }
private List<HistoryResource> GetHistorySince()
{
var queryDate = Request.Query.Date;
var queryEventType = Request.Query.EventType;
if (!queryDate.HasValue)
{
throw new BadRequestException("date is missing");
}
DateTime date = DateTime.Parse(queryDate.Value);
HistoryEventType? eventType = null;
var includeArtist = Request.GetBooleanQueryParameter("includeArtist");
var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum");
var includeTrack = Request.GetBooleanQueryParameter("includeTrack");
if (queryEventType.HasValue)
{
eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value);
}
return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList();
}
private Response MarkAsFailed() private Response MarkAsFailed()
{ {
var id = (int)Request.Form.Id; var id = (int)Request.Form.Id;

@ -89,6 +89,7 @@
<Compile Include="Albums\AlbumResource.cs" /> <Compile Include="Albums\AlbumResource.cs" />
<Compile Include="Albums\AlbumsMonitoredResource.cs" /> <Compile Include="Albums\AlbumsMonitoredResource.cs" />
<Compile Include="Albums\AlbumStatisticsResource.cs" /> <Compile Include="Albums\AlbumStatisticsResource.cs" />
<Compile Include="Albums\MediumResource.cs" />
<Compile Include="Blacklist\BlacklistModule.cs" /> <Compile Include="Blacklist\BlacklistModule.cs" />
<Compile Include="Blacklist\BlacklistResource.cs" /> <Compile Include="Blacklist\BlacklistResource.cs" />
<Compile Include="Calendar\CalendarFeedModule.cs" /> <Compile Include="Calendar\CalendarFeedModule.cs" />
@ -97,6 +98,8 @@
<Compile Include="Commands\CommandResource.cs" /> <Compile Include="Commands\CommandResource.cs" />
<Compile Include="Config\MetadataProviderConfigModule.cs" /> <Compile Include="Config\MetadataProviderConfigModule.cs" />
<Compile Include="Config\MetadataProviderConfigResource.cs" /> <Compile Include="Config\MetadataProviderConfigResource.cs" />
<Compile Include="Profiles\Quality\QualityCutoffValidator.cs" />
<Compile Include="Profiles\Quality\QualityItemsValidator.cs" />
<Compile Include="TrackFiles\TrackFileListResource.cs" /> <Compile Include="TrackFiles\TrackFileListResource.cs" />
<Compile Include="TrackFiles\MediaInfoResource.cs" /> <Compile Include="TrackFiles\MediaInfoResource.cs" />
<Compile Include="Indexers\ReleaseModuleBase.cs" /> <Compile Include="Indexers\ReleaseModuleBase.cs" />
@ -172,7 +175,6 @@
<Compile Include="Profiles\Quality\QualityProfileModule.cs" /> <Compile Include="Profiles\Quality\QualityProfileModule.cs" />
<Compile Include="Profiles\Quality\QualityProfileResource.cs" /> <Compile Include="Profiles\Quality\QualityProfileResource.cs" />
<Compile Include="Profiles\Quality\QualityProfileSchemaModule.cs" /> <Compile Include="Profiles\Quality\QualityProfileSchemaModule.cs" />
<Compile Include="Profiles\Quality\QualityProfileValidation.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="ProviderModuleBase.cs" /> <Compile Include="ProviderModuleBase.cs" />
<Compile Include="ProviderResource.cs" /> <Compile Include="ProviderResource.cs" />

@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Validators;
namespace Lidarr.Api.V1.Profiles.Quality
{
public static class QualityCutoffValidator
{
public static IRuleBuilderOptions<T, int> ValidCutoff<T>(this IRuleBuilder<T, int> ruleBuilder)
{
return ruleBuilder.SetValidator(new ValidCutoffValidator<T>());
}
}
public class ValidCutoffValidator<T> : PropertyValidator
{
public ValidCutoffValidator()
: base("Cutoff must be an allowed quality or group")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var cutoff = (int)context.PropertyValue;
dynamic instance = context.ParentContext.InstanceToValidate;
var items = instance.Items as IList<QualityProfileQualityItemResource>;
var cutoffItem = items.SingleOrDefault(i => i.Id == cutoff || (i.Quality != null && i.Quality.Id == cutoff));
if (cutoffItem == null)
{
return false;
}
if (!cutoffItem.Allowed)
{
return false;
}
return true;
}
}
}

@ -0,0 +1,197 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Validators;
using NzbDrone.Common.Extensions;
namespace Lidarr.Api.V1.Profiles.Quality
{
public static class QualityItemsValidator
{
public static IRuleBuilderOptions<T, IList<QualityProfileQualityItemResource>> ValidItems<T>(this IRuleBuilder<T, IList<QualityProfileQualityItemResource>> ruleBuilder)
{
ruleBuilder.SetValidator(new NotEmptyValidator(null));
ruleBuilder.SetValidator(new AllowedValidator<T>());
ruleBuilder.SetValidator(new QualityNameValidator<T>());
ruleBuilder.SetValidator(new EmptyItemGroupNameValidator<T>());
ruleBuilder.SetValidator(new ItemGroupIdValidator<T>());
ruleBuilder.SetValidator(new UniqueIdValidator<T>());
ruleBuilder.SetValidator(new UniqueQualityIdValidator<T>());
return ruleBuilder.SetValidator(new ItemGroupNameValidator<T>());
}
}
public class AllowedValidator<T> : PropertyValidator
{
public AllowedValidator()
: base("Must contain at least one allowed quality")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var list = context.PropertyValue as IList<QualityProfileQualityItemResource>;
if (list == null)
{
return false;
}
if (!list.Any(c => c.Allowed))
{
return false;
}
return true;
}
}
public class EmptyItemGroupNameValidator<T> : PropertyValidator
{
public EmptyItemGroupNameValidator()
: base("Groups must not be empty")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var items = context.PropertyValue as IList<QualityProfileQualityItemResource>;
if (items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Items.Empty()))
{
return false;
}
return true;
}
}
public class QualityNameValidator<T> : PropertyValidator
{
public QualityNameValidator()
: base("Individual qualities should not be named")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var items = context.PropertyValue as IList<QualityProfileQualityItemResource>;
if (items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Quality != null))
{
return false;
}
return true;
}
}
public class ItemGroupNameValidator<T> : PropertyValidator
{
public ItemGroupNameValidator()
: base("Groups must have a name")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var items = context.PropertyValue as IList<QualityProfileQualityItemResource>;
if (items.Any(i => i.Quality == null && i.Name.IsNullOrWhiteSpace()))
{
return false;
}
return true;
}
}
public class ItemGroupIdValidator<T> : PropertyValidator
{
public ItemGroupIdValidator()
: base("Groups must have an ID")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var items = context.PropertyValue as IList<QualityProfileQualityItemResource>;
if (items.Any(i => i.Quality == null && i.Id == 0))
{
return false;
}
return true;
}
}
public class UniqueIdValidator<T> : PropertyValidator
{
public UniqueIdValidator()
: base("Groups must have a unique ID")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var items = context.PropertyValue as IList<QualityProfileQualityItemResource>;
if (items.Where(i => i.Id > 0).Select(i => i.Id).GroupBy(i => i).Any(g => g.Count() > 1))
{
return false;
}
return true;
}
}
public class UniqueQualityIdValidator<T> : PropertyValidator
{
public UniqueQualityIdValidator()
: base("Qualities can only be used once")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var items = context.PropertyValue as IList<QualityProfileQualityItemResource>;
var qualityIds = new HashSet<int>();
foreach (var item in items)
{
if (item.Id > 0)
{
foreach (var quality in item.Items)
{
if (qualityIds.Contains(quality.Quality.Id))
{
return false;
}
qualityIds.Add(quality.Quality.Id);
}
}
else
{
if (qualityIds.Contains(item.Quality.Id))
{
return false;
}
qualityIds.Add(item.Quality.Id);
}
}
return true;
}
}
}

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using FluentValidation; using FluentValidation;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using Lidarr.Http; using Lidarr.Http;
@ -13,8 +13,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
{ {
_profileService = profileService; _profileService = profileService;
SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Cutoff).NotNull(); // TODO: Need to validate the cutoff is allowed and the ID/quality ID exists
SharedValidator.RuleFor(c => c.Items).MustHaveAllowedQuality(); // TODO: Need to validate the Items to ensure groups have names and at no item has no name, no items and no quality
SharedValidator.RuleFor(c => c.Cutoff).ValidCutoff();
SharedValidator.RuleFor(c => c.Items).ValidItems();
GetResourceAll = GetAll; GetResourceAll = GetAll;
GetResourceById = GetById; GetResourceById = GetById;
@ -52,4 +54,4 @@ namespace Lidarr.Api.V1.Profiles.Quality
return _profileService.All().ToResource(); return _profileService.All().ToResource();
} }
} }
} }

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using Lidarr.Http.REST; using Lidarr.Http.REST;
@ -8,14 +8,21 @@ namespace Lidarr.Api.V1.Profiles.Quality
public class QualityProfileResource : RestResource public class QualityProfileResource : RestResource
{ {
public string Name { get; set; } public string Name { get; set; }
public NzbDrone.Core.Qualities.Quality Cutoff { get; set; } public int Cutoff { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; } public List<QualityProfileQualityItemResource> Items { get; set; }
} }
public class QualityProfileQualityItemResource : RestResource public class QualityProfileQualityItemResource : RestResource
{ {
public string Name { get; set; }
public NzbDrone.Core.Qualities.Quality Quality { get; set; } public NzbDrone.Core.Qualities.Quality Quality { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; }
public bool Allowed { get; set; } public bool Allowed { get; set; }
public QualityProfileQualityItemResource()
{
Items = new List<QualityProfileQualityItemResource>();
}
} }
public static class ProfileResourceMapper public static class ProfileResourceMapper
@ -27,7 +34,6 @@ namespace Lidarr.Api.V1.Profiles.Quality
return new QualityProfileResource return new QualityProfileResource
{ {
Id = model.Id, Id = model.Id,
Name = model.Name, Name = model.Name,
Cutoff = model.Cutoff, Cutoff = model.Cutoff,
Items = model.Items.ConvertAll(ToResource), Items = model.Items.ConvertAll(ToResource),
@ -40,7 +46,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
return new QualityProfileQualityItemResource return new QualityProfileQualityItemResource
{ {
Id = model.Id,
Name = model.Name,
Quality = model.Quality, Quality = model.Quality,
Items = model.Items.ConvertAll(ToResource),
Allowed = model.Allowed Allowed = model.Allowed
}; };
} }
@ -52,9 +61,8 @@ namespace Lidarr.Api.V1.Profiles.Quality
return new Profile return new Profile
{ {
Id = resource.Id, Id = resource.Id,
Name = resource.Name, Name = resource.Name,
Cutoff = (NzbDrone.Core.Qualities.Quality)resource.Cutoff.Id, Cutoff = resource.Cutoff,
Items = resource.Items.ConvertAll(ToModel) Items = resource.Items.ConvertAll(ToModel)
}; };
} }
@ -65,7 +73,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
return new ProfileQualityItem return new ProfileQualityItem
{ {
Quality = (NzbDrone.Core.Qualities.Quality)resource.Quality.Id, Id = resource.Id,
Name = resource.Name,
Quality = resource.Quality != null ? (NzbDrone.Core.Qualities.Quality)resource.Quality.Id : null,
Items = resource.Items.ConvertAll(ToModel),
Allowed = resource.Allowed Allowed = resource.Allowed
}; };
} }
@ -75,4 +86,4 @@ namespace Lidarr.Api.V1.Profiles.Quality
return models.Select(ToResource).ToList(); return models.Select(ToResource).ToList();
} }
} }
} }

@ -1,34 +1,52 @@
using System.Linq; using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using Lidarr.Http; using Lidarr.Http;
namespace Lidarr.Api.V1.Profiles.Quality namespace Lidarr.Api.V1.Profiles.Quality
{ {
public class QualityProfileSchemaModule : LidarrRestModule<QualityProfileResource> public class QualityProfileSchemaModule : LidarrRestModule<QualityProfileResource>
{ {
private readonly IQualityDefinitionService _qualityDefinitionService; public QualityProfileSchemaModule()
public QualityProfileSchemaModule(IQualityDefinitionService qualityDefinitionService)
: base("/qualityprofile/schema") : base("/qualityprofile/schema")
{ {
_qualityDefinitionService = qualityDefinitionService;
GetResourceSingle = GetSchema; GetResourceSingle = GetSchema;
} }
private QualityProfileResource GetSchema() private QualityProfileResource GetSchema()
{ {
var items = _qualityDefinitionService.All() var groupedQualites = NzbDrone.Core.Qualities.Quality.DefaultQualityDefinitions.GroupBy(q => q.Weight);
.OrderBy(v => v.Weight) var items = new List<ProfileQualityItem>();
.Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = false }) var groupId = 1000;
.ToList();
foreach (var group in groupedQualites)
{
if (group.Count() == 1)
{
items.Add(new ProfileQualityItem { Quality = group.First().Quality, Allowed = false });
continue;
}
items.Add(new ProfileQualityItem
{
Id = groupId,
Name = group.First().GroupName,
Items = group.Select(g => new ProfileQualityItem
{
Quality = g.Quality,
Allowed = false
}).ToList(),
Allowed = false
});
groupId++;
}
var qualityProfile = new Profile(); var qualityProfile = new Profile();
qualityProfile.Cutoff = NzbDrone.Core.Qualities.Quality.Unknown; qualityProfile.Cutoff = NzbDrone.Core.Qualities.Quality.Unknown.Id;
qualityProfile.Items = items; qualityProfile.Items = items;
return qualityProfile.ToResource(); return qualityProfile.ToResource();
} }
} }
} }

@ -1,43 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Validators;
namespace Lidarr.Api.V1.Profiles.Quality
{
public static class QualityProfileValidation
{
public static IRuleBuilderOptions<T, IList<QualityProfileQualityItemResource>> MustHaveAllowedQuality<T>(this IRuleBuilder<T, IList<QualityProfileQualityItemResource>> ruleBuilder)
{
ruleBuilder.SetValidator(new NotEmptyValidator(null));
return ruleBuilder.SetValidator(new AllowedValidator<T>());
}
}
public class AllowedValidator<T> : PropertyValidator
{
public AllowedValidator()
: base("Must contain at least one allowed quality")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
var list = context.PropertyValue as IList<QualityProfileQualityItemResource>;
if (list == null)
{
return false;
}
if (!list.Any(c => c.Allowed))
{
return false;
}
return true;
}
}
}

@ -15,14 +15,15 @@ namespace Lidarr.Api.V1.Tracks
public int TrackFileId { get; set; } public int TrackFileId { get; set; }
public int AlbumId { get; set; } public int AlbumId { get; set; }
public bool Explicit { get; set; } public bool Explicit { get; set; }
public int TrackNumber { get; set; } public int AbsoluteTrackNumber { get; set; }
public string TrackNumber { get; set; }
public string Title { get; set; } public string Title { get; set; }
public int Duration { get; set; } public int Duration { get; set; }
public TrackFileResource TrackFile { get; set; } public TrackFileResource TrackFile { get; set; }
public int MediumNumber { get; set; }
public bool HasFile { get; set; } public bool HasFile { get; set; }
public bool Monitored { get; set; } public bool Monitored { get; set; }
//public string SeriesTitle { get; set; }
public ArtistResource Artist { get; set; } public ArtistResource Artist { get; set; }
public Ratings Ratings { get; set; } public Ratings Ratings { get; set; }
@ -45,16 +46,14 @@ namespace Lidarr.Api.V1.Tracks
TrackFileId = model.TrackFileId, TrackFileId = model.TrackFileId,
AlbumId = model.AlbumId, AlbumId = model.AlbumId,
Explicit = model.Explicit, Explicit = model.Explicit,
AbsoluteTrackNumber = model.AbsoluteTrackNumber,
TrackNumber = model.TrackNumber, TrackNumber = model.TrackNumber,
Title = model.Title, Title = model.Title,
Duration = model.Duration, Duration = model.Duration,
//EpisodeFile MediumNumber = model.MediumNumber,
HasFile = model.HasFile, HasFile = model.HasFile,
Monitored = model.Monitored, Monitored = model.Monitored,
Ratings = model.Ratings, Ratings = model.Ratings,
//SeriesTitle = model.SeriesTitle,
//Series = model.Series.MapToResource(),
}; };
} }

@ -22,7 +22,7 @@ namespace NzbDrone.Core.Test.Datastore
var profile = new Profile var profile = new Profile
{ {
Name = "Test", Name = "Test",
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}; };

@ -20,7 +20,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new Profile new Profile
{ {
Cutoff = Quality.MP3_256, Cutoff = Quality.MP3_256.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}, },
new LanguageProfile new LanguageProfile
@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.CutoffNotMet( Subject.CutoffNotMet(
new Profile new Profile
{ {
Cutoff = Quality.MP3_256, Cutoff = Quality.MP3_256.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}, },
new LanguageProfile new LanguageProfile
@ -55,7 +55,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new Profile new Profile
{ {
Cutoff = Quality.MP3_256, Cutoff = Quality.MP3_256.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}, },
new LanguageProfile new LanguageProfile
@ -73,7 +73,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new Profile new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}, },
new LanguageProfile new LanguageProfile
@ -93,7 +93,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new Profile new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}, },
new LanguageProfile new LanguageProfile
@ -111,7 +111,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Profile _profile = new Profile Profile _profile = new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
}; };
@ -134,7 +134,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Profile _profile = new Profile Profile _profile = new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
}; };
@ -158,7 +158,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Profile _profile = new Profile Profile _profile = new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
}; };
@ -182,7 +182,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Profile _profile = new Profile Profile _profile = new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
}; };
@ -206,7 +206,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Profile _profile = new Profile Profile _profile = new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
}; };

@ -47,7 +47,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
}; };
_fakeArtist = Builder<Artist>.CreateNew() _fakeArtist = Builder<Artist>.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() }) .With(c => c.Profile = new Profile { Cutoff = Quality.MP3_512.Id, Items = Qualities.QualityFixture.GetDefaultQualities() })
.With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = LanguageFixture.GetDefaultLanguages() }) .With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = LanguageFixture.GetDefaultLanguages() })
.Build(); .Build();
@ -162,7 +162,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_not_be_upgradable_if_album_is_of_same_quality_as_existing() public void should_not_be_upgradable_if_album_is_of_same_quality_as_existing()
{ {
_fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() }; _fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512.Id, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1)); _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
_upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.English); _upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.English);
@ -174,7 +174,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_not_be_upgradable_if_cutoff_already_met() public void should_not_be_upgradable_if_cutoff_already_met()
{ {
_fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() }; _fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512.Id, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1)); _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
_upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.Spanish); _upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.Spanish);
@ -202,7 +202,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void should_return_false_if_cutoff_already_met_and_cdh_is_disabled() public void should_return_false_if_cutoff_already_met_and_cdh_is_disabled()
{ {
GivenCdhDisabled(); GivenCdhDisabled();
_fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities() }; _fakeArtist.Profile = new Profile { Cutoff = Quality.MP3_512.Id, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1)); _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_512, new Revision(version: 1));
_upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.Spanish); _upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.MP3_512, new Revision(version: 1)), Language.Spanish);

@ -35,7 +35,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void Setup() public void Setup()
{ {
var fakeArtist = Builder<Artist>.CreateNew() var fakeArtist = Builder<Artist>.CreateNew()
.With(c => c.Profile = (LazyLoaded<Profile>)new Profile { Cutoff = Quality.MP3_512 }) .With(c => c.Profile = (LazyLoaded<Profile>)new Profile { Cutoff = Quality.MP3_512.Id })
.Build(); .Build();
remoteAlbum = new RemoteAlbum remoteAlbum = new RemoteAlbum

@ -79,7 +79,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var profile = new Profile var profile = new Profile
{ {
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
Cutoff = cutoff, Cutoff = cutoff.Id,
}; };
var langProfile = new LanguageProfile var langProfile = new LanguageProfile

@ -103,7 +103,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_return_true_when_quality_in_queue_is_lower() public void should_return_true_when_quality_in_queue_is_lower()
{ {
_artist.Profile.Value.Cutoff = Quality.MP3_512; _artist.Profile.Value.Cutoff = Quality.MP3_512.Id;
_artist.LanguageProfile.Value.Cutoff = Language.Spanish; _artist.LanguageProfile.Value.Cutoff = Language.Spanish;
var remoteAlbum = Builder<RemoteAlbum>.CreateNew() var remoteAlbum = Builder<RemoteAlbum>.CreateNew()
@ -123,7 +123,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_return_true_when_quality_in_queue_is_lower_but_language_is_higher() public void should_return_true_when_quality_in_queue_is_lower_but_language_is_higher()
{ {
_artist.Profile.Value.Cutoff = Quality.FLAC; _artist.Profile.Value.Cutoff = Quality.FLAC.Id;
_artist.LanguageProfile.Value.Cutoff = Language.Spanish; _artist.LanguageProfile.Value.Cutoff = Language.Spanish;
var remoteAlbum = Builder<RemoteAlbum>.CreateNew() var remoteAlbum = Builder<RemoteAlbum>.CreateNew()
@ -193,7 +193,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_return_false_when_quality_in_queue_is_better() public void should_return_false_when_quality_in_queue_is_better()
{ {
_artist.Profile.Value.Cutoff = Quality.MP3_512; _artist.Profile.Value.Cutoff = Quality.MP3_512.Id;
var remoteAlbum = Builder<RemoteAlbum>.CreateNew() var remoteAlbum = Builder<RemoteAlbum>.CreateNew()
.With(r => r.Artist = _artist) .With(r => r.Artist = _artist)
@ -289,7 +289,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_return_false_if_quality_and_language_in_queue_meets_cutoff() public void should_return_false_if_quality_and_language_in_queue_meets_cutoff()
{ {
_artist.Profile.Value.Cutoff = _remoteAlbum.ParsedAlbumInfo.Quality.Quality; _artist.Profile.Value.Cutoff = _remoteAlbum.ParsedAlbumInfo.Quality.Quality.Id;
var remoteAlbum = Builder<RemoteAlbum>.CreateNew() var remoteAlbum = Builder<RemoteAlbum>.CreateNew()
.With(r => r.Artist = _artist) .With(r => r.Artist = _artist)

@ -59,7 +59,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 });
_profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 });
_profile.Cutoff = Quality.MP3_320; _profile.Cutoff = Quality.MP3_320.Id;
_langProfile.Cutoff = Language.Spanish; _langProfile.Cutoff = Language.Spanish;
_langProfile.Languages = Languages.LanguageFixture.GetDefaultLanguages(); _langProfile.Languages = Languages.LanguageFixture.GetDefaultLanguages();

@ -60,7 +60,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
var secondTrack = new Track { TrackFile = _secondFile, TrackFileId = 2, AlbumId = 2 }; var secondTrack = new Track { TrackFile = _secondFile, TrackFileId = 2, AlbumId = 2 };
var fakeArtist = Builder<Artist>.CreateNew() var fakeArtist = Builder<Artist>.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.FLAC }) .With(c => c.Profile = new Profile { Cutoff = Quality.FLAC.Id })
.With(c => c.Path = @"C:\Music\My.Artist".AsOsAgnostic()) .With(c => c.Path = @"C:\Music\My.Artist".AsOsAgnostic())
.Build(); .Build();

@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
var fakeArtist = Builder<Artist>.CreateNew() var fakeArtist = Builder<Artist>.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.FLAC }) .With(c => c.Profile = new Profile { Cutoff = Quality.FLAC.Id })
.Build(); .Build();
Mocker.GetMock<IMediaFileService>() Mocker.GetMock<IMediaFileService>()

@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var languages = Languages.LanguageFixture.GetDefaultLanguages(Language.English, Language.Spanish); var languages = Languages.LanguageFixture.GetDefaultLanguages(Language.English, Language.Spanish);
var fakeArtist = Builder<Artist>.CreateNew() var fakeArtist = Builder<Artist>.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.MP3_512, Items = Qualities.QualityFixture.GetDefaultQualities()}) .With(c => c.Profile = new Profile { Cutoff = Quality.MP3_512.Id, Items = Qualities.QualityFixture.GetDefaultQualities()})
.With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = languages }) .With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = languages })
.Build(); .Build();

@ -39,7 +39,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
_profile = new Profile _profile = new Profile
{ {
Name = "Test", Name = "Test",
Cutoff = Quality.MP3_256, Cutoff = Quality.MP3_256.Id,
Items = new List<ProfileQualityItem> Items = new List<ProfileQualityItem>
{ {
new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 },

@ -39,7 +39,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
_profile = new Profile _profile = new Profile
{ {
Name = "Test", Name = "Test",
Cutoff = Quality.MP3_256, Cutoff = Quality.MP3_256.Id,
Items = new List<ProfileQualityItem> Items = new List<ProfileQualityItem>
{ {
new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 },

@ -41,7 +41,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
_profile = new Profile _profile = new Profile
{ {
Name = "Test", Name = "Test",
Cutoff = Quality.MP3_192, Cutoff = Quality.MP3_192.Id,
Items = new List<ProfileQualityItem> Items = new List<ProfileQualityItem>
{ {
new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_192 }, new ProfileQualityItem { Allowed = true, Quality = Quality.MP3_192 },

@ -30,14 +30,14 @@ namespace NzbDrone.Core.Test.HistoryTests
{ {
_profile = new Profile _profile = new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = QualityFixture.GetDefaultQualities(), Items = QualityFixture.GetDefaultQualities(),
}; };
_profileCustom = new Profile _profileCustom = new Profile
{ {
Cutoff = Quality.MP3_320, Cutoff = Quality.MP3_320.Id,
Items = QualityFixture.GetDefaultQualities(Quality.MP3_256), Items = QualityFixture.GetDefaultQualities(Quality.MP3_256),
}; };

@ -0,0 +1,53 @@
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using System.Linq;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Music;
using NzbDrone.Core.Profiles.Languages;
namespace NzbDrone.Core.Test.MusicTests.ArtistRepositoryTests
{
[TestFixture]
public class ArtistRepositoryFixture : DbTest<ArtistRepository, Artist>
{
[Test]
public void should_lazyload_quality_profile()
{
var profile = new Profile
{
Items = Qualities.QualityFixture.GetDefaultQualities(Quality.FLAC, Quality.MP3_192, Quality.MP3_320),
Cutoff = Quality.FLAC.Id,
Name = "TestProfile"
};
var langProfile = new LanguageProfile
{
Name = "TestProfile",
Languages = Languages.LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English
};
Mocker.Resolve<ProfileRepository>().Insert(profile);
Mocker.Resolve<LanguageProfileRepository>().Insert(langProfile);
var series = Builder<Artist>.CreateNew().BuildNew();
series.ProfileId = profile.Id;
series.LanguageProfileId = langProfile.Id;
Subject.Insert(series);
StoredModel.Profile.Should().NotBeNull();
StoredModel.LanguageProfile.Should().NotBeNull();
}
}
}

@ -270,11 +270,14 @@
<Compile Include="MetadataSource\SkyHook\SkyHookProxyFixture.cs" /> <Compile Include="MetadataSource\SkyHook\SkyHookProxyFixture.cs" />
<Compile Include="MusicTests\AddArtistFixture.cs" /> <Compile Include="MusicTests\AddArtistFixture.cs" />
<Compile Include="MusicTests\ArtistNameSlugValidatorFixture.cs" /> <Compile Include="MusicTests\ArtistNameSlugValidatorFixture.cs" />
<Compile Include="MusicTests\ArtistRepositoryTests\ArtistRepositoryFixture.cs" />
<Compile Include="NotificationTests\NotificationBaseFixture.cs" /> <Compile Include="NotificationTests\NotificationBaseFixture.cs" />
<Compile Include="NotificationTests\SynologyIndexerFixture.cs" /> <Compile Include="NotificationTests\SynologyIndexerFixture.cs" />
<Compile Include="OrganizerTests\FileNameBuilderTests\CleanTitleFixture.cs" /> <Compile Include="OrganizerTests\FileNameBuilderTests\CleanTitleFixture.cs" />
<Compile Include="OrganizerTests\FileNameBuilderTests\TitleTheFixture.cs" /> <Compile Include="OrganizerTests\FileNameBuilderTests\TitleTheFixture.cs" />
<Compile Include="ParserTests\MusicParserFixture.cs" /> <Compile Include="ParserTests\MusicParserFixture.cs" />
<Compile Include="Profiles\Delay\DelayProfileServiceFixture.cs" />
<Compile Include="Profiles\Qualities\QualityIndexCompareToFixture.cs" />
<Compile Include="Qualities\RevisionComparableFixture.cs" /> <Compile Include="Qualities\RevisionComparableFixture.cs" />
<Compile Include="QueueTests\QueueServiceFixture.cs" /> <Compile Include="QueueTests\QueueServiceFixture.cs" />
<Compile Include="RemotePathMappingsTests\RemotePathMappingServiceFixture.cs" /> <Compile Include="RemotePathMappingsTests\RemotePathMappingServiceFixture.cs" />

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save