Fixed: Quality Groups and Profiles

This commit is contained in:
Qstick 2019-06-10 22:59:39 -04:00
parent 6275737ced
commit 16ff1176f7
55 changed files with 1229 additions and 216 deletions

View File

@ -17,7 +17,7 @@ import Settings from 'Settings/Settings';
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
import Profiles from 'Settings/Profiles/Profiles';
import Quality from 'Settings/Quality/Quality';
import CustomFormatsConnector from 'Settings/CustomFormats/CustomFormatsConnector';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import NetImportSettingsConnector from 'Settings/NetImport/NetImportSettingsConnector';
@ -142,7 +142,7 @@ function AppRoutes(props) {
<Route
path="/settings/customformats"
component={CustomFormatsConnector}
component={CustomFormatSettingsConnector}
/>
<Route

View File

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
class CustomFormatSettingsConnector extends Component {
//
// Render
render() {
return (
<PageContent title="Custom Formats Settings">
<SettingsToolbarConnector
showSave={false}
/>
<PageContentBodyConnector>
<CustomFormatsConnector />
</PageContentBodyConnector>
</PageContent>
);
}
}
export default DragDropContext(HTML5Backend)(CustomFormatSettingsConnector);

View File

@ -0,0 +1,38 @@
.customFormat {
composes: card from '~Components/Card.css';
width: 300px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.cloneButton {
composes: button from '~Components/Link/IconButton.css';
height: 36px;
}
.formats {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
pointer-events: all;
}
.tooltipLabel {
composes: label from '~Components/Label.css';
margin: 0;
border: none;
}

View File

@ -0,0 +1,141 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds } from 'Helpers/Props';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
// import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import styles from './CustomFormat.css';
class CustomFormat extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditCustomFormatModalOpen: false,
isDeleteCustomFormatModalOpen: false
};
}
//
// Listeners
onEditCustomFormatPress = () => {
this.setState({ isEditCustomFormatModalOpen: true });
}
onEditCustomFormatModalClose = () => {
this.setState({ isEditCustomFormatModalOpen: false });
}
onDeleteCustomFormatPress = () => {
this.setState({
isEditCustomFormatModalOpen: false,
isDeleteCustomFormatModalOpen: true
});
}
onDeleteCustomFormatModalClose = () => {
this.setState({ isDeleteCustomFormatModalOpen: false });
}
onConfirmDeleteCustomFormat = () => {
this.props.onConfirmDeleteCustomFormat(this.props.id);
}
onCloneCustomFormatPress = () => {
const {
id,
onCloneCustomFormatPress
} = this.props;
onCloneCustomFormatPress(id);
}
//
// Render
render() {
const {
// id,
name,
items,
isDeleting
} = this.props;
return (
<Card
className={styles.CustomFormat}
overlayContent={true}
onPress={this.onEditCustomFormatPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<IconButton
className={styles.cloneButton}
title="Clone Profile"
name={icons.CLONE}
onPress={this.onCloneCustomFormatPress}
/>
</div>
<div className={styles.formats}>
{
items.map((item) => {
if (!item.allowed) {
return null;
}
return (
<Label
key={item.quality.id}
kind={kinds.default}
title={null}
>
{item.quality.name}
</Label>
);
})
}
</div>
{/* <EditCustomFormatModalConnector
id={id}
isOpen={this.state.isEditCustomFormatModalOpen}
onModalClose={this.onEditCustomFormatModalClose}
onDeleteCustomFormatPress={this.onDeleteCustomFormatPress}
/> */}
<ConfirmModal
isOpen={this.state.isDeleteCustomFormatModalOpen}
kind={kinds.DANGER}
title="Delete Custom Format"
message={`Are you sure you want to delete the custom format '${name}'?`}
confirmLabel="Delete"
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteCustomFormat}
onCancel={this.onDeleteCustomFormatModalClose}
/>
</Card>
);
}
}
CustomFormat.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteCustomFormat: PropTypes.func.isRequired,
onCloneCustomFormatPress: PropTypes.func.isRequired
};
export default CustomFormat;

View File

@ -0,0 +1,21 @@
.customFormats {
display: flex;
flex-wrap: wrap;
}
.addCustomFormat {
composes: customFormat from '~./CustomFormat.css';
background-color: $cardAlternateBackgroundColor;
color: $gray;
text-align: center;
font-size: 45px;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
}

View File

@ -0,0 +1,109 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import sortByName from 'Utilities/Array/sortByName';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Card from 'Components/Card';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import CustomFormat from './CustomFormat';
// import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import styles from './CustomFormats.css';
class CustomFormats extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isCustomFormatModalOpen: false
};
}
//
// Listeners
onCloneCustomFormatPress = (id) => {
this.props.onCloneCustomFormatPress(id);
this.setState({ isCustomFormatModalOpen: true });
}
onEditCustomFormatPress = () => {
this.setState({ isCustomFormatModalOpen: true });
}
onModalClose = () => {
this.setState({ isCustomFormatModalOpen: false });
}
//
// Render
render() {
const {
items,
isDeleting,
onConfirmDeleteCustomFormat,
onCloneCustomFormatPress,
...otherProps
} = this.props;
return (
<FieldSet legend="Custom Formats">
<PageSectionContent
errorMessage="Unable to load Custom Formats"
{...otherProps}c={true}
>
<div className={styles.CustomFormats}>
{
items.sort(sortByName).map((item) => {
return (
<CustomFormat
key={item.id}
{...item}
isDeleting={isDeleting}
onConfirmDeleteCustomFormat={onConfirmDeleteCustomFormat}
onCloneCustomFormatPress={this.onCloneCustomFormatPress}
/>
);
})
}
<Card
className={styles.addCustomFormat}
onPress={this.onEditCustomFormatPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
{/*
<EditCustomFormatModalConnector
isOpen={this.state.isCustomFormatModalOpen}
onModalClose={this.onModalClose}
/> */}
</PageSectionContent>
</FieldSet>
);
}
}
CustomFormats.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteCustomFormat: PropTypes.func.isRequired,
onCloneCustomFormatPress: PropTypes.func.isRequired
};
export default CustomFormats;

