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

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

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

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import titleCase from 'Utilities/String/titleCase';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
@ -14,6 +13,18 @@ import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConn
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
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 {
//
@ -89,7 +100,7 @@ class AlbumHistoryRow extends Component {
name={icons.INFO}
/>
}
title={titleCase(eventType)}
title={getTitle(eventType)}
body={
<HistoryDetailsConnector
eventType={eventType}

@ -15,7 +15,9 @@ function createMapStateToProps() {
createCommandsSelector(),
createDimensionsSelector(),
(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 {
network: episode.label,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -12,9 +12,9 @@ const messages = [
'Hum something loud while others stare',
'Loading humorous message... Please Wait',
'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.',
'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...',
'I\'ll be here all week',
'Don\'t forget to tip your waitress',

@ -51,6 +51,18 @@
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) {
.modal.large {
width: 90%;
@ -71,9 +83,10 @@
.modal.small,
.modal.medium,
.modal.large {
.modal.large,
.modal.extraLarge {
max-height: 100%;
width: 100%;
height: 100%;
height: 100% !important;
}
}

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

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

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

@ -11,6 +11,7 @@ import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
const {
formsAuth,
onKeyboardShortcutsPress,
onRestartPress,
onShutdownPress
} = props;
@ -25,6 +26,16 @@ function PageHeaderActionsMenu(props) {
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon
className={styles.itemIcon}
name={icons.KEYBOARD}
/>
Keyboard Shortcuts
</MenuItem>
<div className={styles.separator} />
<MenuItem onPress={onRestartPress}>
<Icon
className={styles.itemIcon}
@ -68,6 +79,7 @@ function PageHeaderActionsMenu(props) {
PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: 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() {
return createSelector(
(state) => state.app.isReconnecting,
@ -66,6 +74,7 @@ class SignalRConnector extends Component {
this.signalRconnection = null;
this.retryInterval = 5;
this.retryTimeoutId = null;
this.disconnectedTime = null;
}
componentDidMount() {
@ -90,7 +99,7 @@ class SignalRConnector extends Component {
// Control
retryConnection = () => {
if (this.retryInterval >= 30) {
if (isAppDisconnected(this.disconnectedTime)) {
this.setState({
isDisconnected: true
});
@ -290,6 +299,9 @@ class SignalRConnector extends Component {
console.log(`SignalR: ${state}`);
if (state === 'connected') {
// Clear disconnected time
this.disconnectedTime = null;
// Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting.
@ -322,6 +334,10 @@ class SignalRConnector extends Component {
return;
}
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.setAppValue({
isReconnecting: true
});
@ -332,11 +348,14 @@ class SignalRConnector extends Component {
return;
}
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.setAppValue({
isConnected: false,
isReconnecting: true
// Don't set isDisconnected yet, it'll be set it if it's disconnected
// for ~105 seconds (retry interval reaches 30 seconds)
isReconnecting: true,
isDisconnected: isAppDisconnected(this.disconnectedTime)
});
this.retryConnection();

@ -8,7 +8,7 @@ import TableOptionsColumn from './TableOptionsColumn';
import styles from './TableOptionsColumnDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth);
const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class TableOptionsColumnDragPreview extends Component {
// list item and the preview is wider than the drag handle.
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 style = {

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

@ -50,11 +50,17 @@ class Tooltip extends Component {
constructor(props, context) {
super(props, context);
this._closeTimeout = null;
this.state = {
isOpen: false
};
this._closeTimeout = null;
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
}
//
@ -83,6 +89,7 @@ class Tooltip extends Component {
render() {
const {
className,
anchor,
tooltip,
kind,
@ -97,6 +104,7 @@ class Tooltip extends Component {
{...tetherOptions[position]}
>
<span
className={className}
// onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
@ -137,6 +145,7 @@ class Tooltip extends Component {
}
Tooltip.propTypes = {
className: PropTypes.string,
anchor: PropTypes.node.isRequired,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
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 FOLDER = 'fa fa-folder-o';
export const FOLDER_OPEN = 'fa fa-folder-open';
export const GROUP = 'fa fa-object-group';
export const HEALTH = 'fa fa-medkit';
export const HEART = 'fa fa-heart';
export const HOUSEKEEPING = 'fa fa-home';
export const INFO = 'fa fa-info-circle';
export const INTERACTIVE = 'fa fa-user';
export const KEYBOARD = 'fa fa-keyboard-o';
export const LOGOUT = 'fa fa-sign-out';
export const MISSING = 'fa fa-exclamation-triangle';
export const MONITORED = 'fa fa-bookmark';
@ -82,6 +84,7 @@ export const SUBTRACT = 'fa fa-minus';
export const SYSTEM = 'fa fa-laptop';
export const TAGS = 'fa fa-tags';
export const TBA = 'fa fa-question-circle';
export const UNGROUP = 'fa fa-object-ungroup';
export const UNKNOWN = 'fa fa-question';
export const UNMONITORED = 'fa fa-bookmark-o';
export const UPDATE = 'fa fa-retweet';

@ -1,5 +1,7 @@
export const EXTRA_SMALL = 'extraSmall';
export const SMALL = 'small';
export const MEDIUM = 'medium';
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;
}
.quality {
.quality,
.language {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
text-align: center;
}
.label {
composes: label from 'Components/Label.css';
pointer-events: none;
}

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

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

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

@ -90,6 +90,15 @@ class NamingModal extends Component {
{ 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 = [
{ token: '{track:0}', example: '1' },
{ token: '{track:00}', example: '01' }
@ -260,6 +269,48 @@ class NamingModal extends Component {
{
track &&
<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">
<div className={styles.groups}>
{

@ -8,7 +8,7 @@ import LanguageProfileItem from './LanguageProfileItem';
import styles from './LanguageProfileItemDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth);
const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class LanguageProfileItemDragPreview extends Component {
// list item and the preview is wider than the drag handle.
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 style = {

@ -1,20 +1,56 @@
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 EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
function EditQualityProfileModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditQualityProfileModalContentConnector
{...otherProps}
class EditQualityProfileModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
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}
/>
</Modal>
);
>
<EditQualityProfileModalContentConnector
{...otherProps}
onContentHeightChange={this.onContentHeightChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
EditQualityProfileModal.propTypes = {

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

@ -1,6 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import React, { Component } from 'react';
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 SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -15,123 +17,223 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import QualityProfileItems from './QualityProfileItems';
import styles from './EditQualityProfileModalContent.css';
function EditQualityProfileModalContent(props) {
const {
isFetching,
error,
isSaving,
saveError,
qualities,
item,
isInUse,
onInputChange,
onCutoffChange,
onSavePress,
onModalClose,
onDeleteQualityProfilePress,
...otherProps
} = props;
const {
id,
name,
cutoff,
items
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Quality Profile' : 'Add Quality Profile'}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new quality profile, please try again.</div>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>Name</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Cutoff</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
value={cutoff ? cutoff.value.id : 0}
values={qualities}
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>
}
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
class EditQualityProfileModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
headerHeight: 0,
bodyHeight: 0,
footerHeight: 0
};
}
componentDidUpdate(prevProps, prevState) {
const {
headerHeight,
bodyHeight,
footerHeight
} = this.state;
if (
headerHeight > 0 &&
bodyHeight > 0 &&
footerHeight > 0 &&
(
headerHeight !== prevState.headerHeight ||
bodyHeight !== prevState.bodyHeight ||
footerHeight !== prevState.footerHeight
)
) {
const padding = MODAL_BODY_PADDING * 2;
this.props.onContentHeightChange(
headerHeight + bodyHeight + footerHeight + padding
);
}
}
//
// Listeners
onHeaderMeasure = ({ height }) => {
if (height > this.state.headerHeight) {
this.setState({ headerHeight: height });
}
}
onBodyMeasure = ({ height }) => {
if (height > this.state.bodyHeight) {
this.setState({ bodyHeight: height });
}
}
onFooterMeasure = ({ height }) => {
if (height > this.state.footerHeight) {
this.setState({ footerHeight: height });
}
}
//
// Render
<Button
onPress={onModalClose}
render() {
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
</Button>
<ModalHeader>
{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
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
<div className={styles.formGroupWrapper}>
<QualityProfileItems
editGroups={editGroups}
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
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
<ModalFooter>
{
id &&
<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 = {
editGroups: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
@ -142,6 +244,7 @@ EditQualityProfileModalContent.propTypes = {
onInputChange: PropTypes.func.isRequired,
onCutoffChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onContentHeightChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteQualityProfilePress: PropTypes.func
};

@ -8,6 +8,29 @@ import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile }
import connectSection from 'Store/connectSection';
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() {
return createSelector(
createProviderSettingsSelector(),
@ -17,12 +40,19 @@ function createQualitiesSelector() {
return [];
}
return _.reduceRight(items.value, (result, { allowed, quality }) => {
return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
if (allowed) {
result.push({
key: quality.id,
value: quality.name
});
if (id) {
result.push({
key: id,
value: name
});
} else {
result.push({
key: quality.id,
value: quality.name
});
}
}
return result;
@ -61,8 +91,10 @@ class EditQualityProfileModalContentConnector extends Component {
super(props, context);
this.state = {
dragIndex: null,
dropIndex: null
dragQualityIndex: 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
@ -87,9 +146,17 @@ class EditQualityProfileModalContentConnector extends Component {
onCutoffChange = ({ name, 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 = () => {
@ -98,58 +165,239 @@ class EditQualityProfileModalContentConnector extends Component {
onQualityProfileItemAllowedChange = (id, allowed) => {
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;
this.props.setQualityProfileValue({
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
if (!cutoff || !_.find(qualityProfile.items.value, (i) => i.quality.id === cutoff.id).allowed) {
const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
onItemGroupAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item);
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) => {
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
onQualityProfileItemDragMove = (options) => {
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({
dragIndex,
dropIndex
dragQualityIndex,
dropQualityIndex: adjustedDropQualityIndex,
dropPosition
});
}
}
onQualityProfileItemDragEnd = ({ id }, didDrop) => {
onQualityProfileItemDragEnd = (didDrop) => {
const {
dragIndex,
dropIndex
dragQualityIndex,
dropQualityIndex
} = this.state;
if (didDrop && dropIndex !== null) {
if (didDrop && dropQualityIndex != null) {
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);
qualityProfile.items.value.splice(dropIndex, 0, items[0]);
let item = null;
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({
name: 'items',
value: qualityProfile.items.value
value: items
});
this.ensureCutoff(qualityProfile);
}
this.setState({
dragIndex: null,
dropIndex: null
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null
});
}
onToggleEditGroupsMode = () => {
this.setState({ editGroups: !this.state.editGroups });
}
//
// Render
@ -165,9 +413,14 @@ class EditQualityProfileModalContentConnector extends Component {
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onCutoffChange={this.onCutoffChange}
onCreateGroupPress={this.onCreateGroupPress}
onDeleteGroupPress={this.onDeleteGroupPress}
onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange}
onItemGroupAllowedChange={this.onItemGroupAllowedChange}
onItemGroupNameChange={this.onItemGroupNameChange}
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onToggleEditGroupsMode={this.onToggleEditGroupsMode}
/>
);
}

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

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

@ -5,25 +5,56 @@
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
&.isInGroup {
border-style: dashed;
}
}
.checkContainer {
.checkInputContainer {
position: relative;
margin-right: 4px;
margin-bottom: 7px;
margin-bottom: 5px;
margin-left: 8px;
}
.qualityName {
.checkInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 5px;
}
.qualityNameContainer {
display: flex;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
line-height: 36px;
line-height: $qualityProfileItemHeight;
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 {
display: flex;
align-items: center;
@ -42,3 +73,13 @@
.isDragging {
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 { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import CheckInput from 'Components/Form/CheckInput';
import styles from './QualityProfileItem.css';
@ -20,14 +21,27 @@ class QualityProfileItem extends Component {
onQualityProfileItemAllowedChange(qualityId, value);
}
onCreateGroupPress = () => {
const {
qualityId,
onCreateGroupPress
} = this.props;
onCreateGroupPress(qualityId);
}
//
// Render
render() {
const {
editGroups,
isPreview,
groupId,
name,
allowed,
isDragging,
isOverCurrent,
connectDragSource
} = this.props;
@ -36,18 +50,44 @@ class QualityProfileItem extends Component {
className={classNames(
styles.qualityProfileItem,
isDragging && styles.isDragging,
isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent,
groupId && styles.isInGroup
)}
>
<label
className={styles.qualityName}
className={styles.qualityNameContainer}
>
<CheckInput
containerClassName={styles.checkContainer}
name={name}
value={allowed}
onChange={this.onAllowedChange}
/>
{name}
{
editGroups && !groupId && !isPreview &&
<IconButton
className={styles.createGroupButton}
name={icons.GROUP}
title="Group"
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>
{
@ -55,6 +95,7 @@ class QualityProfileItem extends Component {
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title="Create group"
name={icons.REORDER}
/>
</div>
@ -66,16 +107,23 @@ class QualityProfileItem extends Component {
}
QualityProfileItem.propTypes = {
editGroups: PropTypes.bool,
isPreview: PropTypes.bool,
groupId: PropTypes.number,
qualityId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired,
isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func
};
QualityProfileItem.defaultProps = {
isPreview: false,
isOverCurrent: false,
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};

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

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

@ -5,44 +5,86 @@ import { DragSource, DropTarget } from 'react-dnd';
import classNames from 'classnames';
import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
import QualityProfileItem from './QualityProfileItem';
import QualityProfileItemGroup from './QualityProfileItemGroup';
import styles from './QualityProfileItemDragSource.css';
const qualityProfileItemDragSource = {
beginDrag({ qualityId, name, allowed, sortIndex }) {
beginDrag(props) {
const {
editGroups,
qualityIndex,
groupId,
qualityId,
name,
allowed
} = props;
return {
editGroups,
qualityIndex,
groupId,
qualityId,
isGroup: !qualityId,
name,
allowed,
sortIndex
allowed
};
},
endDrag(props, monitor, component) {
props.onQualityProfileItemDragEnd(monitor.getItem(), monitor.didDrop());
props.onQualityProfileItemDragEnd(monitor.didDrop());
}
};
const qualityProfileItemDropTarget = {
hover(props, monitor, component) {
const dragIndex = monitor.getItem().sortIndex;
const hoverIndex = props.sortIndex;
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
const {
qualityIndex: dragQualityIndex,
isGroup: isDragGroup
} = 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 clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Moving up, only trigger if drag position is above 50%
if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
// If we're hovering over a child don't trigger on the parent
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;
}
// Moving down, only trigger if drag position is below 50%
if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
let dropPosition = null;
// Determine drop position based on position over target
if (hoverClientY > hoverMiddleY) {
dropPosition = 'below';
} else if (hoverClientY < hoverMiddleY) {
dropPosition = 'above';
} else {
return;
}
props.onQualityProfileItemDragMove(dragIndex, hoverIndex);
props.onQualityProfileItemDragMove({
dragQualityIndex,
dropQualityIndex,
dropPosition
});
}
};
@ -56,7 +98,8 @@ function collectDragSource(connect, monitor) {
function collectDropTarget(connect, monitor) {
return {
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver()
isOver: monitor.isOver(),
isOverCurrent: monitor.isOver({ shallow: true })
};
}
@ -67,25 +110,30 @@ class QualityProfileItemDragSource extends Component {
render() {
const {
editGroups,
groupId,
qualityId,
name,
allowed,
sortIndex,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
isOver,
isOverCurrent,
connectDragSource,
connectDropTarget,
onQualityProfileItemAllowedChange
onCreateGroupPress,
onDeleteGroupPress,
onQualityProfileItemAllowedChange,
onItemGroupAllowedChange,
onItemGroupNameChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
// if (isDragging && !isOver) {
// return null;
// }
const isBefore = !isDragging && isDraggingUp && isOverCurrent;
const isAfter = !isDragging && isDraggingDown && isOverCurrent;
return connectDropTarget(
<div
@ -105,16 +153,44 @@ class QualityProfileItemDragSource extends Component {
/>
}
<QualityProfileItem
qualityId={qualityId}
name={name}
allowed={allowed}
sortIndex={sortIndex}
isDragging={isDragging}
isOver={isOver}
connectDragSource={connectDragSource}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
/>
{
!!groupId && qualityId == null &&
<QualityProfileItemGroup
editGroups={editGroups}
groupId={groupId}
name={name}
allowed={allowed}
items={items}
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 &&
@ -131,17 +207,25 @@ class QualityProfileItemDragSource extends Component {
}
QualityProfileItemDragSource.propTypes = {
qualityId: PropTypes.number.isRequired,
editGroups: PropTypes.bool.isRequired,
groupId: PropTypes.number,
qualityId: PropTypes.number,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool,
isOver: PropTypes.bool,
isOverCurrent: PropTypes.bool,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onDeleteGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
onItemGroupAllowedChange: PropTypes.func,
onItemGroupNameChange: PropTypes.func,
onQualityProfileItemDragMove: 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 {
margin-top: 10px;
/* TODO: This should consider the number of qualities in the list */
min-height: 550px;
transition: min-height 200ms;
user-select: none;
}

@ -1,5 +1,9 @@
import PropTypes from 'prop-types';
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 FormLabel from 'Components/Form/FormLabel';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
@ -9,26 +13,69 @@ import styles from './QualityProfileItems.css';
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() {
const {
dragIndex,
dropIndex,
editGroups,
dropQualityIndex,
dropPosition,
qualityProfileItems,
errors,
warnings,
...otherProps
} = this.props;
const isDragging = dropIndex !== null;
const isDraggingUp = isDragging && dropIndex > dragIndex;
const isDraggingDown = isDragging && dropIndex < dragIndex;
const {
qualitiesHeight,
qualitiesHeightEditGroups
} = this.state;
const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
return (
<FormGroup>
<FormLabel>Qualities</FormLabel>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Qualities
</FormLabel>
<div>
<FormInputHelpText
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}>
{
qualityProfileItems.map(({ allowed, quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
sortIndex={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
}).reverse()
}
<QualityProfileItemDragPreview />
</div>
<Button
className={styles.editGroupsButton}
kind={kinds.PRIMARY}
onPress={this.onToggleEditGroupsMode}
>
<div>
<Icon
className={styles.editGroupsButtonIcon}
name={editGroups ? icons.REORDER : icons.GROUP}
/>
{
editGroups ? 'Done Editing Groups' : 'Edit Groups'
}
</div>
</Button>
<Measure
whitelist={['height']}
includeMargin={false}
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>
</FormGroup>
);
@ -88,11 +167,14 @@ class QualityProfileItems extends Component {
}
QualityProfileItems.propTypes = {
dragIndex: PropTypes.number,
dropIndex: PropTypes.number,
editGroups: PropTypes.bool.isRequired,
dragQualityIndex: PropTypes.string,
dropQualityIndex: PropTypes.string,
dropPosition: PropTypes.string,
qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object)
warnings: PropTypes.arrayOf(PropTypes.object),
onToggleEditGroupsMode: PropTypes.func.isRequired
};
QualityProfileItems.defaultProps = {

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

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

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

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

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

@ -113,6 +113,7 @@ export const ALBUM_HISTORY_MARK_AS_FAILED = 'ALBUM_HISTORY_MARK_AS_FAILED';
// 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 CLEAR_RELEASES = 'CLEAR_RELEASES';
export const GRAB_RELEASE = 'GRAB_RELEASE';

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

@ -3,10 +3,27 @@ import createFetchHandler from './Creators/createFetchHandler';
import * as types from './actionTypes';
import { updateRelease } from './releaseActions';
let abortCurrentRequest = null;
const section = 'releases';
const fetchReleases = createFetchHandler(section, '/release');
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) {
return function(dispatch, getState) {

@ -3,6 +3,7 @@ import * as types from './actionTypes';
import releaseActionHandlers from './releaseActionHandlers';
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 clearReleases = createAction(types.CLEAR_RELEASES);
export const grabRelease = releaseActionHandlers[types.GRAB_RELEASE];

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

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

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

@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import getQualities from 'Utilities/Quality/getQualities';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
@ -52,8 +53,8 @@ function createMapStateToProps() {
});
const languages = _.map(languageProfilesSchema.languages, 'language');
const qualities = _.map(qualityProfileSchema.items, 'quality');
const qualities = getQualities(qualityProfileSchema.items);
return {
items,
artistType: artist.artistType,
@ -90,18 +91,6 @@ function createMapDispatchToProps(dispatch, props) {
onDeletePress(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 = {
id: PropTypes.number.isRequired,
trackNumber: PropTypes.number.isRequired,
trackNumber: PropTypes.string.isRequired,
relativePath: PropTypes.string.isRequired,
language: 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';
export default function createAjaxRequest() {
return function(ajaxOptions) {
const requestXHR = new window.XMLHttpRequest();
let aborted = false;
let complete = false;
export default function createAjaxRequest(ajaxOptions) {
const requestXHR = new window.XMLHttpRequest();
let aborted = false;
let complete = false;
function abortRequest() {
if (!complete) {
aborted = true;
requestXHR.abort();
}
function abortRequest() {
if (!complete) {
aborted = true;
requestXHR.abort();
}
}
const request = $.ajax({
xhr: () => requestXHR,
...ajaxOptions
}).then(null, (xhr, textStatus, errorThrown) => {
xhr.aborted = aborted;
const request = $.ajax({
xhr: () => requestXHR,
...ajaxOptions
}).then(null, (xhr, textStatus, errorThrown) => {
xhr.aborted = aborted;
return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
}).always(() => {
complete = true;
});
return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
}).always(() => {
complete = true;
});
return {
request,
abortRequest
};
return {
request,
abortRequest
};
}

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

@ -20,9 +20,22 @@ namespace Lidarr.Api.V1.Albums
public int ProfileId { get; set; }
public int Duration { 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 DateTime? ReleaseDate { get; set; }
public List<string> Genres { get; set; }
public List<MediumResource> Media { get; set; }
public ArtistResource Artist { get; set; }
public List<MediaCover> Images { get; set; }
public AlbumStatisticsResource Statistics { get; set; }
@ -32,7 +45,7 @@ namespace Lidarr.Api.V1.Albums
public bool Grabbed { get; set; }
}
public static class EpisodeResourceMapper
public static class AlbumResourceMapper
{
public static AlbumResource ToResource(this Album model)
{
@ -53,7 +66,8 @@ namespace Lidarr.Api.V1.Albums
Images = model.Images,
Ratings = model.Ratings,
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.Collections.Generic;
using System.Linq;
using Nancy;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine;
@ -9,6 +11,7 @@ using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.Tracks;
using Lidarr.Http;
using Lidarr.Http.Extensions;
using Lidarr.Http.REST;
namespace Lidarr.Api.V1.History
{
@ -27,6 +30,7 @@ namespace Lidarr.Api.V1.History
_failedDownloadService = failedDownloadService;
GetResourcePaged = GetHistory;
Get["/since"] = x => GetHistorySince();
Post["/failed"] = x => MarkAsFailed();
}
@ -81,6 +85,30 @@ namespace Lidarr.Api.V1.History
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()
{
var id = (int)Request.Form.Id;

@ -89,6 +89,7 @@
<Compile Include="Albums\AlbumResource.cs" />
<Compile Include="Albums\AlbumsMonitoredResource.cs" />
<Compile Include="Albums\AlbumStatisticsResource.cs" />
<Compile Include="Albums\MediumResource.cs" />
<Compile Include="Blacklist\BlacklistModule.cs" />
<Compile Include="Blacklist\BlacklistResource.cs" />
<Compile Include="Calendar\CalendarFeedModule.cs" />
@ -97,6 +98,8 @@
<Compile Include="Commands\CommandResource.cs" />
<Compile Include="Config\MetadataProviderConfigModule.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\MediaInfoResource.cs" />
<Compile Include="Indexers\ReleaseModuleBase.cs" />
@ -172,7 +175,6 @@
<Compile Include="Profiles\Quality\QualityProfileModule.cs" />
<Compile Include="Profiles\Quality\QualityProfileResource.cs" />
<Compile Include="Profiles\Quality\QualityProfileSchemaModule.cs" />
<Compile Include="Profiles\Quality\QualityProfileValidation.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="ProviderModuleBase.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 NzbDrone.Core.Profiles.Qualities;
using Lidarr.Http;
@ -13,8 +13,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
{
_profileService = profileService;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Cutoff).NotNull();
SharedValidator.RuleFor(c => c.Items).MustHaveAllowedQuality();
// TODO: Need to validate the cutoff is allowed and the ID/quality ID exists
// 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;
GetResourceById = GetById;
@ -52,4 +54,4 @@ namespace Lidarr.Api.V1.Profiles.Quality
return _profileService.All().ToResource();
}
}
}
}

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Profiles.Qualities;
using Lidarr.Http.REST;
@ -8,14 +8,21 @@ namespace Lidarr.Api.V1.Profiles.Quality
public class QualityProfileResource : RestResource
{
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 class QualityProfileQualityItemResource : RestResource
{
public string Name { get; set; }
public NzbDrone.Core.Qualities.Quality Quality { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; }
public bool Allowed { get; set; }
public QualityProfileQualityItemResource()
{
Items = new List<QualityProfileQualityItemResource>();
}
}
public static class ProfileResourceMapper
@ -27,7 +34,6 @@ namespace Lidarr.Api.V1.Profiles.Quality
return new QualityProfileResource
{
Id = model.Id,
Name = model.Name,
Cutoff = model.Cutoff,
Items = model.Items.ConvertAll(ToResource),
@ -40,7 +46,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
return new QualityProfileQualityItemResource
{
Id = model.Id,
Name = model.Name,
Quality = model.Quality,
Items = model.Items.ConvertAll(ToResource),
Allowed = model.Allowed
};
}
@ -52,9 +61,8 @@ namespace Lidarr.Api.V1.Profiles.Quality
return new Profile
{
Id = resource.Id,
Name = resource.Name,
Cutoff = (NzbDrone.Core.Qualities.Quality)resource.Cutoff.Id,
Cutoff = resource.Cutoff,
Items = resource.Items.ConvertAll(ToModel)
};
}
@ -65,7 +73,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
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
};
}
@ -75,4 +86,4 @@ namespace Lidarr.Api.V1.Profiles.Quality
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.Qualities;
using Lidarr.Http;
namespace Lidarr.Api.V1.Profiles.Quality
{
public class QualityProfileSchemaModule : LidarrRestModule<QualityProfileResource>
{
private readonly IQualityDefinitionService _qualityDefinitionService;
public QualityProfileSchemaModule(IQualityDefinitionService qualityDefinitionService)
public QualityProfileSchemaModule()
: base("/qualityprofile/schema")
{
_qualityDefinitionService = qualityDefinitionService;
GetResourceSingle = GetSchema;
}
private QualityProfileResource GetSchema()
{
var items = _qualityDefinitionService.All()
.OrderBy(v => v.Weight)
.Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = false })
.ToList();
var groupedQualites = NzbDrone.Core.Qualities.Quality.DefaultQualityDefinitions.GroupBy(q => q.Weight);
var items = new List<ProfileQualityItem>();
var groupId = 1000;
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();
qualityProfile.Cutoff = NzbDrone.Core.Qualities.Quality.Unknown;
qualityProfile.Cutoff = NzbDrone.Core.Qualities.Quality.Unknown.Id;
qualityProfile.Items = items;
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 AlbumId { 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 int Duration { get; set; }
public TrackFileResource TrackFile { get; set; }
public int MediumNumber { get; set; }
public bool HasFile { get; set; }
public bool Monitored { get; set; }
//public string SeriesTitle { get; set; }
public ArtistResource Artist { get; set; }
public Ratings Ratings { get; set; }
@ -45,16 +46,14 @@ namespace Lidarr.Api.V1.Tracks
TrackFileId = model.TrackFileId,
AlbumId = model.AlbumId,
Explicit = model.Explicit,
AbsoluteTrackNumber = model.AbsoluteTrackNumber,
TrackNumber = model.TrackNumber,
Title = model.Title,
Duration = model.Duration,
//EpisodeFile
MediumNumber = model.MediumNumber,
HasFile = model.HasFile,
Monitored = model.Monitored,
Ratings = model.Ratings,
//SeriesTitle = model.SeriesTitle,
//Series = model.Series.MapToResource(),
};
}

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

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

@ -47,7 +47,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
};
_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() })
.Build();
@ -162,7 +162,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
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));
_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]
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));
_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()
{
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));
_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()
{
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();
remoteAlbum = new RemoteAlbum

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

@ -103,7 +103,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
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;
var remoteAlbum = Builder<RemoteAlbum>.CreateNew()
@ -123,7 +123,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
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;
var remoteAlbum = Builder<RemoteAlbum>.CreateNew()
@ -193,7 +193,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
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()
.With(r => r.Artist = _artist)
@ -289,7 +289,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
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()
.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.Cutoff = Quality.MP3_320;
_profile.Cutoff = Quality.MP3_320.Id;
_langProfile.Cutoff = Language.Spanish;
_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 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())
.Build();

@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
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();
Mocker.GetMock<IMediaFileService>()

@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var languages = Languages.LanguageFixture.GetDefaultLanguages(Language.English, Language.Spanish);
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 })
.Build();

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

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

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

@ -30,14 +30,14 @@ namespace NzbDrone.Core.Test.HistoryTests
{
_profile = new Profile
{
Cutoff = Quality.MP3_320,
Cutoff = Quality.MP3_320.Id,
Items = QualityFixture.GetDefaultQualities(),
};
_profileCustom = new Profile
{
Cutoff = Quality.MP3_320,
Cutoff = Quality.MP3_320.Id,
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="MusicTests\AddArtistFixture.cs" />
<Compile Include="MusicTests\ArtistNameSlugValidatorFixture.cs" />
<Compile Include="MusicTests\ArtistRepositoryTests\ArtistRepositoryFixture.cs" />
<Compile Include="NotificationTests\NotificationBaseFixture.cs" />
<Compile Include="NotificationTests\SynologyIndexerFixture.cs" />
<Compile Include="OrganizerTests\FileNameBuilderTests\CleanTitleFixture.cs" />
<Compile Include="OrganizerTests\FileNameBuilderTests\TitleTheFixture.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="QueueTests\QueueServiceFixture.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