mirror of https://github.com/lidarr/Lidarr
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
This commit is contained in:
parent
e1e7cad951
commit
21428cba6f
|
@ -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();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMonitorAlbumPress = (monitored) => {
|
||||
const {
|
||||
albumId,
|
||||
episodeEntity
|
||||
} = this.props;
|
||||
|
||||
this.props.toggleEpisodeMonitored({
|
||||
episodeEntity,
|
||||
albumId,
|
||||
monitored
|
||||
});
|
||||
this.props.dispatchClearTracks();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchClearReleases,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
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.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.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{body}
|
||||
</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 MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
|
||||
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
cutoff,
|
||||
items
|
||||
} = item;
|
||||
class EditQualityProfileModalContent extends Component {
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{id ? 'Edit Quality Profile' : 'Add Quality Profile'}
|
||||
</ModalHeader>
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<div>Unable to add a new quality profile, please try again.</div>
|
||||
}
|
||||
this.state = {
|
||||
headerHeight: 0,
|
||||
bodyHeight: 0,
|
||||
footerHeight: 0
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error &&
|
||||
<Form
|
||||
{...otherProps}
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>Name</FormLabel>
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
headerHeight,
|
||||
bodyHeight,
|
||||
footerHeight
|
||||
} = this.state;
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
if (
|
||||
headerHeight > 0 &&
|
||||
bodyHeight > 0 &&
|
||||
footerHeight > 0 &&
|
||||
(
|
||||
headerHeight !== prevState.headerHeight ||
|
||||
bodyHeight !== prevState.bodyHeight ||
|
||||
footerHeight !== prevState.footerHeight
|
||||
)
|
||||
) {
|
||||
const padding = MODAL_BODY_PADDING * 2;
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Cutoff</FormLabel>
|
||||
this.props.onContentHeightChange(
|
||||
headerHeight + bodyHeight + footerHeight + padding
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
<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>
|
||||
//
|
||||
// Listeners
|
||||
|
||||
<QualityProfileItems
|
||||
qualityProfileItems={items.value}
|
||||
errors={items.errors}
|
||||
warnings={items.warnings}
|
||||
{...otherProps}
|
||||
/>
|
||||
onHeaderMeasure = ({ height }) => {
|
||||
if (height > this.state.headerHeight) {
|
||||
this.setState({ headerHeight: height });
|
||||
}
|
||||
}
|
||||
|
||||
</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>
|
||||
onBodyMeasure = ({ height }) => {
|
||||
|
||||
if (height > this.state.bodyHeight) {
|
||||
this.setState({ bodyHeight: height });
|
||||
}
|
||||
}
|
||||
|
||||
onFooterMeasure = ({ height }) => {
|
||||
if (height > this.state.footerHeight) {
|
||||
this.setState({ footerHeight: height });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className={styles.formGroupWrapper}>
|
||||
<QualityProfileItems
|
||||
editGroups={editGroups}
|
||||
qualityProfileItems={items.value}
|
||||
errors={items.errors}
|
||||
warnings={items.warnings}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Measure>
|
||||
</ModalBody>
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
<Measure
|
||||
whitelist={['height']}
|
||||
includeMargin={false}
|
||||
onMeasure={this.onFooterMeasure}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<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>
|
||||
}
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
Save
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
<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;
|
||||
}
|
||||
|
||||
this.props.setQualityProfileValue({ name, value: item.quality });
|
||||
return i.id === id;
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 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 });
|
||||
|
||||
this.props.setQualityProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.quality : null });
|
||||
}
|
||||
this.ensureCutoff(qualityProfile);
|
||||
}
|
||||
|
||||
onQualityProfileItemDragMove = (dragIndex, dropIndex) => {
|
||||
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
|
||||
onItemGroupAllowedChange = (id, allowed) => {
|
||||
const qualityProfile = _.cloneDeep(this.props.item);
|
||||
const items = qualityProfile.items.value;
|
||||
const item = _.find(qualityProfile.items.value, (i) => i.id === id);
|
||||
|
||||
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 = (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 }) {
|
||||
return {
|
||||
beginDrag(props) {
|
||||
const {
|
||||
editGroups,
|
||||
qualityIndex,
|
||||
groupId,
|
||||
qualityId,
|
||||
name,
|
||||
allowed,
|
||||
sortIndex
|
||||
allowed
|
||||
} = props;
|
||||
|
||||
return {
|
||||
editGroups,
|
||||
qualityIndex,
|
||||
groupId,
|
||||
qualityId,
|
||||
isGroup: !qualityId,
|
||||
name,
|
||||
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 {
|
||||
qualityIndex: dragQualityIndex,
|
||||
isGroup: isDragGroup
|
||||
} = monitor.getItem();
|
||||
|
||||
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
|
||||
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;
|
||||
}
|
||||
|
||||
// Moving down, only trigger if drag position is below 50%
|
||||
if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
// Don't show targets for dropping on self
|
||||
if (dragQualityIndex === dropQualityIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onQualityProfileItemDragMove(dragIndex, hoverIndex);
|
||||
// Don't allow a group to be dropped inside a group
|
||||
if (isDragGroup && isDropGroupItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
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({
|
||||
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()
|
||||
}
|
||||
<Button
|
||||
className={styles.editGroupsButton}
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={this.onToggleEditGroupsMode}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.editGroupsButtonIcon}
|
||||
name={editGroups ? icons.REORDER : icons.GROUP}
|
||||
/>
|
||||
|
||||
<QualityProfileItemDragPreview />
|
||||
</div>
|
||||
{
|
||||
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…
Reference in New Issue