View File

@ -0,0 +1,65 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchCustomFormats, deleteCustomFormat, cloneCustomFormat } from 'Store/Actions/settingsActions';
import CustomFormats from './CustomFormats';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.customFormats,
(customFormats) => {
return {
...customFormats
};
}
);
}
const mapDispatchToProps = {
dispatchFetchCustomFormats: fetchCustomFormats,
dispatchDeleteCustomFormat: deleteCustomFormat,
dispatchCloneCustomFormat: cloneCustomFormat
};
class CustomFormatsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchCustomFormats();
}
//
// Listeners
onConfirmDeleteCustomFormat = (id) => {
this.props.dispatchDeleteCustomFormat({ id });
}
onCloneCustomFormatPress = (id) => {
this.props.dispatchCloneCustomFormat({ id });
}
//
// Render
render() {
return (
<CustomFormats
onConfirmDeleteCustomFormat={this.onConfirmDeleteCustomFormat}
onCloneCustomFormatPress={this.onCloneCustomFormatPress}
{...this.props}
/>
);
}
}
CustomFormatsConnector.propTypes = {
dispatchFetchCustomFormats: PropTypes.func.isRequired,
dispatchDeleteCustomFormat: PropTypes.func.isRequired,
dispatchCloneCustomFormat: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CustomFormatsConnector);

View File

@ -1,17 +0,0 @@
import React from 'react';
import PageContent from 'Components/Page/PageContent';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
function CustomFormatsConnector() {
return (
<PageContent title="Custom Formats Settings">
<SettingsToolbarConnector
showSave={false}
/>
</PageContent>
);
}
export default CustomFormatsConnector;

View File

@ -121,7 +121,7 @@ class EditQualityProfileModalContentConnector extends Component {
return false;
}
return i.id === cutoff.id || (i.quality && i.quality.id === cutoff.id);
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

View File

@ -97,20 +97,20 @@ class QualityProfile extends Component {
}
if (item.quality) {
const isCutoff = item.quality.id === cutoff.id;
const isCutoff = item.quality.id === cutoff;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
title={isCutoff ? 'Upgrade until this quality is met or exceeded' : null}
>
{item.quality.name}
</Label>
);
}
const isCutoff = item.id === cutoff.id;
const isCutoff = item.id === cutoff;
return (
<Tooltip
@ -174,7 +174,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,

View File

@ -0,0 +1,97 @@
import { createAction } from 'redux-actions';
import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
//
// Variables
const section = 'settings.customFormats';
//
// Actions Types
export const FETCH_CUSTOM_FORMATS = 'settings/customFormats/fetchCustomFormats';
export const FETCH_CUSTOM_FORMAT_SCHEMA = 'settings/customFormats/fetchCustomFormatSchema';
export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat';
export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat';
export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue';
export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
//
// Action Creators
export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS);
export const fetchCustomFormatSchema = createThunk(FETCH_CUSTOM_FORMAT_SCHEMA);
export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT);
export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT);
export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => {
return {
section,
...payload
};
});
export const cloneCustomFormat = createAction(CLONE_CUSTOM_FORMAT);
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
isDeleting: false,
deleteError: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: {},
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_CUSTOM_FORMATS]: createFetchHandler(section, '/customformat'),
[FETCH_CUSTOM_FORMAT_SCHEMA]: createFetchSchemaHandler(section, '/customformat/schema'),
[SAVE_CUSTOM_FORMAT]: createSaveProviderHandler(section, '/customformat'),
[DELETE_CUSTOM_FORMAT]: createRemoveItemHandler(section, '/customformat')
},
//
// Reducers
reducers: {
[SET_CUSTOM_FORMAT_VALUE]: createSetSettingValueReducer(section),
[CLONE_CUSTOM_FORMAT]: function(state, { payload }) {
const id = payload.id;
const newState = getSectionState(state, section);
const item = newState.items.find((i) => i.id === id);
const pendingChanges = { ...item, id: 0 };
delete pendingChanges.id;
pendingChanges.name = `${pendingChanges.name} - Copy`;
newState.pendingChanges = pendingChanges;
return updateSectionState(state, section, newState);
}
}
};

View File

@ -1,6 +1,7 @@
import { createAction } from 'redux-actions';
import { handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions';
import customFormats from './Settings/customFormats';
import delayProfiles from './Settings/delayProfiles';
import downloadClients from './Settings/downloadClients';
import downloadClientOptions from './Settings/downloadClientOptions';
@ -20,6 +21,7 @@ import remotePathMappings from './Settings/remotePathMappings';
import restrictions from './Settings/restrictions';
import ui from './Settings/ui';
export * from './Settings/customFormats';
export * from './Settings/delayProfiles';
export * from './Settings/downloadClients';
export * from './Settings/downloadClientOptions';
@ -50,6 +52,7 @@ export const section = 'settings';
export const defaultState = {
advancedSettings: false,
customFormats: customFormats.defaultState,
delayProfiles: delayProfiles.defaultState,
downloadClients: downloadClients.defaultState,
downloadClientOptions: downloadClientOptions.defaultState,
@ -88,6 +91,7 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
// Action Handlers
export const actionHandlers = handleThunks({
...customFormats.actionHandlers,
...delayProfiles.actionHandlers,
...downloadClients.actionHandlers,
...downloadClientOptions.actionHandlers,
@ -117,6 +121,7 @@ export const reducers = createHandleActions({
return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
},
...customFormats.reducers,
...delayProfiles.reducers,
...downloadClients.reducers,
...downloadClientOptions.reducers,

View File

@ -6,7 +6,6 @@ using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Validation;
using Radarr.Http;
using Radarr.Http.Mapping;
namespace NzbDrone.Api.Profiles
{

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Api.Qualities;
using Radarr.Http.REST;
using NzbDrone.Core.Parser;
@ -37,14 +38,42 @@ namespace NzbDrone.Api.Profiles
{
if (model == null) return null;
var cutoffItem = model.Items.First(q =>
{
if (q.Id == model.Cutoff) return true;
if (q.Quality == null) return false;
return q.Quality.Id == model.Cutoff;
});
var cutoff = cutoffItem.Items == null || cutoffItem.Items.Empty()
? cutoffItem.Quality
: cutoffItem.Items.First().Quality;
return new ProfileResource
{
Id = model.Id,
Name = model.Name,
Cutoff = model.Cutoff,
PreferredTags = model.PreferredTags != null ? string.Join(",", model.PreferredTags) : "",
Items = model.Items.ConvertAll(ToResource),
Cutoff = cutoff,
// Flatten groups so things don't explode
Items = model.Items.SelectMany(i =>
{
if (i == null)
{
return null;
}
if (i.Items.Any())
{
return i.Items.ConvertAll(ToResource);
}
return new List<ProfileQualityItemResource> { ToResource(i) };
}).ToList(),
FormatCutoff = model.FormatCutoff.ToResource(),
FormatItems = model.FormatItems.ConvertAll(ToResource),
Language = model.Language
@ -80,7 +109,7 @@ namespace NzbDrone.Api.Profiles
Id = resource.Id,
Name = resource.Name,
Cutoff = (Quality)resource.Cutoff.Id,
Cutoff = resource.Cutoff.Id,
PreferredTags = resource.PreferredTags.Split(',').ToList(),
Items = resource.Items.ConvertAll(ToModel),
FormatCutoff = resource.FormatCutoff.ToModel(),

View File

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Parser;
@ -42,7 +42,7 @@ namespace NzbDrone.Api.Profiles
});
var profile = new Profile();
profile.Cutoff = Quality.Unknown;
profile.Cutoff = Quality.Unknown.Id;
profile.Items = items;
profile.FormatCutoff = CustomFormat.None;
profile.FormatItems = formatItems;

View File

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

View File

@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var fakeSeries = Builder<Movie>.CreateNew()
.With(c => c.Profile = (LazyLoaded<Profile>)new Profile { Cutoff = Quality.Bluray1080p })
.With(c => c.Profile = (LazyLoaded<Profile>)new Profile { Cutoff = Quality.Bluray1080p.Id })
.Build();
remoteMovie = new RemoteMovie

View File

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Profiles;
@ -32,28 +32,28 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_return_true_if_current_episode_is_less_than_cutoff()
{
Subject.CutoffNotMet(new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() },
Subject.CutoffNotMet(new Profile { Cutoff = Quality.Bluray1080p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() },
new QualityModel(Quality.DVD, new Revision(version: 2))).Should().BeTrue();
}
[Test]
public void should_return_false_if_current_episode_is_equal_to_cutoff()
{
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() },
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() },
new QualityModel(Quality.HDTV720p, new Revision(version: 2))).Should().BeFalse();
}
[Test]
public void should_return_false_if_current_episode_is_greater_than_cutoff()
{
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() },
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() },
new QualityModel(Quality.Bluray1080p, new Revision(version: 2))).Should().BeFalse();
}
[Test]
public void should_return_true_when_new_episode_is_proper_but_existing_is_not()
{
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() },
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() },
new QualityModel(Quality.HDTV720p, new Revision(version: 1)),
new QualityModel(Quality.HDTV720p, new Revision(version: 2))).Should().BeTrue();
}
@ -61,7 +61,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_return_false_if_cutoff_is_met_and_quality_is_higher()
{
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() },
Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() },
new QualityModel(Quality.HDTV720p, new Revision(version: 2)),
new QualityModel(Quality.Bluray1080p, new Revision(version: 2))).Should().BeFalse();
}
@ -77,7 +77,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.CutoffNotMet(
new Profile
{
Cutoff = Quality.HDTV720p,
Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
FormatCutoff = CustomFormats.CustomFormat.None,
FormatItems = CustomFormatsFixture.GetSampleFormatItems("None", "My Format")

View File

@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
_upgradeHistory = Mocker.Resolve<HistorySpecification>();
_fakeMovie = Builder<Movie>.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() })
.With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() })
.Build();
_parseResultSingle = new RemoteMovie
@ -144,7 +144,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_not_be_upgradable_if_episode_is_of_same_quality_as_existing()
{
_fakeMovie.Profile = new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() };
_fakeMovie.Profile = new Profile { Cutoff = Quality.Bluray1080p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedMovieInfo.Quality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1));
_upgradableQuality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1));
@ -156,7 +156,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_not_be_upgradable_if_cutoff_already_met()
{
_fakeMovie.Profile = new Profile { Cutoff = Quality.WEBDL1080p, Items = Qualities.QualityFixture.GetDefaultQualities() };
_fakeMovie.Profile = new Profile { Cutoff = Quality.WEBDL1080p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedMovieInfo.Quality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1));
_upgradableQuality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1));
@ -184,7 +184,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void should_return_false_if_cutoff_already_met_and_cdh_is_disabled()
{
GivenCdhDisabled();
_fakeMovie.Profile = new Profile { Cutoff = Quality.WEBDL1080p, Items = Qualities.QualityFixture.GetDefaultQualities() };
_fakeMovie.Profile = new Profile { Cutoff = Quality.WEBDL1080p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() };
_parseResultSingle.ParsedMovieInfo.Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1));
_upgradableQuality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1));

View File

@ -35,7 +35,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void Setup()
{
var fakeSeries = Builder<Movie>.CreateNew()
.With(c => c.Profile = (LazyLoaded<Profile>)new Profile { Cutoff = Quality.Bluray1080p })
.With(c => c.Profile = (LazyLoaded<Profile>)new Profile { Cutoff = Quality.Bluray1080p.Id })
.Build();
remoteMovie = new RemoteMovie

View File

@ -81,7 +81,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_return_true_when_quality_in_queue_is_lower()
{
_movie.Profile.Value.Cutoff = Quality.Bluray1080p;
_movie.Profile.Value.Cutoff = Quality.Bluray1080p.Id;
var remoteEpisode = Builder<RemoteMovie>.CreateNew()
.With(r => r.Movie = _movie)
@ -113,7 +113,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_return_false_when_quality_in_queue_is_better()
{
_movie.Profile.Value.Cutoff = Quality.Bluray1080p;
_movie.Profile.Value.Cutoff = Quality.Bluray1080p.Id;
var remoteEpisode = Builder<RemoteMovie>.CreateNew()
.With(r => r.Movie = _movie)
@ -130,7 +130,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test]
public void should_return_false_if_quality_in_queue_meets_cutoff()
{
_movie.Profile.Value.Cutoff = _remoteMovie.ParsedMovieInfo.Quality.Quality;
_movie.Profile.Value.Cutoff = _remoteMovie.ParsedMovieInfo.Quality.Quality.Id;
var remoteEpisode = Builder<RemoteMovie>.CreateNew()
.With(r => r.Movie = _movie)

View File

@ -51,7 +51,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.WEBDL720p });
_profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.Bluray720p });
_profile.Cutoff = Quality.WEBDL720p;
_profile.Cutoff = Quality.WEBDL720p.Id;
_remoteEpisode.ParsedMovieInfo = new ParsedMovieInfo();
_remoteEpisode.Release = new ReleaseInfo();

View File

@ -34,7 +34,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
_secondFile = new MovieFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)), DateAdded = DateTime.Now };
var fakeSeries = Builder<Movie>.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p })
.With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p.Id })
.With(c => c.MovieFile = _firstFile)
.Build();

View File

@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
_firstFile = new MovieFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), DateAdded = DateTime.Now };
var fakeSeries = Builder<Movie>.CreateNew()
.With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() })
.With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p.Id, Items = Qualities.QualityFixture.GetDefaultQualities() })
.With(e => e.MovieFile = _firstFile)
.Build();

View File

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

View File

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

View File

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

View File

@ -25,8 +25,8 @@ namespace NzbDrone.Core.Test.HistoryTests
[SetUp]
public void Setup()
{
_profile = new Profile { Cutoff = Quality.WEBDL720p, Items = QualityFixture.GetDefaultQualities() };
_profileCustom = new Profile { Cutoff = Quality.WEBDL720p, Items = QualityFixture.GetDefaultQualities(Quality.DVD) };
_profile = new Profile { Cutoff = Quality.WEBDL720p.Id, Items = QualityFixture.GetDefaultQualities() };
_profileCustom = new Profile { Cutoff = Quality.WEBDL720p.Id, Items = QualityFixture.GetDefaultQualities(Quality.DVD) };
}
[Test]

View File

@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.MovieTests.MovieRepositoryTests
Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p),
FormatItems = CustomFormat.CustomFormatsFixture.GetDefaultFormatItems(),
FormatCutoff = CustomFormats.CustomFormat.None,
Cutoff = Quality.Bluray1080p,
Cutoff = Quality.Bluray1080p.Id,
Name = "TestProfile"
};

View File

@ -305,6 +305,7 @@
<Compile Include="ParserTests\ParsingServiceTests\AugmentersTests\AugmentWithReleaseInfoFixture.cs" />
<Compile Include="ParserTests\ParsingServiceTests\ParseQualityDefinitionFixture.cs" />
<Compile Include="ParserTests\RomanNumeralTests\RomanNumeralConversionFixture.cs" />
<Compile Include="Profiles\Qualities\QualityIndexCompareToFixture.cs" />
<Compile Include="Qualities\RevisionComparableFixture.cs" />
<Compile Include="QueueTests\QueueServiceFixture.cs" />
<Compile Include="RemotePathMappingsTests\RemotePathMappingServiceFixture.cs" />

View File

@ -22,7 +22,7 @@ namespace NzbDrone.Core.Test.Profiles
Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p),
FormatCutoff = CustomFormats.CustomFormat.None,
FormatItems = CustomFormat.CustomFormatsFixture.GetDefaultFormatItems(),
Cutoff = Quality.Bluray1080p,
Cutoff = Quality.Bluray1080p.Id,
Name = "TestProfile"
};

View File

@ -0,0 +1,36 @@
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Qualities
{
[TestFixture]
public class QualityIndexCompareToFixture : CoreTest
{
[TestCase(1, 0, 1, 0, 0)]
[TestCase(1, 1, 1, 0, 1)]
[TestCase(2, 0, 1, 0, 1)]
[TestCase(1, 0, 1, 1, -1)]
[TestCase(1, 0, 2, 0, -1)]
public void should_match_expected_when_respect_group_order_is_true(int leftIndex, int leftGroupIndex, int rightIndex, int rightGroupIndex, int expected)
{
var left = new QualityIndex(leftIndex, leftGroupIndex);
var right = new QualityIndex(rightIndex, rightGroupIndex);
left.CompareTo(right, true).Should().Be(expected);
}
[TestCase(1, 0, 1, 0, 0)]
[TestCase(1, 1, 1, 0, 0)]
[TestCase(2, 0, 1, 0, 1)]
[TestCase(1, 0, 1, 1, 0)]
[TestCase(1, 0, 2, 0, -1)]
public void should_match_expected_when_respect_group_order_is_false(int leftIndex, int leftGroupIndex, int rightIndex, int rightGroupIndex, int expected)
{
var left = new QualityIndex(leftIndex, leftGroupIndex);
var right = new QualityIndex(rightIndex, rightGroupIndex);
left.CompareTo(right, false).Should().Be(expected);
}
}
}

View File

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Profiles;
@ -31,6 +31,50 @@ namespace NzbDrone.Core.Test.Qualities
Subject = new QualityModelComparer(new Profile { Items = QualityFixture.GetDefaultQualities(Quality.Bluray720p, Quality.DVD) });
}
private void GivenGroupedProfile()
{
var profile = new Profile
{
Items = new List<ProfileQualityItem>
{
new ProfileQualityItem
{
Allowed = false,
Quality = Quality.SDTV
},
new ProfileQualityItem
{
Allowed = false,
Quality = Quality.DVD
},
new ProfileQualityItem
{
Allowed = true,
Items = new List<ProfileQualityItem>
{
new ProfileQualityItem
{
Allowed = true,
Quality = Quality.HDTV720p
},
new ProfileQualityItem
{
Allowed = true,
Quality = Quality.WEBDL720p
}
}
},
new ProfileQualityItem
{
Allowed = true,
Quality = Quality.Bluray720p
}
}
};
Subject = new QualityModelComparer(profile);
}
private void GivenDefaultProfileWithFormats()
{
_customFormat1 = new CustomFormats.CustomFormat("My Format 1", "L_ENGLISH"){Id=1};
@ -118,5 +162,31 @@ namespace NzbDrone.Core.Test.Qualities
compare.Should().BeGreaterThan(0);
}
[Test]
public void should_ignore_group_order_by_default()
{
GivenGroupedProfile();
var first = new QualityModel(Quality.HDTV720p);
var second = new QualityModel(Quality.WEBDL720p);
var compare = Subject.Compare(first, second);
compare.Should().Be(0);
}
[Test]
public void should_respect_group_order()
{
GivenGroupedProfile();
var first = new QualityModel(Quality.HDTV720p);
var second = new QualityModel(Quality.WEBDL720p);
var compare = Subject.Compare(first, second, true);
compare.Should().BeLessThan(0);
}
}
}

View File

@ -112,7 +112,8 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<ImportExclusion>().RegisterModel("ImportExclusions");
Mapper.Entity<QualityDefinition>().RegisterModel("QualityDefinitions")
.Ignore(d => d.Weight)
.Ignore(d => d.GroupName)
.Ignore(d => d.Weight)
.Relationship();
Mapper.Entity<CustomFormat>().RegisterModel("CustomFormats")

View File

@ -61,7 +61,7 @@ namespace NzbDrone.Core.DecisionEngine
private int CompareQuality(DownloadDecision x, DownloadDecision y)
{
return CompareAll(CompareBy(x.RemoteMovie, y.RemoteMovie, remoteMovie => remoteMovie.Movie.Profile.Value.Items.FindIndex(v => v.Quality == remoteMovie.ParsedMovieInfo.Quality.Quality)),
return CompareAll(CompareBy(x.RemoteMovie, y.RemoteMovie, remoteMovie => remoteMovie.Movie.Profile.Value.GetIndex(remoteMovie.ParsedMovieInfo.Quality.Quality)),
CompareCustomFormats(x, y),
CompareBy(x.RemoteMovie, y.RemoteMovie, remoteMovie => remoteMovie.ParsedMovieInfo.Quality.Revision.Real),
CompareBy(x.RemoteMovie, y.RemoteMovie, remoteMovie => remoteMovie.ParsedMovieInfo.Quality.Revision.Version));

View File

@ -43,7 +43,7 @@ namespace NzbDrone.Core.DecisionEngine
public bool CutoffNotMet(Profile profile, QualityModel currentQuality, QualityModel newQuality = null)
{
var comparer = new QualityModelComparer(profile);
var compare = comparer.Compare(currentQuality.Quality, profile.Cutoff);
var compare = comparer.Compare(currentQuality.Quality.Id, profile.Cutoff);
if (compare < 0)
{

View File

@ -21,11 +21,18 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
public virtual Decision IsSatisfiedBy(RemoteMovie subject, SearchCriteriaBase searchCriteria)
{
var profile = subject.Movie.Profile.Value;
if (subject.Movie.MovieFile != null)
{
if (!_qualityUpgradableSpecification.CutoffNotMet(subject.Movie.Profile, subject.Movie.MovieFile.Quality, subject.ParsedMovieInfo.Quality))
if (!_qualityUpgradableSpecification.CutoffNotMet(profile,
subject.Movie.MovieFile.Quality,
subject.ParsedMovieInfo.Quality))
{
return Decision.Reject("Existing file meets cutoff: {0}", subject.Movie.Profile.Value.Cutoff);
var qualityCutoffIndex = profile.GetIndex(profile.Cutoff);
var qualityCutoff = profile.Items[qualityCutoffIndex.Index];
return Decision.Reject("Existing file meets cutoff: {0} - {1}", qualityCutoff, subject.Movie.Profile.Value.Cutoff);
}
}

View File

@ -18,7 +18,11 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
public virtual Decision IsSatisfiedBy(RemoteMovie subject, SearchCriteriaBase searchCriteria)
{
_logger.Debug("Checking if report meets quality requirements. {0}", subject.ParsedMovieInfo.Quality);
if (!subject.Movie.Profile.Value.Items.Exists(v => v.Allowed && v.Quality == subject.ParsedMovieInfo.Quality.Quality))
var profile = subject.Movie.Profile.Value;
var qualityIndex = profile.GetIndex(subject.ParsedMovieInfo.Quality.Quality);
var qualityOrGroup = profile.Items[qualityIndex.Index];
if (!qualityOrGroup.Allowed)
{
_logger.Debug("Quality {0} rejected by Movie's quality profile", subject.ParsedMovieInfo.Quality);
return Decision.Reject("{0} is not wanted in profile", subject.ParsedMovieInfo.Quality.Quality);

View File

@ -82,8 +82,8 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync
}
// If quality meets or exceeds the best allowed quality in the profile accept it immediately
var bestQualityInProfile = new QualityModel(profile.LastAllowedQuality());
var isBestInProfile = comparer.Compare(subject.ParsedMovieInfo.Quality, bestQualityInProfile) >= 0;
var bestQualityInProfile = profile.LastAllowedQuality();
var isBestInProfile = comparer.Compare(subject.ParsedMovieInfo.Quality.Quality, bestQualityInProfile) >= 0;
if (isBestInProfile && isPreferredProtocol && (preferredCount > 0 || preferredWords == null))
{

View File

@ -21,8 +21,8 @@ namespace NzbDrone.Core.MediaFiles.MovieImport.Specifications
var qualityComparer = new QualityModelComparer(localMovie.Movie.Profile);
if (localMovie.Movie.MovieFile != null && qualityComparer.Compare(localMovie.Movie.MovieFile.Quality, localMovie.Quality) > 0)
{
_logger.Debug("This file isn't an upgrade for all episodes. Skipping {0}", localMovie.Path);
return Decision.Reject("Not an upgrade for existing episode file(s)");
_logger.Debug("This file isn't an upgrade for all movies. Skipping {0}", localMovie.Path);
return Decision.Reject("Not an upgrade for existing movie file(s)");
}
return Decision.Accept();

View File

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Core.Datastore;
@ -16,13 +16,11 @@ namespace NzbDrone.Core.Movies
{
private readonly IMovieRepository _movieRepository;
private readonly IProfileService _profileService;
private readonly Logger _logger;
public MovieCutoffService(IMovieRepository movieRepository, IProfileService profileService, Logger logger)
{
_movieRepository = movieRepository;
_profileService = profileService;
_logger = logger;
}
public PagingSpec<Movie> MoviesWhereCutoffUnmet(PagingSpec<Movie> pagingSpec)
@ -33,12 +31,12 @@ namespace NzbDrone.Core.Movies
//Get all items less than the cutoff
foreach (var profile in profiles)
{
var cutoffIndex = profile.Items.FindIndex(v => v.Quality.Id == profile.Cutoff.Id);
var belowCutoff = profile.Items.Take(cutoffIndex).ToList();
var cutoffIndex = profile.GetIndex(profile.Cutoff);
var belowCutoff = profile.Items.Take(cutoffIndex.Index).ToList();
if (belowCutoff.Any())
{
qualitiesBelowCutoff.Add(new QualitiesBelowCutoff(profile.Id, belowCutoff.Select(i => i.Quality.Id)));
qualitiesBelowCutoff.Add(new QualitiesBelowCutoff(profile.Id, belowCutoff.SelectMany(i => i.GetQualities().Select(q => q.Id))));
}
}

View File

@ -1030,6 +1030,7 @@
<Compile Include="Parser\RomanNumerals\RomanNumeralParser.cs" />
<Compile Include="Parser\RomanNumerals\SimpleArabicNumeral.cs" />
<Compile Include="Parser\RomanNumerals\SimpleRomanNumeral.cs" />
<Compile Include="Profiles\QualityIndex.cs" />
<Compile Include="Profiles\Delay\DelayProfile.cs" />
<Compile Include="Profiles\Delay\DelayProfileService.cs" />
<Compile Include="Profiles\Delay\DelayProfileTagInUseValidator.cs" />

View File

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore;
@ -15,7 +15,7 @@ namespace NzbDrone.Core.Profiles
}
public string Name { get; set; }
public Quality Cutoff { get; set; }
public int Cutoff { get; set; }
public List<ProfileQualityItem> Items { get; set; }
public CustomFormat FormatCutoff { get; set; }
public List<ProfileFormatItem> FormatItems { get; set; }
@ -24,7 +24,54 @@ namespace NzbDrone.Core.Profiles
public Quality LastAllowedQuality()
{
return Items.Last(q => q.Allowed).Quality;
var lastAllowed = Items.Last(q => q.Allowed);
if (lastAllowed.Quality != null)
{
return lastAllowed.Quality;
}
// Returning any item from the group will work,
// returning the last because it's the true last quality.
return lastAllowed.Items.Last().Quality;
}
public QualityIndex GetIndex(Quality quality)
{
return GetIndex(quality.Id);
}
public QualityIndex GetIndex(int id)
{
for (var i = 0; i < Items.Count; i++)
{
var item = Items[i];
var quality = item.Quality;
// Quality matches by ID
if (quality != null && quality.Id == id)
{
return new QualityIndex(i);
}
// Group matches by ID
if (item.Id > 0 && item.Id == id)
{
return new QualityIndex(i);
}
for (var g = 0; g < item.Items.Count; g++)
{
var groupItem = item.Items[g];
if (groupItem.Quality.Id == id)
{
return new QualityIndex(i, g);
}
}
}
return new QualityIndex();
}
}
}

View File

@ -1,12 +1,47 @@
using NzbDrone.Core.Datastore;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Core.Profiles
{
public class ProfileQualityItem : IEmbeddedDocument
{
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public int Id { get; set; }
public string Name { get; set; }
public Quality Quality { get; set; }
public List<ProfileQualityItem> Items { get; set; }
public bool Allowed { get; set; }
public ProfileQualityItem()
{
Items = new List<ProfileQualityItem>();
}
public List<Quality> GetQualities()
{
if (Quality == null)
{
return Items.Select(s => s.Quality).ToList();
}
return new List<Quality> { Quality };
}
public override string ToString()
{
var qualitiesString = string.Join(", ", GetQualities());
if (Name.IsNotNullOrWhiteSpace())
{
return $"{Name} ({qualitiesString})";
}
return qualitiesString;
}
}
}

View File

@ -21,6 +21,7 @@ namespace NzbDrone.Core.Profiles
List<Profile> All();
Profile Get(int id);
bool Exists(int id);
Profile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed);
}
public class ProfileService : IProfileService, IHandle<ApplicationStartedEvent>
@ -106,25 +107,6 @@ namespace NzbDrone.Core.Profiles
return _profileRepository.Exists(id);
}
private Profile AddDefaultProfile(string name, Quality cutoff, params Quality[] allowed)
{
var items = Quality.DefaultQualityDefinitions
.OrderBy(v => v.Weight)
.Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = allowed.Contains(v.Quality) })
.ToList();
var profile = new Profile { Name = name, Cutoff = cutoff, Items = items, Language = Language.English, FormatCutoff = CustomFormat.None, FormatItems = new List<ProfileFormatItem>
{
new ProfileFormatItem
{
Allowed = true,
Format = CustomFormat.None
}
}};
return Add(profile);
}
public void Handle(ApplicationStartedEvent message)
{
// Hack to force custom formats to be loaded into memory, if you have a better solution please let me know.
@ -200,5 +182,70 @@ namespace NzbDrone.Core.Profiles
Quality.Remux2160p
);
}
public Profile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed)
{
var groupedQualites = Quality.DefaultQualityDefinitions.GroupBy(q => q.Weight);
var items = new List<ProfileQualityItem>();
var groupId = 1000;
var profileCutoff = cutoff == null ? Quality.Unknown.Id : cutoff.Id;
foreach (var group in groupedQualites)
{
if (group.Count() == 1)
{
var quality = group.First().Quality;
items.Add(new ProfileQualityItem { Quality = group.First().Quality, Allowed = allowed.Contains(quality) });
continue;
}
var groupAllowed = group.Any(g => allowed.Contains(g.Quality));
items.Add(new ProfileQualityItem
{
Id = groupId,
Name = group.First().GroupName,
Items = group.Select(g => new ProfileQualityItem
{
Quality = g.Quality,
Allowed = groupAllowed
}).ToList(),
Allowed = groupAllowed
});
if (group.Any(g => g.Quality.Id == profileCutoff))
{
profileCutoff = groupId;
}
groupId++;
}
var qualityProfile = new Profile
{
Name = name,
Cutoff = profileCutoff,
Items = items,
FormatCutoff = CustomFormat.None,
FormatItems = new List<ProfileFormatItem>
{
new ProfileFormatItem
{
Allowed = true,
Format = CustomFormat.None
}
}
};
return qualityProfile;
}
private Profile AddDefaultProfile(string name, Quality cutoff, params Quality[] allowed)
{
var profile = GetDefaultProfile(name, cutoff, allowed);
return Add(profile);
}
}
}

View File

@ -0,0 +1,55 @@
using System;
namespace NzbDrone.Core.Profiles
{
public class QualityIndex : IComparable, IComparable<QualityIndex>
{
public int Index { get; set; }
public int GroupIndex { get; set; }
public QualityIndex()
{
Index = 0;
GroupIndex = 0;
}
public QualityIndex(int index)
{
Index = index;
GroupIndex = 0;
}
public QualityIndex(int index, int groupIndex)
{
Index = index;
GroupIndex = groupIndex;
}
public int CompareTo(object obj)
{
return CompareTo((QualityIndex)obj, true);
}
public int CompareTo(QualityIndex other)
{
return CompareTo(other, true);
}
public int CompareTo(QualityIndex right, bool respectGroupOrder)
{
if (right == null)
{
return 1;
}
var indexCompare = Index.CompareTo(right.Index);
if (respectGroupOrder && indexCompare == 0)
{
return GroupIndex.CompareTo(right.GroupIndex);
}
return indexCompare; ;
}
}
}

View File

@ -1,4 +1,4 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Qualities
{
@ -7,7 +7,7 @@ namespace NzbDrone.Core.Qualities
public Quality Quality { get; set; }
public string Title { get; set; }
public string GroupName { get; set; }
public int Weight { get; set; }
public double? MinSize { get; set; }

View File

@ -1,9 +1,7 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Instrumentation;
using NzbDrone.Core.Profiles;
namespace NzbDrone.Core.Qualities
@ -20,12 +18,47 @@ namespace NzbDrone.Core.Qualities
_profile = profile;
}
public int Compare(int left, int right, bool respectGroupOrder = false)
{
var leftIndex = _profile.GetIndex(left);
var rightIndex = _profile.GetIndex(right);
return leftIndex.CompareTo(rightIndex, respectGroupOrder);
}
public int Compare(Quality left, Quality right)
{
int leftIndex = _profile.Items.FindIndex(v => v.Quality == left);
int rightIndex = _profile.Items.FindIndex(v => v.Quality == right);
return Compare(left, right, false);
}
return leftIndex.CompareTo(rightIndex);
public int Compare(Quality left, Quality right, bool respectGroupOrder)
{
var leftIndex = _profile.GetIndex(left);
var rightIndex = _profile.GetIndex(right);
return leftIndex.CompareTo(rightIndex, respectGroupOrder);
}
public int Compare(QualityModel left, QualityModel right)
{
return Compare(left, right, false);
}
public int Compare(QualityModel left, QualityModel right, bool respectGroupOrder)
{
int result = Compare(left.Quality, right.Quality, respectGroupOrder);
if (result == 0)
{
result = Compare(left.CustomFormats, right.CustomFormats);
if (result == 0)
{
result = left.Revision.CompareTo(right.Revision);
}
}
return result;
}
public int Compare(List<CustomFormat> left, List<CustomFormat> right)
@ -61,22 +94,5 @@ namespace NzbDrone.Core.Qualities
return leftIndicies.Select(i => i.CompareTo(rightIndex)).Sum();
}
public int Compare(QualityModel left, QualityModel right)
{
int result = Compare(left.Quality, right.Quality);
if (result == 0)
{
result = Compare(left.CustomFormats, right.CustomFormats);
if (result == 0)
{
result = left.Revision.CompareTo(right.Revision);
}
}
return result;
}
}
}

View File

@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Validators;
namespace Radarr.Api.V2.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.Quality == null && i.Id == cutoff) || i.Quality?.Id == cutoff);
if (cutoffItem == null) return false;
if (!cutoffItem.Allowed) return false;
return true;
}
}
}

View File

@ -0,0 +1,197 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Validators;
using NzbDrone.Common.Extensions;
namespace Radarr.Api.V2.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;
}
}
}

View File

@ -20,8 +20,10 @@ namespace Radarr.Api.V2.Profiles.Quality
_profileService = profileService;
_formatService = formatService;
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();
SharedValidator.RuleFor(c => c.Language).ValidLanguage();
SharedValidator.RuleFor(c => c.FormatItems).Must(items =>
{

View File

@ -11,18 +11,25 @@ namespace Radarr.Api.V2.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 string PreferredTags { get; set; }
public List<ProfileQualityItemResource> Items { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; }
public CustomFormatResource FormatCutoff { get; set; }
public List<ProfileFormatItemResource> FormatItems { get; set; }
public Language Language { get; set; }
}
public class ProfileQualityItemResource : RestResource
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 class ProfileFormatItemResource : RestResource
@ -40,7 +47,6 @@ namespace Radarr.Api.V2.Profiles.Quality
return new QualityProfileResource
{
Id = model.Id,
Name = model.Name,
Cutoff = model.Cutoff,
PreferredTags = model.PreferredTags != null ? string.Join(",", model.PreferredTags) : "",
@ -51,13 +57,16 @@ namespace Radarr.Api.V2.Profiles.Quality
};
}
public static ProfileQualityItemResource ToResource(this ProfileQualityItem model)
public static QualityProfileQualityItemResource ToResource(this ProfileQualityItem model)
{
if (model == null) return null;
return new ProfileQualityItemResource
return new QualityProfileQualityItemResource
{
Id = model.Id,
Name = model.Name,
Quality = model.Quality,
Items = model.Items.ConvertAll(ToResource),
Allowed = model.Allowed
};
}
@ -78,9 +87,8 @@ namespace Radarr.Api.V2.Profiles.Quality
return new Profile
{
Id = resource.Id,
Name = resource.Name,
Cutoff = (NzbDrone.Core.Qualities.Quality)resource.Cutoff.Id,
Cutoff = resource.Cutoff,
PreferredTags = resource.PreferredTags.Split(',').ToList(),
Items = resource.Items.ConvertAll(ToModel),
FormatCutoff = resource.FormatCutoff.ToModel(),
@ -89,13 +97,16 @@ namespace Radarr.Api.V2.Profiles.Quality
};
}
public static ProfileQualityItem ToModel(this ProfileQualityItemResource resource)
public static ProfileQualityItem ToModel(this QualityProfileQualityItemResource resource)
{
if (resource == null) return null;
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
};
}

View File

@ -1,54 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Qualities;
using Radarr.Http;
using Radarr.Http.Mapping;
namespace Radarr.Api.V2.Profiles.Quality
{
public class QualityProfileSchemaModule : RadarrRestModule<QualityProfileResource>
{
private readonly IQualityDefinitionService _qualityDefinitionService;
private readonly ICustomFormatService _formatService;
private readonly IProfileService _profileService;
public QualityProfileSchemaModule(IQualityDefinitionService qualityDefinitionService, ICustomFormatService formatService)
: base("/profile/schema")
public QualityProfileSchemaModule(IProfileService profileService)
: base("/qualityprofile/schema")
{
_qualityDefinitionService = qualityDefinitionService;
_formatService = formatService;
_profileService = profileService;
GetResourceAll = GetAll;
GetResourceSingle = GetSchema;
}
private List<QualityProfileResource> GetAll()
private QualityProfileResource GetSchema()
{
var items = _qualityDefinitionService.All()
.OrderBy(v => v.Weight)
.Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = false })
.ToList();
var qualityProfile = _profileService.GetDefaultProfile(string.Empty);
var formatItems = _formatService.All().Select(v => new ProfileFormatItem
{
Format = v, Allowed = true
}).ToList();
formatItems.Insert(0, new ProfileFormatItem
{
Format = CustomFormat.None,
Allowed = true
});
var profile = new Profile();
profile.Cutoff = NzbDrone.Core.Qualities.Quality.Unknown;
profile.Items = items;
profile.FormatCutoff = CustomFormat.None;
profile.FormatItems = formatItems;
profile.Language = Language.English;
return new List<QualityProfileResource> { profile.ToResource() };
return qualityProfile.ToResource();
}
}
}

View File

@ -1,43 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Validators;
namespace Radarr.Api.V2.Profiles.Quality
{
public static class QualityProfileValidation
{
public static IRuleBuilderOptions<T, IList<ProfileQualityItemResource>> MustHaveAllowedQuality<T>(this IRuleBuilder<T, IList<ProfileQualityItemResource>> 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<ProfileQualityItemResource>;
if (list == null)
{
return false;
}
if (!list.Any(c => c.Allowed))
{
return false;
}
return true;
}
}
}

View File

@ -153,10 +153,11 @@
<Compile Include="Profiles\Delay\DelayProfileResource.cs" />
<Compile Include="Profiles\Languages\LanguageModule.cs" />
<Compile Include="Profiles\Languages\LanguageResource.cs" />
<Compile Include="Profiles\Quality\QualityCutoffValidator.cs" />
<Compile Include="Profiles\Quality\QualityItemsValidator.cs" />
<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" />