mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2026-03-05 13:40:08 -05:00
Compare commits
70 Commits
v1.6.1.356
...
v1.7.1.368
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193335e2a8 | ||
|
|
1c98727cf3 | ||
|
|
ab5b321385 | ||
|
|
96340909f1 | ||
|
|
bd6a37dc8c | ||
|
|
a663cebada | ||
|
|
2ce5618499 | ||
|
|
94c91d4c3f | ||
|
|
79fbb2d0d7 | ||
|
|
e2e52746bb | ||
|
|
21cc96d683 | ||
|
|
e68b45636e | ||
|
|
ce68fe4105 | ||
|
|
712404ddca | ||
|
|
826828e8ec | ||
|
|
252740519f | ||
|
|
062fd77e1b | ||
|
|
6769055b6b | ||
|
|
90e92c0b66 | ||
|
|
7eac11f57a | ||
|
|
02a3c1b224 | ||
|
|
57efa6d0b1 | ||
|
|
cee52147bc | ||
|
|
a1abcd6c93 | ||
|
|
18e2757d37 | ||
|
|
8790a6f06a | ||
|
|
4fafdb2cd2 | ||
|
|
bfc06fc8bc | ||
|
|
9f4f6a5726 | ||
|
|
d9ace9a862 | ||
|
|
95691c7476 | ||
|
|
90f2020e59 | ||
|
|
6afa1dc8ba | ||
|
|
e8139f2a5b | ||
|
|
45328db2c7 | ||
|
|
e55d6b827a | ||
|
|
34cd68fa07 | ||
|
|
aed3f9f887 | ||
|
|
6880e67507 | ||
|
|
e0e1b1494e | ||
|
|
20df31919d | ||
|
|
8785fe02e8 | ||
|
|
b2b877a8c3 | ||
|
|
0de302ad48 | ||
|
|
06391489cf | ||
|
|
8fcceb0702 | ||
|
|
f20319fff1 | ||
|
|
20bcc00662 | ||
|
|
c4af3e746f | ||
|
|
660a162b7e | ||
|
|
20a3cad7fb | ||
|
|
77fe3f78fe | ||
|
|
d777cb8e29 | ||
|
|
15e7cc7ea8 | ||
|
|
04cf061275 | ||
|
|
d4cdeac69a | ||
|
|
e60fe05ee0 | ||
|
|
9a4c23797a | ||
|
|
acfdb5bae3 | ||
|
|
e2e65627ee | ||
|
|
4b8906ea62 | ||
|
|
f0c5d8ceea | ||
|
|
427802a50e | ||
|
|
0c9eae244a | ||
|
|
75ff2f41d3 | ||
|
|
d1ba208243 | ||
|
|
4e03ebadc4 | ||
|
|
0155ff60fd | ||
|
|
f0915638f3 | ||
|
|
56eb58aed1 |
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -74,7 +74,7 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Trace Logs have been provided as applicable. Reports may be closed if the required logs are not provided.
|
||||
description: Trace logs are generally required for all bug reports
|
||||
description: Trace logs are generally required for all bug reports and contain `trace`. Info logs are invalid for bug reports and do not contain `debug` nor `trace`
|
||||
options:
|
||||
- label: I have followed the steps in the wiki link above and provided the required trace logs that are relevant and show this issue.
|
||||
- label: I have read and followed the steps in the wiki link above and provided the required trace logs - the logs contain `trace` - that are relevant and show this issue.
|
||||
required: true
|
||||
|
||||
2
.github/label-actions.yml
vendored
2
.github/label-actions.yml
vendored
@@ -7,6 +7,7 @@
|
||||
to be a support request. Please hop over onto our [Discord](https://prowlarr.com/discord)
|
||||
or [Subreddit](https://reddit.com/r/prowlarr)
|
||||
close: true
|
||||
close-reason: 'not planned'
|
||||
|
||||
'Type: Indexer Request':
|
||||
comment: >
|
||||
@@ -14,6 +15,7 @@
|
||||
for bug reports and feature requests. However, this issue appears
|
||||
to be a indexer request. Please use our Indexer request [site](https://requests.prowlarr.com/)
|
||||
close: true
|
||||
close-reason: 'not planned'
|
||||
|
||||
'Status: Logs Needed':
|
||||
comment: >
|
||||
|
||||
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '1.6.1'
|
||||
majorVersion: '1.7.1'
|
||||
minorVersion: $[counter('minorVersion', 1)]
|
||||
prowlarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
|
||||
@@ -362,7 +362,7 @@ stages:
|
||||
- bash: |
|
||||
echo "Uploading source maps to sentry"
|
||||
curl -sL https://sentry.io/get-cli/ | bash
|
||||
RELEASENAME="${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
|
||||
RELEASENAME="Prowlarr@${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
|
||||
sentry-cli releases new --finalize -p prowlarr -p prowlarr-ui -p prowlarr-update "${RELEASENAME}"
|
||||
sentry-cli releases -p prowlarr-ui files "${RELEASENAME}" upload-sourcemaps _output/UI/ --rewrite
|
||||
sentry-cli releases set-commits --auto "${RELEASENAME}"
|
||||
@@ -1003,7 +1003,7 @@ stages:
|
||||
git add .
|
||||
if git status | grep -q modified
|
||||
then
|
||||
git commit -am 'Automated API Docs update'
|
||||
git commit -am 'Automated API Docs update [skip ci]'
|
||||
git push -f --set-upstream origin api-docs
|
||||
curl -X POST -H "Authorization: token ${GITHUBTOKEN}" -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/prowlarr/prowlarr/pulls -d '{"head":"api-docs","base":"develop","title":"Update API docs"}'
|
||||
else
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SelectInput from './SelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
@@ -23,7 +24,7 @@ function createMapStateToProps() {
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
value: translate('NoChange'),
|
||||
disabled: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.downloadClients,
|
||||
(state, { includeAny }) => includeAny,
|
||||
(state, { protocol }) => protocol,
|
||||
(downloadClients, includeAny, protocolFilter) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = downloadClients;
|
||||
|
||||
const values = items
|
||||
.filter((downloadClient) => downloadClient.protocol === protocolFilter)
|
||||
.sort(sortByName)
|
||||
.map((downloadClient) => ({
|
||||
key: downloadClient.id,
|
||||
value: downloadClient.name
|
||||
}));
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: '(Any)'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
values
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchDownloadClients: fetchDownloadClients
|
||||
};
|
||||
|
||||
class DownloadClientSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.isPopulated) {
|
||||
this.props.dispatchFetchDownloadClients();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = ({ name, value }) => {
|
||||
this.props.onChange({ name, value: parseInt(value) });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DownloadClientSelectInputConnector.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
includeAny: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
dispatchFetchDownloadClients: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
DownloadClientSelectInputConnector.defaultProps = {
|
||||
includeAny: false,
|
||||
protocol: 'torrent'
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector);
|
||||
@@ -10,6 +10,7 @@ import CaptchaInputConnector from './CaptchaInputConnector';
|
||||
import CardigannCaptchaInputConnector from './CardigannCaptchaInputConnector';
|
||||
import CheckInput from './CheckInput';
|
||||
import DeviceInputConnector from './DeviceInputConnector';
|
||||
import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
@@ -72,6 +73,9 @@ function getComponent(type) {
|
||||
case inputTypes.CATEGORY_SELECT:
|
||||
return NewznabCategorySelectInputConnector;
|
||||
|
||||
case inputTypes.DOWNLOAD_CLIENT_SELECT:
|
||||
return DownloadClientSelectInputConnector;
|
||||
|
||||
case inputTypes.INDEXER_FLAGS_SELECT:
|
||||
return IndexerFlagsSelectInputConnector;
|
||||
|
||||
@@ -258,6 +262,8 @@ FormInputGroup.propTypes = {
|
||||
values: PropTypes.arrayOf(PropTypes.any),
|
||||
type: PropTypes.string.isRequired,
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
min: PropTypes.number,
|
||||
max: PropTypes.number,
|
||||
unit: PropTypes.string,
|
||||
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
|
||||
helpText: PropTypes.string,
|
||||
|
||||
@@ -12,7 +12,7 @@ function createMapStateToProps() {
|
||||
(state) => state.indexers,
|
||||
(value, indexers) => {
|
||||
const values = [];
|
||||
const groupedIndexers = _(indexers.items).groupBy((x) => x.protocol).map((val, key) => ({ protocol: key, indexers: val })).value();
|
||||
const groupedIndexers = _.map(_.groupBy(indexers.items, 'protocol'), (val, key) => ({ protocol: key, indexers: val }));
|
||||
|
||||
groupedIndexers.forEach((element) => {
|
||||
values.push({
|
||||
@@ -21,10 +21,11 @@ function createMapStateToProps() {
|
||||
});
|
||||
|
||||
if (element.indexers && element.indexers.length > 0) {
|
||||
element.indexers.forEach((subCat) => {
|
||||
element.indexers.forEach((indexer) => {
|
||||
values.push({
|
||||
key: subCat.id,
|
||||
value: subCat.name,
|
||||
key: indexer.id,
|
||||
value: indexer.name,
|
||||
isDisabled: !indexer.enable,
|
||||
parentKey: element.protocol === 'usenet' ? -1 : -2
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,8 @@ class VirtualTable extends Component {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
width: 0
|
||||
width: 0,
|
||||
scrollRestored: false
|
||||
};
|
||||
|
||||
this._grid = null;
|
||||
@@ -48,20 +49,25 @@ class VirtualTable extends Component {
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
items,
|
||||
scrollIndex
|
||||
scrollIndex,
|
||||
scrollTop
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
width
|
||||
width,
|
||||
scrollRestored
|
||||
} = this.state;
|
||||
|
||||
if (this._grid &&
|
||||
(prevState.width !== width ||
|
||||
hasDifferentItemsOrOrder(prevProps.items, items))) {
|
||||
if (this._grid && (prevState.width !== width || hasDifferentItemsOrOrder(prevProps.items, items))) {
|
||||
// recomputeGridSize also forces Grid to discard its cache of rendered cells
|
||||
this._grid.recomputeGridSize();
|
||||
}
|
||||
|
||||
if (this._grid && scrollTop !== undefined && scrollTop !== 0 && !scrollRestored) {
|
||||
this.setState({ scrollRestored: true });
|
||||
this._grid.scrollToPosition({ scrollTop });
|
||||
}
|
||||
|
||||
if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
|
||||
this._grid.scrollToCell({
|
||||
rowIndex: scrollIndex,
|
||||
@@ -98,6 +104,7 @@ class VirtualTable extends Component {
|
||||
focusScroller,
|
||||
header,
|
||||
headerHeight,
|
||||
rowHeight,
|
||||
rowRenderer,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
@@ -141,6 +148,7 @@ class VirtualTable extends Component {
|
||||
{header}
|
||||
<div ref={registerChild}>
|
||||
<Grid
|
||||
{...otherProps}
|
||||
ref={this.setGridRef}
|
||||
autoContainerWidth={true}
|
||||
autoHeight={true}
|
||||
@@ -148,7 +156,7 @@ class VirtualTable extends Component {
|
||||
width={width}
|
||||
height={height}
|
||||
headerHeight={height - headerHeight}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowHeight={rowHeight}
|
||||
rowCount={items.length}
|
||||
columnCount={1}
|
||||
columnWidth={width}
|
||||
@@ -162,7 +170,6 @@ class VirtualTable extends Component {
|
||||
className={styles.tableBodyContainer}
|
||||
style={gridStyle}
|
||||
containerStyle={containerStyle}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
</Scroller>
|
||||
@@ -180,16 +187,19 @@ VirtualTable.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
scrollIndex: PropTypes.number,
|
||||
scrollTop: PropTypes.number,
|
||||
scroller: PropTypes.instanceOf(Element).isRequired,
|
||||
focusScroller: PropTypes.bool.isRequired,
|
||||
header: PropTypes.node.isRequired,
|
||||
headerHeight: PropTypes.number.isRequired,
|
||||
rowRenderer: PropTypes.func.isRequired
|
||||
rowRenderer: PropTypes.func.isRequired,
|
||||
rowHeight: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
VirtualTable.defaultProps = {
|
||||
className: styles.tableContainer,
|
||||
headerHeight: 38,
|
||||
rowHeight: ROW_HEIGHT,
|
||||
focusScroller: true
|
||||
};
|
||||
|
||||
|
||||
@@ -67,8 +67,10 @@ function keyboardShortcuts(WrappedComponent) {
|
||||
};
|
||||
|
||||
unbindShortcut = (key) => {
|
||||
delete this._mousetrapBindings[key];
|
||||
this._mousetrap.unbind(key);
|
||||
if (this._mousetrap != null) {
|
||||
delete this._mousetrapBindings[key];
|
||||
this._mousetrap.unbind(key);
|
||||
}
|
||||
};
|
||||
|
||||
unbindAllShortcuts = () => {
|
||||
|
||||
11
frontend/src/Helpers/Hooks/usePrevious.tsx
Normal file
11
frontend/src/Helpers/Hooks/usePrevious.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const KEY_VALUE_LIST = 'keyValueList';
|
||||
export const INFO = 'info';
|
||||
export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect';
|
||||
export const CATEGORY_SELECT = 'newznabCategorySelect';
|
||||
export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect';
|
||||
export const NUMBER = 'number';
|
||||
export const OAUTH = 'oauth';
|
||||
export const PASSWORD = 'password';
|
||||
|
||||
@@ -18,6 +18,8 @@ function HistoryDetails(props) {
|
||||
query,
|
||||
queryResults,
|
||||
categories,
|
||||
limit,
|
||||
offset,
|
||||
source,
|
||||
url
|
||||
} = data;
|
||||
@@ -31,43 +33,66 @@ function HistoryDetails(props) {
|
||||
/>
|
||||
|
||||
{
|
||||
!!indexer &&
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer.name}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('QueryResults')}
|
||||
data={queryResults ? queryResults : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Categories')}
|
||||
data={categories ? categories : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
limit ?
|
||||
<DescriptionListItem
|
||||
title={translate('Limit')}
|
||||
data={limit}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
offset ?
|
||||
<DescriptionListItem
|
||||
title={translate('Offset')}
|
||||
data={offset}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Source')}
|
||||
data={source}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Url')}
|
||||
data={url ? <Link to={url}>{translate('Link')}</Link> : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
@@ -76,42 +101,46 @@ function HistoryDetails(props) {
|
||||
if (eventType === 'releaseGrabbed') {
|
||||
const {
|
||||
source,
|
||||
title,
|
||||
grabTitle,
|
||||
url
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
{
|
||||
!!indexer &&
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer.name}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Source')}
|
||||
data={source ? source : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Title')}
|
||||
data={title ? title : '-'}
|
||||
/>
|
||||
title={translate('GrabTitle')}
|
||||
data={grabTitle ? grabTitle : '-'}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!data &&
|
||||
data ?
|
||||
<DescriptionListItem
|
||||
title={translate('Url')}
|
||||
data={url ? <Link to={url}>{translate('Link')}</Link> : '-'}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
@@ -124,11 +153,12 @@ function HistoryDetails(props) {
|
||||
title={translate('Auth')}
|
||||
>
|
||||
{
|
||||
!!indexer &&
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer.name}
|
||||
/>
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.parameters {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
.parametersContent {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
2
frontend/src/History/HistoryRow.css.d.ts
vendored
2
frontend/src/History/HistoryRow.css.d.ts
vendored
@@ -6,7 +6,7 @@ interface CssExports {
|
||||
'details': string;
|
||||
'elapsedTime': string;
|
||||
'indexer': string;
|
||||
'parameters': string;
|
||||
'parametersContent': string;
|
||||
'query': string;
|
||||
'releaseGroup': string;
|
||||
'source': string;
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import CapabilitiesLabel from 'Indexer/Index/Table/CapabilitiesLabel';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryDetailsModal from './Details/HistoryDetailsModal';
|
||||
import * as historyDataTypes from './historyDataTypes';
|
||||
import HistoryEventTypeCell from './HistoryEventTypeCell';
|
||||
import HistoryRowParameter from './HistoryRowParameter';
|
||||
import styles from './HistoryRow.css';
|
||||
|
||||
const historyParameters = [
|
||||
{ key: historyDataTypes.IMDB_ID, title: 'IMDb' },
|
||||
{ key: historyDataTypes.TMDB_ID, title: 'TMDb' },
|
||||
{ key: historyDataTypes.TVDB_ID, title: 'TVDb' },
|
||||
{ key: historyDataTypes.TRAKT_ID, title: 'Trakt' },
|
||||
{ key: historyDataTypes.R_ID, title: 'TvRage' },
|
||||
{ key: historyDataTypes.TVMAZE_ID, title: 'TvMaze' },
|
||||
{ key: historyDataTypes.SEASON, title: translate('Season') },
|
||||
{ key: historyDataTypes.EPISODE, title: translate('Episode') },
|
||||
{ key: historyDataTypes.ARTIST, title: translate('Artist') },
|
||||
{ key: historyDataTypes.ALBUM, title: translate('Album') },
|
||||
{ key: historyDataTypes.LABEL, title: translate('Label') },
|
||||
{ key: historyDataTypes.TRACK, title: translate('Track') },
|
||||
{ key: historyDataTypes.YEAR, title: translate('Year') },
|
||||
{ key: historyDataTypes.GENRE, title: translate('Genre') },
|
||||
{ key: historyDataTypes.AUTHOR, title: translate('Author') },
|
||||
{ key: historyDataTypes.TITLE, title: translate('Title') },
|
||||
{ key: historyDataTypes.PUBLISHER, title: translate('Publisher') }
|
||||
];
|
||||
|
||||
class HistoryRow extends Component {
|
||||
|
||||
//
|
||||
@@ -44,15 +66,52 @@ class HistoryRow extends Component {
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
const { query, queryType, limit, offset } = data;
|
||||
|
||||
let searchQuery = query;
|
||||
let categories = [];
|
||||
|
||||
if (data.categories) {
|
||||
categories = data.categories.split(',').map((item) => {
|
||||
return parseInt(item);
|
||||
});
|
||||
categories = data.categories.split(',').map((item) => parseInt(item));
|
||||
}
|
||||
|
||||
this.props.onSearchPress(data.query, indexer.id, categories);
|
||||
const searchParams = [
|
||||
historyDataTypes.IMDB_ID,
|
||||
historyDataTypes.TMDB_ID,
|
||||
historyDataTypes.TVDB_ID,
|
||||
historyDataTypes.TRAKT_ID,
|
||||
historyDataTypes.R_ID,
|
||||
historyDataTypes.TVMAZE_ID,
|
||||
historyDataTypes.SEASON,
|
||||
historyDataTypes.EPISODE,
|
||||
historyDataTypes.ARTIST,
|
||||
historyDataTypes.ALBUM,
|
||||
historyDataTypes.LABEL,
|
||||
historyDataTypes.TRACK,
|
||||
historyDataTypes.YEAR,
|
||||
historyDataTypes.GENRE,
|
||||
historyDataTypes.AUTHOR,
|
||||
historyDataTypes.TITLE,
|
||||
historyDataTypes.PUBLISHER
|
||||
]
|
||||
.reduce((acc, key) => {
|
||||
if (key in data && data[key].length > 0) {
|
||||
const value = data[key];
|
||||
|
||||
acc.push({ key, value });
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [])
|
||||
.map((item) => `{${item.key}:${item.value}}`)
|
||||
.join('')
|
||||
;
|
||||
|
||||
if (searchParams.length > 0) {
|
||||
searchQuery += `${searchParams}`;
|
||||
}
|
||||
|
||||
this.props.onSearchPress(searchQuery, indexer.id, categories, queryType, parseInt(limit), parseInt(offset));
|
||||
};
|
||||
|
||||
onDetailsPress = () => {
|
||||
@@ -84,6 +143,8 @@ class HistoryRow extends Component {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parameters = historyParameters.filter((parameter) => parameter.key in data && data[parameter.key]);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{
|
||||
@@ -133,162 +194,19 @@ class HistoryRow extends Component {
|
||||
|
||||
if (name === 'parameters') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.parameters}
|
||||
>
|
||||
{
|
||||
data.imdbId ?
|
||||
<HistoryRowParameter
|
||||
title='IMDb'
|
||||
value={data.imdbId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.tmdbId ?
|
||||
<HistoryRowParameter
|
||||
title='TMDb'
|
||||
value={data.tmdbId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.tvdbId ?
|
||||
<HistoryRowParameter
|
||||
title='TVDb'
|
||||
value={data.tvdbId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.traktId ?
|
||||
<HistoryRowParameter
|
||||
title='Trakt'
|
||||
value={data.traktId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.rId ?
|
||||
<HistoryRowParameter
|
||||
title='TvRage'
|
||||
value={data.rId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.tvMazeId ?
|
||||
<HistoryRowParameter
|
||||
title='TvMaze'
|
||||
value={data.tvMazeId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.season ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Season')}
|
||||
value={data.season}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.episode ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Episode')}
|
||||
value={data.episode}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.artist ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Artist')}
|
||||
value={data.artist}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.album ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Album')}
|
||||
value={data.album}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.label ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Label')}
|
||||
value={data.label}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.track ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Track')}
|
||||
value={data.track}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.year ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Year')}
|
||||
value={data.year}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.genre ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Genre')}
|
||||
value={data.genre}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.author ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Author')}
|
||||
value={data.author}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.bookTitle ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Book')}
|
||||
value={data.bookTitle}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
data.publisher ?
|
||||
<HistoryRowParameter
|
||||
title={translate('Publisher')}
|
||||
value={data.publisher}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
<TableRowCell key={name}>
|
||||
<div className={styles.parametersContent}>
|
||||
{parameters.map((parameter) => {
|
||||
return (
|
||||
<HistoryRowParameter
|
||||
key={parameter.key}
|
||||
title={parameter.title}
|
||||
value={data[parameter.key]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
@@ -300,8 +218,25 @@ class HistoryRow extends Component {
|
||||
className={styles.indexer}
|
||||
>
|
||||
{
|
||||
data.title ?
|
||||
data.title :
|
||||
data.grabTitle ?
|
||||
data.grabTitle :
|
||||
null
|
||||
}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'queryType') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.query}
|
||||
>
|
||||
{
|
||||
data.queryType ?
|
||||
<Label kind={kinds.INFO}>
|
||||
{data.queryType}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</TableRowCell>
|
||||
@@ -377,6 +312,12 @@ class HistoryRow extends Component {
|
||||
key={name}
|
||||
className={styles.details}
|
||||
>
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
onPress={this.onDetailsPress}
|
||||
title={translate('HistoryDetails')}
|
||||
/>
|
||||
|
||||
{
|
||||
eventType === 'indexerQuery' ?
|
||||
<IconButton
|
||||
@@ -386,11 +327,6 @@ class HistoryRow extends Component {
|
||||
/> :
|
||||
null
|
||||
}
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
onPress={this.onDetailsPress}
|
||||
title={translate('HistoryDetails')}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -48,8 +49,15 @@ class HistoryRowConnector extends Component {
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSearchPress = (term, indexerId, categories) => {
|
||||
this.props.setSearchDefault({ searchQuery: term, searchIndexerIds: [indexerId], searchCategories: categories });
|
||||
onSearchPress = (query, indexerId, categories, type, limit, offset) => {
|
||||
this.props.setSearchDefault(_.pickBy({
|
||||
searchQuery: query,
|
||||
searchIndexerIds: [indexerId],
|
||||
searchCategories: categories,
|
||||
searchType: type,
|
||||
searchLimit: limit,
|
||||
searchOffset: offset
|
||||
}));
|
||||
this.props.push(`${window.Prowlarr.urlBase}/search`);
|
||||
};
|
||||
|
||||
|
||||
17
frontend/src/History/historyDataTypes.js
Normal file
17
frontend/src/History/historyDataTypes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export const IMDB_ID = 'imdbId';
|
||||
export const TMDB_ID = 'tmdbId';
|
||||
export const TVDB_ID = 'tvdbId';
|
||||
export const TRAKT_ID = 'traktId';
|
||||
export const R_ID = 'rId';
|
||||
export const TVMAZE_ID = 'tvMazeId';
|
||||
export const SEASON = 'season';
|
||||
export const EPISODE = 'episode';
|
||||
export const ARTIST = 'artist';
|
||||
export const ALBUM = 'album';
|
||||
export const LABEL = 'label';
|
||||
export const TRACK = 'track';
|
||||
export const YEAR = 'year';
|
||||
export const GENRE = 'genre';
|
||||
export const AUTHOR = 'author';
|
||||
export const TITLE = 'title';
|
||||
export const PUBLISHER = 'publisher';
|
||||
@@ -40,6 +40,7 @@
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
margin-right: 12px;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.filterContainer:last-child {
|
||||
|
||||
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput';
|
||||
import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -89,7 +90,8 @@ class AddIndexerModalContent extends Component {
|
||||
filter: '',
|
||||
filterProtocols: [],
|
||||
filterLanguages: [],
|
||||
filterPrivacyLevels: []
|
||||
filterPrivacyLevels: [],
|
||||
filterCategories: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +123,13 @@ class AddIndexerModalContent extends Component {
|
||||
.map((language) => ({ key: language, value: language }));
|
||||
|
||||
const filteredIndexers = indexers.filter((indexer) => {
|
||||
const { filter, filterProtocols, filterLanguages, filterPrivacyLevels } = this.state;
|
||||
const {
|
||||
filter,
|
||||
filterProtocols,
|
||||
filterLanguages,
|
||||
filterPrivacyLevels,
|
||||
filterCategories
|
||||
} = this.state;
|
||||
|
||||
if (!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase())) {
|
||||
return false;
|
||||
@@ -139,6 +147,18 @@ class AddIndexerModalContent extends Component {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterCategories.length) {
|
||||
const { categories = [] } = indexer.capabilities || {};
|
||||
const flat = ({ id, subCategories = [] }) => [id, ...subCategories.flatMap(flat)];
|
||||
const flatCategories = categories
|
||||
.filter((item) => item.id < 100000)
|
||||
.flatMap(flat);
|
||||
|
||||
if (!filterCategories.every((item) => flatCategories.includes(item))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -165,7 +185,7 @@ class AddIndexerModalContent extends Component {
|
||||
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>Protocol</label>
|
||||
<label className={styles.filterLabel}>{translate('Protocol')}</label>
|
||||
<EnhancedSelectInput
|
||||
name="indexerProtocols"
|
||||
value={this.state.filterProtocols}
|
||||
@@ -175,7 +195,7 @@ class AddIndexerModalContent extends Component {
|
||||
</div>
|
||||
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>Language</label>
|
||||
<label className={styles.filterLabel}>{translate('Language')}</label>
|
||||
<EnhancedSelectInput
|
||||
name="indexerLanguages"
|
||||
value={this.state.filterLanguages}
|
||||
@@ -185,7 +205,7 @@ class AddIndexerModalContent extends Component {
|
||||
</div>
|
||||
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>Privacy</label>
|
||||
<label className={styles.filterLabel}>{translate('Privacy')}</label>
|
||||
<EnhancedSelectInput
|
||||
name="indexerPrivacyLevels"
|
||||
value={this.state.filterPrivacyLevels}
|
||||
@@ -193,6 +213,15 @@ class AddIndexerModalContent extends Component {
|
||||
onChange={({ value }) => this.setState({ filterPrivacyLevels: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterContainer}>
|
||||
<label className={styles.filterLabel}>{translate('Categories')}</label>
|
||||
<NewznabCategorySelectInputConnector
|
||||
name="indexerCategories"
|
||||
value={this.state.filterCategories}
|
||||
onChange={({ value }) => this.setState({ filterCategories: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
@@ -212,7 +241,7 @@ class AddIndexerModalContent extends Component {
|
||||
isFetching ? <LoadingIndicator /> : null
|
||||
}
|
||||
{
|
||||
error ? <div>{errorMessage}</div> : null
|
||||
error ? <Alert kind={kinds.DANGER}>{errorMessage}</Alert> : null
|
||||
}
|
||||
{
|
||||
isPopulated && !!indexers.length ?
|
||||
@@ -237,6 +266,15 @@ class AddIndexerModalContent extends Component {
|
||||
</Table> :
|
||||
null
|
||||
}
|
||||
{
|
||||
isPopulated && !!indexers.length && !filteredIndexers.length ?
|
||||
<Alert
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
{translate('NoIndexersFound')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ function EditIndexerModalContent(props) {
|
||||
isTesting,
|
||||
saveError,
|
||||
item,
|
||||
hasUsenetDownloadClients,
|
||||
hasTorrentDownloadClients,
|
||||
onInputChange,
|
||||
onFieldChange,
|
||||
onModalClose,
|
||||
@@ -48,10 +50,13 @@ function EditIndexerModalContent(props) {
|
||||
appProfileId,
|
||||
tags,
|
||||
fields,
|
||||
priority
|
||||
priority,
|
||||
protocol,
|
||||
downloadClientId
|
||||
} = item;
|
||||
|
||||
const indexerDisplayName = implementationName === definitionName ? implementationName : `${implementationName} (${definitionName})`;
|
||||
const showDownloadClientInput = downloadClientId.value > 0 || protocol.value === 'usenet' && hasUsenetDownloadClients || protocol.value === 'torrent' && hasTorrentDownloadClients;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
@@ -156,6 +161,25 @@ function EditIndexerModalContent(props) {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{showDownloadClientInput ?
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('DownloadClient')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.DOWNLOAD_CLIENT_SELECT}
|
||||
name="downloadClientId"
|
||||
helpText={translate('IndexerDownloadClientHelpText')}
|
||||
{...downloadClientId}
|
||||
includeAny={true}
|
||||
protocol={protocol.value}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup> : null
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
@@ -222,6 +246,8 @@ EditIndexerModalContent.propTypes = {
|
||||
isTesting: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
hasUsenetDownloadClients: PropTypes.bool.isRequired,
|
||||
hasTorrentDownloadClients: PropTypes.bool.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onFieldChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
|
||||
@@ -3,17 +3,23 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { saveIndexer, setIndexerFieldValue, setIndexerValue, testIndexer } from 'Store/Actions/indexerActions';
|
||||
import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
|
||||
import { fetchDownloadClients, toggleAdvancedSettings } from 'Store/Actions/settingsActions';
|
||||
import createIndexerSchemaSelector from 'Store/Selectors/createIndexerSchemaSelector';
|
||||
import EditIndexerModalContent from './EditIndexerModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.advancedSettings,
|
||||
(state) => state.settings.downloadClients,
|
||||
createIndexerSchemaSelector(),
|
||||
(advancedSettings, indexer) => {
|
||||
(advancedSettings, downloadClients, indexer) => {
|
||||
const usenetDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'usenet');
|
||||
const torrentDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'torrent');
|
||||
|
||||
return {
|
||||
advancedSettings,
|
||||
hasUsenetDownloadClients: usenetDownloadClients.length > 0,
|
||||
hasTorrentDownloadClients: torrentDownloadClients.length > 0,
|
||||
...indexer
|
||||
};
|
||||
}
|
||||
@@ -25,7 +31,8 @@ const mapDispatchToProps = {
|
||||
setIndexerFieldValue,
|
||||
saveIndexer,
|
||||
testIndexer,
|
||||
toggleAdvancedSettings
|
||||
toggleAdvancedSettings,
|
||||
dispatchFetchDownloadClients: fetchDownloadClients
|
||||
};
|
||||
|
||||
class EditIndexerModalContentConnector extends Component {
|
||||
@@ -33,6 +40,10 @@ class EditIndexerModalContentConnector extends Component {
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatchFetchDownloadClients();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
@@ -90,7 +101,8 @@ EditIndexerModalContentConnector.propTypes = {
|
||||
toggleAdvancedSettings: PropTypes.func.isRequired,
|
||||
saveIndexer: PropTypes.func.isRequired,
|
||||
testIndexer: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
dispatchFetchDownloadClients: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector);
|
||||
|
||||
@@ -148,17 +148,17 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
);
|
||||
|
||||
const jumpBarItems = useMemo(() => {
|
||||
// Reset if not sorting by sortTitle
|
||||
if (sortKey !== 'sortTitle') {
|
||||
// Reset if not sorting by sortName
|
||||
if (sortKey !== 'sortName') {
|
||||
return {
|
||||
order: [],
|
||||
};
|
||||
}
|
||||
|
||||
const characters = items.reduce((acc, item) => {
|
||||
let char = item.sortTitle.charAt(0);
|
||||
let char = item.sortName.charAt(0);
|
||||
|
||||
if (!isNaN(char)) {
|
||||
if (!isNaN(Number(char))) {
|
||||
char = '#';
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
label={
|
||||
isSelectMode
|
||||
? translate('StopSelecting')
|
||||
: translate('SelectIndexer')
|
||||
: translate('SelectIndexers')
|
||||
}
|
||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||
isSelectMode={isSelectMode}
|
||||
|
||||
@@ -9,6 +9,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { bulkDeleteIndexers } from 'Store/Actions/indexerIndexActions';
|
||||
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './DeleteIndexerModalContent.css';
|
||||
|
||||
interface DeleteIndexerModalContentProps {
|
||||
@@ -19,16 +20,16 @@ interface DeleteIndexerModalContentProps {
|
||||
function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
||||
const { indexerIds, onModalClose } = props;
|
||||
|
||||
const allIndexer = useSelector(createAllIndexersSelector());
|
||||
const allIndexers = useSelector(createAllIndexersSelector());
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const indexers = useMemo(() => {
|
||||
const selectedIndexers = useMemo(() => {
|
||||
const indexers = indexerIds.map((id) => {
|
||||
return allIndexer.find((s) => s.id === id);
|
||||
return allIndexers.find((s) => s.id === id);
|
||||
});
|
||||
|
||||
return orderBy(indexers, ['sortTitle']);
|
||||
}, [indexerIds, allIndexer]);
|
||||
return orderBy(indexers, ['sortName']);
|
||||
}, [indexerIds, allIndexers]);
|
||||
|
||||
const onDeleteIndexerConfirmed = useCallback(() => {
|
||||
dispatch(
|
||||
@@ -42,17 +43,19 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>Delete Selected Indexer</ModalHeader>
|
||||
<ModalHeader>{translate('DeleteSelectedIndexers')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.message}>
|
||||
{`Are you sure you want to delete ${indexers.length} selected indexers?`}
|
||||
{translate('DeleteSelectedIndexersMessageText', [
|
||||
selectedIndexers.length,
|
||||
])}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{indexers.map((s) => {
|
||||
{selectedIndexers.map((s) => {
|
||||
return (
|
||||
<li key={s.name}>
|
||||
<li key={s.id}>
|
||||
<span>{s.name}</span>
|
||||
</li>
|
||||
);
|
||||
@@ -61,10 +64,10 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>Cancel</Button>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.DANGER} onPress={onDeleteIndexerConfirmed}>
|
||||
Delete
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -14,6 +14,7 @@ import styles from './EditIndexerModalContent.css';
|
||||
interface SavePayload {
|
||||
enable?: boolean;
|
||||
appProfileId?: number;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
interface EditIndexerModalContentProps {
|
||||
@@ -35,6 +36,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
|
||||
const [enable, setEnable] = useState(NO_CHANGE);
|
||||
const [appProfileId, setAppProfileId] = useState<string | number>(NO_CHANGE);
|
||||
const [priority, setPriority] = useState<null | string | number>(null);
|
||||
|
||||
const save = useCallback(() => {
|
||||
let hasChanges = false;
|
||||
@@ -50,12 +52,17 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
payload.appProfileId = appProfileId as number;
|
||||
}
|
||||
|
||||
if (priority !== null) {
|
||||
hasChanges = true;
|
||||
payload.priority = priority as number;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
onSavePress(payload);
|
||||
}
|
||||
|
||||
onModalClose();
|
||||
}, [enable, appProfileId, onSavePress, onModalClose]);
|
||||
}, [enable, appProfileId, priority, onSavePress, onModalClose]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
({ name, value }) => {
|
||||
@@ -66,8 +73,11 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
case 'appProfileId':
|
||||
setAppProfileId(value);
|
||||
break;
|
||||
case 'priority':
|
||||
setPriority(value);
|
||||
break;
|
||||
default:
|
||||
console.warn('EditIndexerModalContent Unknown Input');
|
||||
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
|
||||
}
|
||||
},
|
||||
[setEnable]
|
||||
@@ -81,7 +91,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('Edit Selected Indexer')}</ModalHeader>
|
||||
<ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
@@ -108,18 +118,31 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Priority')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="priority"
|
||||
value={priority}
|
||||
min={1}
|
||||
max={50}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div className={styles.selected}>
|
||||
{translate('{0} indexers selected', selectedCount.toString())}
|
||||
{translate('CountIndexersSelected', [selectedCount])}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button onPress={onSavePressWrapper}>
|
||||
{translate('Apply Changes')}
|
||||
{translate('ApplyChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSelector } from 'reselect';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { saveIndexerEditor } from 'Store/Actions/indexerIndexActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -13,7 +14,7 @@ import EditIndexerModal from './Edit/EditIndexerModal';
|
||||
import TagsModal from './Tags/TagsModal';
|
||||
import styles from './IndexerIndexSelectFooter.css';
|
||||
|
||||
const seriesEditorSelector = createSelector(
|
||||
const indexersEditorSelector = createSelector(
|
||||
(state) => state.indexers,
|
||||
(indexers) => {
|
||||
const { isSaving, isDeleting, deleteError } = indexers;
|
||||
@@ -27,8 +28,9 @@ const seriesEditorSelector = createSelector(
|
||||
);
|
||||
|
||||
function IndexerIndexSelectFooter() {
|
||||
const { isSaving, isDeleting, deleteError } =
|
||||
useSelector(seriesEditorSelector);
|
||||
const { isSaving, isDeleting, deleteError } = useSelector(
|
||||
indexersEditorSelector
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -37,6 +39,7 @@ function IndexerIndexSelectFooter() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isSavingIndexer, setIsSavingIndexer] = useState(false);
|
||||
const [isSavingTags, setIsSavingTags] = useState(false);
|
||||
const previousIsDeleting = usePrevious(isDeleting);
|
||||
|
||||
const [selectState, selectDispatch] = useSelect();
|
||||
const { selectedState } = selectState;
|
||||
@@ -110,10 +113,10 @@ function IndexerIndexSelectFooter() {
|
||||
}, [isSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDeleting && !deleteError) {
|
||||
if (previousIsDeleting && !isDeleting && !deleteError) {
|
||||
selectDispatch({ type: 'unselectAll' });
|
||||
}
|
||||
}, [isDeleting, deleteError, selectDispatch]);
|
||||
}, [previousIsDeleting, isDeleting, deleteError, selectDispatch]);
|
||||
|
||||
const anySelected = selectedCount > 0;
|
||||
|
||||
@@ -134,7 +137,7 @@ function IndexerIndexSelectFooter() {
|
||||
isDisabled={!anySelected}
|
||||
onPress={onTagsPress}
|
||||
>
|
||||
{translate('Set Tags')}
|
||||
{translate('SetTags')}
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +154,7 @@ function IndexerIndexSelectFooter() {
|
||||
</div>
|
||||
|
||||
<div className={styles.selected}>
|
||||
{translate('{0} indexers selected', selectedCount.toString())}
|
||||
{translate('CountIndexersSelected', [selectedCount])}
|
||||
</div>
|
||||
|
||||
<EditIndexerModal
|
||||
|
||||
@@ -59,14 +59,14 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||
}, [tags, applyTags, onApplyTagsPress]);
|
||||
|
||||
const applyTagsOptions = [
|
||||
{ key: 'add', value: 'Add' },
|
||||
{ key: 'remove', value: 'Remove' },
|
||||
{ key: 'replace', value: 'Replace' },
|
||||
{ key: 'add', value: translate('Add') },
|
||||
{ key: 'remove', value: translate('Remove') },
|
||||
{ key: 'replace', value: translate('Replace') },
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>Tags</ModalHeader>
|
||||
<ModalHeader>{translate('Tags')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
@@ -119,8 +119,8 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||
key={tag.id}
|
||||
title={
|
||||
removeTag
|
||||
? translate('RemoveTagRemovingTag')
|
||||
: translate('RemoveTagExistingTag')
|
||||
? translate('RemovingTag')
|
||||
: translate('ExistingTag')
|
||||
}
|
||||
kind={removeTag ? kinds.INVERSE : kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
@@ -159,10 +159,10 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>Cancel</Button>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
|
||||
Apply
|
||||
{translate('Apply')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -92,11 +92,9 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
|
||||
const columns = useSelector(columnsSelector);
|
||||
const { showBanners } = useSelector(selectTableOptions);
|
||||
const listRef: React.MutableRefObject<List> = useRef();
|
||||
const listRef = useRef<List>(null);
|
||||
const [measureRef, bounds] = useMeasure();
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
const rowHeight = useMemo(() => {
|
||||
return showBanners ? 70 : 38;
|
||||
@@ -107,8 +105,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
|
||||
if (isSmallScreen) {
|
||||
setSize({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -121,14 +119,14 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
|
||||
setSize({
|
||||
width: width - padding * 2,
|
||||
height: windowHeight,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
}, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]);
|
||||
}, [isSmallScreen, scrollerRef, bounds]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
|
||||
const currentScrollerRef = scrollerRef.current;
|
||||
const currentScrollerRef = scrollerRef.current as HTMLElement;
|
||||
const currentScrollListener = isSmallScreen ? window : currentScrollerRef;
|
||||
|
||||
const handleScroll = throttle(() => {
|
||||
const { offsetTop = 0 } = currentScrollerRef;
|
||||
@@ -137,7 +135,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
? getWindowScrollTopPosition()
|
||||
: currentScrollerRef.scrollTop) - offsetTop;
|
||||
|
||||
listRef.current.scrollTo(scrollTop);
|
||||
listRef.current?.scrollTo(scrollTop);
|
||||
}, 10);
|
||||
|
||||
currentScrollListener.addEventListener('scroll', handleScroll);
|
||||
@@ -166,8 +164,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
scrollTop += offset;
|
||||
}
|
||||
|
||||
listRef.current.scrollTo(scrollTop);
|
||||
scrollerRef.current.scrollTo(0, scrollTop);
|
||||
listRef.current?.scrollTo(scrollTop);
|
||||
scrollerRef.current?.scrollTo(0, scrollTop);
|
||||
}
|
||||
}
|
||||
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);
|
||||
|
||||
@@ -43,7 +43,7 @@ function IndexerStatusCell(props: IndexerStatusCellProps) {
|
||||
className={styles.statusIcon}
|
||||
kind={enabled ? enableKind : kinds.DEFAULT}
|
||||
name={enabled ? enableIcon : icons.BLOCKLIST}
|
||||
title={enabled ? enableTitle : translate('EnabledIndexerIsDisabled')}
|
||||
title={enabled ? enableTitle : translate('Disabled')}
|
||||
/>
|
||||
}
|
||||
{status ? (
|
||||
|
||||
@@ -13,6 +13,10 @@ import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import TagListConnector from 'Components/TagListConnector';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal';
|
||||
@@ -149,6 +153,7 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('SearchCapabilities')}>
|
||||
<div>
|
||||
<DescriptionList>
|
||||
@@ -237,6 +242,54 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
{capabilities.categories !== null &&
|
||||
capabilities.categories.length > 0 ? (
|
||||
<FieldSet legend={translate('IndexerCategories')}>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
name: 'id',
|
||||
label: translate('Id'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: translate('Name'),
|
||||
isVisible: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{capabilities.categories
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((category) => {
|
||||
return (
|
||||
<TableBody key={category.id}>
|
||||
<TableRow key={category.id}>
|
||||
<TableRowCell>{category.id}</TableRowCell>
|
||||
<TableRowCell>{category.name}</TableRowCell>
|
||||
</TableRow>
|
||||
{category.subCategories !== null &&
|
||||
category.subCategories.length > 0
|
||||
? category.subCategories
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((subCategory) => {
|
||||
return (
|
||||
<TableRow key={subCategory.id}>
|
||||
<TableRowCell>{subCategory.id}</TableRowCell>
|
||||
<TableRowCell>
|
||||
{subCategory.name}
|
||||
</TableRowCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
</FieldSet>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
|
||||
@@ -253,7 +253,6 @@ Stats.propTypes = {
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectedFilterKey: PropTypes.string.isRequired,
|
||||
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onFilterSelect: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
data: PropTypes.object
|
||||
|
||||
@@ -27,7 +27,9 @@ class SearchFooter extends Component {
|
||||
defaultIndexerIds,
|
||||
defaultCategories,
|
||||
defaultSearchQuery,
|
||||
defaultSearchType
|
||||
defaultSearchType,
|
||||
defaultSearchLimit,
|
||||
defaultSearchOffset
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
@@ -38,8 +40,8 @@ class SearchFooter extends Component {
|
||||
searchQuery: defaultSearchQuery || '',
|
||||
searchIndexerIds: defaultIndexerIds,
|
||||
searchCategories: defaultCategories,
|
||||
searchLimit: 100,
|
||||
searchOffset: 0,
|
||||
searchLimit: defaultSearchLimit,
|
||||
searchOffset: defaultSearchOffset,
|
||||
newSearch: true
|
||||
};
|
||||
}
|
||||
@@ -55,7 +57,9 @@ class SearchFooter extends Component {
|
||||
this.onSearchPress();
|
||||
}
|
||||
|
||||
this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true });
|
||||
setTimeout(() => {
|
||||
this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true });
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -120,7 +124,6 @@ class SearchFooter extends Component {
|
||||
};
|
||||
|
||||
onSearchPress = () => {
|
||||
|
||||
const {
|
||||
searchLimit,
|
||||
searchOffset,
|
||||
@@ -188,10 +191,10 @@ class SearchFooter extends Component {
|
||||
icon = icons.SEARCH;
|
||||
}
|
||||
|
||||
let footerLabel = `Search ${searchIndexerIds.length === 0 ? 'all' : searchIndexerIds.length} Indexers`;
|
||||
let footerLabel = searchIndexerIds.length === 0 ? translate('SearchAllIndexers') : translate('SearchCountIndexers', [searchIndexerIds.length]);
|
||||
|
||||
if (isPopulated) {
|
||||
footerLabel = selectedCount === 0 ? `Found ${itemCount} releases` : `Selected ${selectedCount} of ${itemCount} releases`;
|
||||
footerLabel = selectedCount === 0 ? translate('FoundCountReleases', [itemCount]) : translate('SelectedCountOfCountReleases', [selectedCount, itemCount]);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -302,6 +305,8 @@ SearchFooter.propTypes = {
|
||||
defaultCategories: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
defaultSearchQuery: PropTypes.string.isRequired,
|
||||
defaultSearchType: PropTypes.string.isRequired,
|
||||
defaultSearchLimit: PropTypes.number.isRequired,
|
||||
defaultSearchOffset: PropTypes.number.isRequired,
|
||||
selectedCount: PropTypes.number.isRequired,
|
||||
itemCount: PropTypes.number.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -3,24 +3,58 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { setSearchDefault } from 'Store/Actions/releaseActions';
|
||||
import parseUrl from 'Utilities/String/parseUrl';
|
||||
import SearchFooter from './SearchFooter';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.releases,
|
||||
(releases) => {
|
||||
(state) => state.router.location,
|
||||
(releases, location) => {
|
||||
const {
|
||||
searchQuery: defaultSearchQuery,
|
||||
searchIndexerIds: defaultIndexerIds,
|
||||
searchCategories: defaultCategories,
|
||||
searchType: defaultSearchType
|
||||
searchType: defaultSearchType,
|
||||
searchLimit: defaultSearchLimit,
|
||||
searchOffset: defaultSearchOffset
|
||||
} = releases.defaults;
|
||||
|
||||
const { params } = parseUrl(location.search);
|
||||
const defaultSearchQueryParams = {};
|
||||
|
||||
if (params.query && !defaultSearchQuery) {
|
||||
defaultSearchQueryParams.searchQuery = params.query;
|
||||
}
|
||||
|
||||
if (params.indexerIds && !defaultIndexerIds.length) {
|
||||
defaultSearchQueryParams.searchIndexerIds = params.indexerIds.split(',').filter(Boolean).map((id) => Number(id));
|
||||
}
|
||||
|
||||
if (params.categories && !defaultCategories.length) {
|
||||
defaultSearchQueryParams.searchCategories = params.categories.split(',').filter(Boolean).map((id) => Number(id));
|
||||
}
|
||||
|
||||
if (params.type && defaultSearchType === 'search') {
|
||||
defaultSearchQueryParams.searchType = params.type;
|
||||
}
|
||||
|
||||
if (params.limit && defaultSearchLimit === 100 && !isNaN(params.limit)) {
|
||||
defaultSearchQueryParams.searchLimit = Number(params.limit);
|
||||
}
|
||||
|
||||
if (params.offset && !defaultSearchOffset && !isNaN(params.offset)) {
|
||||
defaultSearchQueryParams.searchOffset = Number(params.offset);
|
||||
}
|
||||
|
||||
return {
|
||||
defaultSearchQuery,
|
||||
defaultIndexerIds,
|
||||
defaultCategories,
|
||||
defaultSearchType
|
||||
defaultSearchQueryParams,
|
||||
defaultSearchQuery: defaultSearchQueryParams.searchQuery ?? defaultSearchQuery,
|
||||
defaultIndexerIds: defaultSearchQueryParams.searchIndexerIds ?? defaultIndexerIds,
|
||||
defaultCategories: defaultSearchQueryParams.searchCategories ?? defaultCategories,
|
||||
defaultSearchType: defaultSearchQueryParams.searchType ?? defaultSearchType,
|
||||
defaultSearchLimit: defaultSearchQueryParams.searchLimit ?? defaultSearchLimit,
|
||||
defaultSearchOffset: defaultSearchQueryParams.searchOffset ?? defaultSearchOffset
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -32,6 +66,16 @@ const mapDispatchToProps = {
|
||||
|
||||
class SearchFooterConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
// Set defaults from query parameters
|
||||
Object.entries(this.props.defaultSearchQueryParams).forEach(([name, value]) => {
|
||||
this.onInputChange({ name, value });
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
@@ -43,9 +87,14 @@ class SearchFooterConnector extends Component {
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
defaultSearchQueryParams,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<SearchFooter
|
||||
{...this.props}
|
||||
{...otherProps}
|
||||
onInputChange={this.onInputChange}
|
||||
/>
|
||||
);
|
||||
@@ -53,6 +102,7 @@ class SearchFooterConnector extends Component {
|
||||
}
|
||||
|
||||
SearchFooterConnector.propTypes = {
|
||||
defaultSearchQueryParams: PropTypes.object.isRequired,
|
||||
setSearchDefault: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import { align, icons, kinds, sortDirections } from 'Helpers/Props';
|
||||
import AddIndexerModal from 'Indexer/Add/AddIndexerModal';
|
||||
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
|
||||
import NoIndexer from 'Indexer/NoIndexer';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
@@ -54,7 +56,9 @@ class SearchIndex extends Component {
|
||||
lastToggled: null,
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
selectedState: {}
|
||||
selectedState: {},
|
||||
isAddIndexerModalOpen: false,
|
||||
isEditIndexerModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,7 +146,7 @@ class SearchIndex extends Component {
|
||||
} = this.props;
|
||||
|
||||
// Reset if not sorting by sortTitle
|
||||
if (sortKey !== 'title') {
|
||||
if (sortKey !== 'sortTitle') {
|
||||
this.setState({ jumpBarItems: { order: [] } });
|
||||
return;
|
||||
}
|
||||
@@ -150,7 +154,7 @@ class SearchIndex extends Component {
|
||||
const characters = _.reduce(items, (acc, item) => {
|
||||
let char = item.sortTitle.charAt(0);
|
||||
|
||||
if (!isNaN(char)) {
|
||||
if (!isNaN(Number(char))) {
|
||||
char = '#';
|
||||
}
|
||||
|
||||
@@ -181,6 +185,22 @@ class SearchIndex extends Component {
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onAddIndexerPress = () => {
|
||||
this.setState({ isAddIndexerModalOpen: true });
|
||||
};
|
||||
|
||||
onAddIndexerModalClose = () => {
|
||||
this.setState({ isAddIndexerModalOpen: false });
|
||||
};
|
||||
|
||||
onAddIndexerSelectIndexer = () => {
|
||||
this.setState({ isEditIndexerModalOpen: true });
|
||||
};
|
||||
|
||||
onEditIndexerModalClose = () => {
|
||||
this.setState({ isEditIndexerModalOpen: false });
|
||||
};
|
||||
|
||||
onJumpBarItemPress = (jumpToCharacter) => {
|
||||
this.setState({ jumpToCharacter });
|
||||
};
|
||||
@@ -252,7 +272,9 @@ class SearchIndex extends Component {
|
||||
jumpToCharacter,
|
||||
selectedState,
|
||||
allSelected,
|
||||
allUnselected
|
||||
allUnselected,
|
||||
isAddIndexerModalOpen,
|
||||
isEditIndexerModalOpen
|
||||
} = this.state;
|
||||
|
||||
const selectedIndexerIds = this.getSelectedIds();
|
||||
@@ -348,6 +370,17 @@ class SearchIndex extends Component {
|
||||
!error && !isFetching && hasIndexers && !items.length &&
|
||||
<NoSearchResults totalItems={totalItems} />
|
||||
}
|
||||
|
||||
<AddIndexerModal
|
||||
isOpen={isAddIndexerModalOpen}
|
||||
onModalClose={this.onAddIndexerModalClose}
|
||||
onSelectIndexer={this.onAddIndexerSelectIndexer}
|
||||
/>
|
||||
|
||||
<EditIndexerModalConnector
|
||||
isOpen={isEditIndexerModalOpen}
|
||||
onModalClose={this.onEditIndexerModalClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ function AdvancedSettingsButton(props) {
|
||||
return (
|
||||
<Link
|
||||
className={styles.button}
|
||||
title={advancedSettings ? translate('ShownClickToHide') : translate('HiddenClickToShow')}
|
||||
title={advancedSettings ? translate('AdvancedSettingsShownClickToHide') : translate('AdvancedSettingsHiddenClickToShow')}
|
||||
onPress={onAdvancedSettingsPress}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditApplicationModalContent.css';
|
||||
|
||||
@@ -38,6 +39,7 @@ function EditApplicationModalContent(props) {
|
||||
onSavePress,
|
||||
onTestPress,
|
||||
onDeleteApplicationPress,
|
||||
onAdvancedSettingsPress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -149,6 +151,12 @@ function EditApplicationModalContent(props) {
|
||||
</Button>
|
||||
}
|
||||
|
||||
<AdvancedSettingsButton
|
||||
advancedSettings={advancedSettings}
|
||||
onAdvancedSettingsPress={onAdvancedSettingsPress}
|
||||
showLabel={false}
|
||||
/>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isTesting}
|
||||
error={saveError}
|
||||
@@ -188,7 +196,8 @@ EditApplicationModalContent.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onTestPress: PropTypes.func.isRequired,
|
||||
onDeleteApplicationPress: PropTypes.func
|
||||
onDeleteApplicationPress: PropTypes.func,
|
||||
onAdvancedSettingsPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditApplicationModalContent;
|
||||
|
||||
@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { saveApplication, setApplicationFieldValue, setApplicationValue, testApplication } from 'Store/Actions/settingsActions';
|
||||
import {
|
||||
saveApplication,
|
||||
setApplicationFieldValue,
|
||||
setApplicationValue,
|
||||
testApplication,
|
||||
toggleAdvancedSettings
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
|
||||
import EditApplicationModalContent from './EditApplicationModalContent';
|
||||
|
||||
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
|
||||
setApplicationValue,
|
||||
setApplicationFieldValue,
|
||||
saveApplication,
|
||||
testApplication
|
||||
testApplication,
|
||||
toggleAdvancedSettings
|
||||
};
|
||||
|
||||
class EditApplicationModalContentConnector extends Component {
|
||||
@@ -56,6 +63,10 @@ class EditApplicationModalContentConnector extends Component {
|
||||
this.props.testApplication({ id: this.props.id });
|
||||
};
|
||||
|
||||
onAdvancedSettingsPress = () => {
|
||||
this.props.toggleAdvancedSettings();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
@@ -67,6 +78,7 @@ class EditApplicationModalContentConnector extends Component {
|
||||
onTestPress={this.onTestPress}
|
||||
onInputChange={this.onInputChange}
|
||||
onFieldChange={this.onFieldChange}
|
||||
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +94,8 @@ EditApplicationModalContentConnector.propTypes = {
|
||||
setApplicationFieldValue: PropTypes.func,
|
||||
saveApplication: PropTypes.func,
|
||||
testApplication: PropTypes.func,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
toggleAdvancedSettings: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditApplicationModalContentConnector);
|
||||
|
||||
@@ -58,6 +58,12 @@ export const defaultState = {
|
||||
isSortable: false,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'queryType',
|
||||
label: translate('QueryType'),
|
||||
isSortable: false,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
label: translate('Categories'),
|
||||
|
||||
@@ -29,6 +29,8 @@ export const defaultState = {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
selectedSchema: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const defaultState = {
|
||||
columns: [
|
||||
{
|
||||
name: 'status',
|
||||
columnLabel: translate('ReleaseStatus'),
|
||||
columnLabel: translate('IndexerStatus'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
|
||||
@@ -31,16 +31,18 @@ export const defaultState = {
|
||||
error: null,
|
||||
grabError: null,
|
||||
items: [],
|
||||
sortKey: 'title',
|
||||
sortKey: 'age',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
secondarySortKey: 'title',
|
||||
secondarySortKey: 'sortTitle',
|
||||
secondarySortDirection: sortDirections.ASCENDING,
|
||||
|
||||
defaults: {
|
||||
searchType: 'search',
|
||||
searchQuery: '',
|
||||
searchIndexerIds: [],
|
||||
searchCategories: []
|
||||
searchCategories: [],
|
||||
searchLimit: 100,
|
||||
searchOffset: 0
|
||||
},
|
||||
|
||||
columns: [
|
||||
|
||||
@@ -9,12 +9,12 @@ function createUnoptimizedSelector(uiSection) {
|
||||
const items = indexers.items.map((s) => {
|
||||
const {
|
||||
id,
|
||||
name
|
||||
sortName
|
||||
} = s;
|
||||
|
||||
return {
|
||||
id,
|
||||
sortTitle: name
|
||||
sortName
|
||||
};
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ const createMovieEqualSelector = createSelectorCreator(
|
||||
function createIndexerClientSideCollectionItemsSelector(uiSection) {
|
||||
return createMovieEqualSelector(
|
||||
createUnoptimizedSelector(uiSection),
|
||||
(movies) => movies
|
||||
(indexers) => indexers
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ function createUnoptimizedSelector(uiSection) {
|
||||
const items = releases.items.map((s) => {
|
||||
const {
|
||||
guid,
|
||||
title,
|
||||
sortTitle,
|
||||
indexerId
|
||||
} = s;
|
||||
|
||||
return {
|
||||
guid,
|
||||
sortTitle: title,
|
||||
sortTitle,
|
||||
indexerId
|
||||
};
|
||||
});
|
||||
@@ -40,7 +40,7 @@ const createMovieEqualSelector = createSelectorCreator(
|
||||
function createReleaseClientSideCollectionItemsSelector(uiSection) {
|
||||
return createMovieEqualSelector(
|
||||
createUnoptimizedSelector(uiSection),
|
||||
(movies) => movies
|
||||
(releases) => releases
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ module.exports = {
|
||||
inputHoverBackgroundColor: 'rgba(255, 255, 255, 0.20)',
|
||||
inputSelectedBackgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
advancedFormLabelColor: '#ff902b',
|
||||
disabledCheckInputColor: '#ddd',
|
||||
disabledCheckInputColor: '#999',
|
||||
disabledInputColor: '#808080',
|
||||
|
||||
//
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export default function getIndexOfFirstCharacter(items, character) {
|
||||
return items.findIndex((item) => {
|
||||
const firstCharacter = item.sortTitle.charAt(0);
|
||||
const firstCharacter = 'sortName' in item ? item.sortName.charAt(0) : item.sortTitle.charAt(0);
|
||||
|
||||
if (character === '#') {
|
||||
return !isNaN(firstCharacter);
|
||||
return !isNaN(Number(firstCharacter));
|
||||
}
|
||||
|
||||
return firstCharacter === character;
|
||||
|
||||
@@ -18,13 +18,14 @@ function getTranslations() {
|
||||
|
||||
const translations = getTranslations();
|
||||
|
||||
export default function translate(key, args = '') {
|
||||
export default function translate(key, args = []) {
|
||||
const translation = translations[key] || key;
|
||||
|
||||
if (args) {
|
||||
const translatedKey = translate(key);
|
||||
return translatedKey.replace(/\{(\d+)\}/g, (match, index) => {
|
||||
return translation.replace(/\{(\d+)\}/g, (match, index) => {
|
||||
return args[index];
|
||||
});
|
||||
}
|
||||
|
||||
return translations[key] || key;
|
||||
return translation;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-text-truncate": "0.19.0",
|
||||
"react-use-measure": "2.1.1",
|
||||
"react-virtualized": "9.22.3",
|
||||
"react-virtualized": "9.21.1",
|
||||
"react-window": "1.8.8",
|
||||
"redux": "4.2.1",
|
||||
"redux-actions": "2.6.5",
|
||||
|
||||
@@ -46,7 +46,11 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
|
||||
public async Task<HttpResponse> GetResponseAsync(HttpRequest request, CookieContainer cookies)
|
||||
{
|
||||
var requestMessage = new HttpRequestMessage(request.Method, (Uri)request.Url);
|
||||
var requestMessage = new HttpRequestMessage(request.Method, (Uri)request.Url)
|
||||
{
|
||||
Version = HttpVersion.Version20,
|
||||
VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
|
||||
};
|
||||
requestMessage.Headers.UserAgent.ParseAdd(_userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent));
|
||||
requestMessage.Headers.ConnectionClose = !request.ConnectionKeepAlive;
|
||||
|
||||
@@ -148,7 +152,7 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new HttpResponse(request, new HttpHeader(headers), cookieCollection, data, sw.ElapsedMilliseconds, responseMessage.StatusCode);
|
||||
return new HttpResponse(request, new HttpHeader(headers), cookieCollection, data, sw.ElapsedMilliseconds, responseMessage.StatusCode, responseMessage.Version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +190,8 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
|
||||
var client = new System.Net.Http.HttpClient(handler)
|
||||
{
|
||||
DefaultRequestVersion = HttpVersion.Version20,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ namespace NzbDrone.Common.Http
|
||||
{
|
||||
public class HttpResponse
|
||||
{
|
||||
private static readonly Regex RegexRefresh = new Regex("^(.*?url)=(.*?)(?:;|$)", RegexOptions.Compiled);
|
||||
private static readonly Regex RegexRefresh = new ("^(.*?url)=(.*?)(?:;|$)", RegexOptions.Compiled);
|
||||
|
||||
public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, byte[] binaryData, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, byte[] binaryData, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK, Version version = null)
|
||||
{
|
||||
Request = request;
|
||||
Headers = headers;
|
||||
@@ -19,9 +19,10 @@ namespace NzbDrone.Common.Http
|
||||
ResponseData = binaryData;
|
||||
StatusCode = statusCode;
|
||||
ElapsedTime = elapsedTime;
|
||||
Version = version;
|
||||
}
|
||||
|
||||
public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, string content, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
public HttpResponse(HttpRequest request, HttpHeader headers, CookieCollection cookies, string content, long elapsedTime = 0, HttpStatusCode statusCode = HttpStatusCode.OK, Version version = null)
|
||||
{
|
||||
Request = request;
|
||||
Headers = headers;
|
||||
@@ -30,6 +31,7 @@ namespace NzbDrone.Common.Http
|
||||
_content = content;
|
||||
StatusCode = statusCode;
|
||||
ElapsedTime = elapsedTime;
|
||||
Version = version;
|
||||
}
|
||||
|
||||
public HttpRequest Request { get; private set; }
|
||||
@@ -37,6 +39,7 @@ namespace NzbDrone.Common.Http
|
||||
public CookieCollection Cookies { get; private set; }
|
||||
public HttpStatusCode StatusCode { get; private set; }
|
||||
public long ElapsedTime { get; private set; }
|
||||
public Version Version { get; private set; }
|
||||
public byte[] ResponseData { get; private set; }
|
||||
|
||||
private string _content;
|
||||
@@ -63,6 +66,8 @@ namespace NzbDrone.Common.Http
|
||||
|
||||
public bool HasHttpError => (int)StatusCode >= 400;
|
||||
|
||||
public bool HasHttpServerError => (int)StatusCode >= 500;
|
||||
|
||||
public bool HasHttpRedirect => StatusCode == HttpStatusCode.Moved ||
|
||||
StatusCode == HttpStatusCode.Found ||
|
||||
StatusCode == HttpStatusCode.SeeOther ||
|
||||
@@ -119,7 +124,7 @@ namespace NzbDrone.Common.Http
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var result = string.Format("Res: [{0}] {1}: {2}.{3} ({4} bytes)", Request.Method, Request.Url, (int)StatusCode, StatusCode, ResponseData?.Length ?? 0);
|
||||
var result = $"Res: HTTP/{Version} [{Request.Method}] {Request.Url}: {(int)StatusCode}.{StatusCode} ({ResponseData?.Length ?? 0} bytes)";
|
||||
|
||||
if (HasHttpError && Headers.ContentType.IsNotNullOrWhiteSpace() && !Headers.ContentType.Equals("text/html", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
@@ -134,7 +139,7 @@ namespace NzbDrone.Common.Http
|
||||
where T : new()
|
||||
{
|
||||
public HttpResponse(HttpResponse response)
|
||||
: base(response.Request, response.Headers, response.Cookies, response.ResponseData, response.ElapsedTime, response.StatusCode)
|
||||
: base(response.Request, response.Headers, response.Cookies, response.ResponseData, response.ElapsedTime, response.StatusCode, response.Version)
|
||||
{
|
||||
Resource = Json.Deserialize<T>(response.Content);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
o.Dsn = dsn;
|
||||
o.AttachStacktrace = true;
|
||||
o.MaxBreadcrumbs = 200;
|
||||
o.Release = BuildInfo.Release;
|
||||
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
|
||||
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
|
||||
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
|
||||
o.Environment = BuildInfo.Branch;
|
||||
|
||||
@@ -121,6 +121,11 @@ namespace NzbDrone.Common.Serializer
|
||||
return JsonConvert.SerializeObject(obj, SerializerSettings);
|
||||
}
|
||||
|
||||
public static string ToJson(this object obj, Formatting formatting)
|
||||
{
|
||||
return JsonConvert.SerializeObject(obj, formatting, SerializerSettings);
|
||||
}
|
||||
|
||||
public static void Serialize<TModel>(TModel model, TextWriter outputStream)
|
||||
{
|
||||
var jsonTextWriter = new JsonTextWriter(outputStream);
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace NzbDrone.Core.Test.Configuration
|
||||
[Test]
|
||||
public void Get_value_should_return_default_when_no_value()
|
||||
{
|
||||
Subject.HistoryCleanupDays.Should().Be(365);
|
||||
Subject.HistoryCleanupDays.Should().Be(30);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.Configuration
|
||||
public void get_value_with_out_persist_should_not_store_default_value()
|
||||
{
|
||||
var interval = Subject.HistoryCleanupDays;
|
||||
interval.Should().Be(365);
|
||||
interval.Should().Be(30);
|
||||
Mocker.GetMock<IConfigRepository>().Verify(c => c.Insert(It.IsAny<Config>()), Times.Never());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Datastore.Migration;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.Datastore.Migration
|
||||
{
|
||||
[TestFixture]
|
||||
public class apprise_server_urlFixture : MigrationTest<apprise_server_url>
|
||||
{
|
||||
[Test]
|
||||
public void should_rename_server_url_setting_for_apprise()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("Notifications").Row(new
|
||||
{
|
||||
Name = "Apprise",
|
||||
Implementation = "Apprise",
|
||||
Settings = new
|
||||
{
|
||||
BaseUrl = "http://localhost:8000",
|
||||
NotificationType = 0
|
||||
}.ToJson(),
|
||||
ConfigContract = "AppriseSettings",
|
||||
OnHealthIssue = true,
|
||||
IncludeHealthWarnings = true,
|
||||
OnApplicationUpdate = true,
|
||||
OnGrab = true,
|
||||
IncludeManualGrabs = true
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<NotificationDefinition31>("SELECT * FROM \"Notifications\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().Settings.Should().NotContainKey("baseUrl");
|
||||
items.First().Settings.Should().ContainKey("serverUrl");
|
||||
items.First().Settings.GetValueOrDefault("serverUrl").Should().Be("http://localhost:8000");
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationDefinition31
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Implementation { get; set; }
|
||||
public Dictionary<string, string> Settings { get; set; }
|
||||
public string ConfigContract { get; set; }
|
||||
public bool OnHealthIssue { get; set; }
|
||||
public bool IncludeHealthWarnings { get; set; }
|
||||
public bool OnApplicationUpdate { get; set; }
|
||||
public bool OnGrab { get; set; }
|
||||
public bool IncludeManualGrabs { get; set; }
|
||||
public List<int> Tags { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Datastore.Migration;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.Datastore.Migration
|
||||
{
|
||||
[TestFixture]
|
||||
public class history_fix_data_titlesFixture : MigrationTest<history_fix_data_titles>
|
||||
{
|
||||
[Test]
|
||||
public void should_update_data_for_book_search()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("History").Row(new
|
||||
{
|
||||
IndexerId = 1,
|
||||
Date = DateTime.UtcNow,
|
||||
Data = new
|
||||
{
|
||||
Author = "Fake Author",
|
||||
BookTitle = "Fake Book Title",
|
||||
Publisher = "",
|
||||
Year = "",
|
||||
Genre = "",
|
||||
Query = "",
|
||||
QueryType = "book",
|
||||
Source = "Prowlarr",
|
||||
Host = "localhost"
|
||||
}.ToJson(),
|
||||
EventType = 2,
|
||||
Successful = true
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<HistoryDefinition34>("SELECT * FROM \"History\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().Data.Should().NotContainKey("bookTitle");
|
||||
items.First().Data.Should().ContainKey("title");
|
||||
items.First().Data.GetValueOrDefault("title").Should().Be("Fake Book Title");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_update_data_for_release_grabbed()
|
||||
{
|
||||
var db = WithMigrationTestDb(c =>
|
||||
{
|
||||
c.Insert.IntoTable("History").Row(new
|
||||
{
|
||||
IndexerId = 1,
|
||||
Date = DateTime.UtcNow,
|
||||
Data = new
|
||||
{
|
||||
GrabMethod = "Proxy",
|
||||
Title = "Fake Release Title",
|
||||
Source = "Prowlarr",
|
||||
Host = "localhost"
|
||||
}.ToJson(),
|
||||
EventType = 1,
|
||||
Successful = true
|
||||
});
|
||||
});
|
||||
|
||||
var items = db.Query<HistoryDefinition34>("SELECT * FROM \"History\"");
|
||||
|
||||
items.Should().HaveCount(1);
|
||||
|
||||
items.First().Data.Should().NotContainKey("title");
|
||||
items.First().Data.Should().ContainKey("grabTitle");
|
||||
items.First().Data.GetValueOrDefault("grabTitle").Should().Be("Fake Release Title");
|
||||
}
|
||||
}
|
||||
|
||||
public class HistoryDefinition34
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int IndexerId { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public Dictionary<string, string> Data { get; set; }
|
||||
public int EventType { get; set; }
|
||||
public string DownloadId { get; set; }
|
||||
public bool Successful { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
@@ -32,10 +31,10 @@ namespace NzbDrone.Core.Applications.LazyLibrarian
|
||||
{
|
||||
failures.AddIfNotNull(_lazyLibrarianV1Proxy.TestConnection(Settings));
|
||||
}
|
||||
catch (WebException ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to LazyLibrarian"));
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to LazyLibrarian. {ex.Message}"));
|
||||
}
|
||||
|
||||
return new ValidationResult(failures);
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace NzbDrone.Core.Applications.LazyLibrarian
|
||||
[FieldDefinition(1, Label = "LazyLibrarian Server", HelpText = "URL used to connect to LazyLibrarian server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:5299")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by LazyLibrarian in Settings/Web Interface")]
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by LazyLibrarian in Settings/Web Interface")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
|
||||
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using FluentValidation.Results;
|
||||
using Newtonsoft.Json;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
|
||||
namespace NzbDrone.Core.Applications.LazyLibrarian
|
||||
{
|
||||
@@ -139,11 +139,11 @@ namespace NzbDrone.Core.Applications.LazyLibrarian
|
||||
return new ValidationFailure("ApiKey", status.Error.Message);
|
||||
}
|
||||
|
||||
var indexers = GetIndexers(settings);
|
||||
GetIndexers(settings);
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
return new ValidationFailure("BaseUrl", "Unable to complete application test");
|
||||
}
|
||||
catch (LazyLibrarianException ex)
|
||||
@@ -153,8 +153,8 @@ namespace NzbDrone.Core.Applications.LazyLibrarian
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("", "Unable to send test message");
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
return new ValidationFailure("", $"Unable to send test message. {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -164,7 +164,9 @@ namespace NzbDrone.Core.Applications.LazyLibrarian
|
||||
{
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
|
||||
var requestBuilder = new HttpRequestBuilder(baseUrl).Resource(resource)
|
||||
var requestBuilder = new HttpRequestBuilder(baseUrl)
|
||||
.Resource(resource)
|
||||
.Accept(HttpAccept.Json)
|
||||
.AddQueryParam("cmd", command)
|
||||
.AddQueryParam("apikey", settings.ApiKey);
|
||||
|
||||
@@ -191,9 +193,12 @@ namespace NzbDrone.Core.Applications.LazyLibrarian
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return results;
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
|
||||
private int CalculatePriority(int indexerPriority) => ProwlarrHighestPriority - indexerPriority + 1;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
@@ -48,9 +51,36 @@ namespace NzbDrone.Core.Applications.Lidarr
|
||||
{
|
||||
failures.AddIfNotNull(_lidarrV1Proxy.TestConnection(BuildLidarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings));
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
switch (ex.Response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Unauthorized:
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid"));
|
||||
break;
|
||||
case HttpStatusCode.BadRequest:
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Lidarr cannot connect to Prowlarr"));
|
||||
break;
|
||||
case HttpStatusCode.SeeOther:
|
||||
_logger.Error(ex, "Lidarr returned redirect and is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Lidarr URL is invalid, Prowlarr cannot connect to Lidarr - are you missing a URL base?"));
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Lidarr. {ex.Message}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to parse JSON response from application");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Lidarr. {ex.Message}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace NzbDrone.Core.Applications.Lidarr
|
||||
[FieldDefinition(1, Label = "Lidarr Server", HelpText = "URL used to connect to Lidarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8686")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Lidarr in Settings/General")]
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Lidarr in Settings/General")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
|
||||
@@ -85,13 +85,25 @@ namespace NzbDrone.Core.Applications.Lidarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
try
|
||||
{
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Debug("Retrying to add indexer forcefully");
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
public LidarrIndexer UpdateIndexer(LidarrIndexer indexer, LidarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put);
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
@@ -103,47 +115,16 @@ namespace NzbDrone.Core.Applications.Lidarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
try
|
||||
var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version");
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
var applicationVersion = _httpClient.Post<LidarrIndexer>(request).Headers.GetSingleValue("X-Application-Version");
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, "Failed to fetch Lidarr version");
|
||||
}
|
||||
|
||||
if (new Version(applicationVersion) < MinimumApplicationVersion)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, $"Lidarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
return new ValidationFailure(string.Empty, "Failed to fetch Lidarr version");
|
||||
}
|
||||
catch (HttpException ex)
|
||||
|
||||
if (new Version(applicationVersion) < MinimumApplicationVersion)
|
||||
{
|
||||
if (ex.Response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
return new ValidationFailure("ApiKey", "API Key is invalid");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Lidarr cannot connect to Prowlarr");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.SeeOther)
|
||||
{
|
||||
_logger.Error(ex, "Lidarr returned redirect and is invalid");
|
||||
return new ValidationFailure("BaseUrl", "Lidarr url is invalid, Prowlarr cannot connect to Lidarr - are you missing a url base?");
|
||||
}
|
||||
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("BaseUrl", "Unable to complete application test");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("", "Unable to send test message");
|
||||
return new ValidationFailure(string.Empty, $"Lidarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -179,8 +160,10 @@ namespace NzbDrone.Core.Applications.Lidarr
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode);
|
||||
throw;
|
||||
break;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
@@ -192,15 +175,15 @@ namespace NzbDrone.Core.Applications.Lidarr
|
||||
_logger.Error(ex, "Unable to add or update indexer");
|
||||
throw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpRequest BuildRequest(LidarrSettings settings, string resource, HttpMethod method)
|
||||
{
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
|
||||
var request = new HttpRequestBuilder(baseUrl).Resource(resource)
|
||||
var request = new HttpRequestBuilder(baseUrl)
|
||||
.Resource(resource)
|
||||
.Accept(HttpAccept.Json)
|
||||
.SetHeader("X-Api-Key", settings.ApiKey)
|
||||
.Build();
|
||||
|
||||
@@ -217,9 +200,12 @@ namespace NzbDrone.Core.Applications.Lidarr
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return results;
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
@@ -32,10 +31,10 @@ namespace NzbDrone.Core.Applications.Mylar
|
||||
{
|
||||
failures.AddIfNotNull(_mylarV3Proxy.TestConnection(Settings));
|
||||
}
|
||||
catch (WebException ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Mylar"));
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Mylar. {ex.Message}"));
|
||||
}
|
||||
|
||||
return new ValidationResult(failures);
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace NzbDrone.Core.Applications.Mylar
|
||||
[FieldDefinition(1, Label = "Mylar Server", HelpText = "URL used to connect to Mylar server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8090")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Mylar in Settings/Web Interface")]
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Mylar in Settings/Web Interface")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
|
||||
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using FluentValidation.Results;
|
||||
using Newtonsoft.Json;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
|
||||
namespace NzbDrone.Core.Applications.Mylar
|
||||
{
|
||||
@@ -135,11 +135,11 @@ namespace NzbDrone.Core.Applications.Mylar
|
||||
return new ValidationFailure("ApiKey", status.Error.Message);
|
||||
}
|
||||
|
||||
var indexers = GetIndexers(settings);
|
||||
GetIndexers(settings);
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
return new ValidationFailure("BaseUrl", "Unable to complete application test");
|
||||
}
|
||||
catch (MylarException ex)
|
||||
@@ -149,8 +149,8 @@ namespace NzbDrone.Core.Applications.Mylar
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("", "Unable to send test message");
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
return new ValidationFailure("", $"Unable to send test message. {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -160,7 +160,9 @@ namespace NzbDrone.Core.Applications.Mylar
|
||||
{
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
|
||||
var requestBuilder = new HttpRequestBuilder(baseUrl).Resource(resource)
|
||||
var requestBuilder = new HttpRequestBuilder(baseUrl)
|
||||
.Resource(resource)
|
||||
.Accept(HttpAccept.Json)
|
||||
.AddQueryParam("cmd", command)
|
||||
.AddQueryParam("apikey", settings.ApiKey);
|
||||
|
||||
@@ -187,9 +189,12 @@ namespace NzbDrone.Core.Applications.Mylar
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return results;
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
@@ -48,9 +51,36 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
{
|
||||
failures.AddIfNotNull(_radarrV3Proxy.TestConnection(BuildRadarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings));
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
switch (ex.Response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Unauthorized:
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid"));
|
||||
break;
|
||||
case HttpStatusCode.BadRequest:
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Radarr cannot connect to Prowlarr"));
|
||||
break;
|
||||
case HttpStatusCode.SeeOther:
|
||||
_logger.Error(ex, "Radarr returned redirect and is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Radarr URL is invalid, Prowlarr cannot connect to Radarr - are you missing a URL base?"));
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Radarr. {ex.Message}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to parse JSON response from application");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Radarr. {ex.Message}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
{
|
||||
ProwlarrUrl = "http://localhost:9696";
|
||||
BaseUrl = "http://localhost:7878";
|
||||
SyncCategories = new[] { 2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080 };
|
||||
SyncCategories = new[] { 2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080, 2090 };
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Radarr sees it, including http(s)://, port, and urlbase if needed", Placeholder = "http://localhost:9696")]
|
||||
@@ -34,7 +34,7 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
[FieldDefinition(1, Label = "Radarr Server", HelpText = "URL used to connect to Radarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:7878")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Radarr in Settings/General")]
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Radarr in Settings/General")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
|
||||
@@ -86,13 +86,25 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
try
|
||||
{
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Debug("Retrying to add indexer forcefully");
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
public RadarrIndexer UpdateIndexer(RadarrIndexer indexer, RadarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put);
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
@@ -104,59 +116,28 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
try
|
||||
var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version");
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
var applicationVersion = _httpClient.Post<RadarrIndexer>(request).Headers.GetSingleValue("X-Application-Version");
|
||||
return new ValidationFailure(string.Empty, "Failed to fetch Radarr version");
|
||||
}
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, "Failed to fetch Radarr version");
|
||||
}
|
||||
var version = new Version(applicationVersion);
|
||||
|
||||
var version = new Version(applicationVersion);
|
||||
|
||||
if (version.Major == 3)
|
||||
if (version.Major == 3)
|
||||
{
|
||||
if (version < MinimumApplicationV3Version)
|
||||
{
|
||||
if (version < MinimumApplicationV3Version)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV3Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (version < MinimumApplicationV4Version)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV4Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV3Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
}
|
||||
catch (HttpException ex)
|
||||
else
|
||||
{
|
||||
if (ex.Response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
if (version < MinimumApplicationV4Version)
|
||||
{
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
return new ValidationFailure("ApiKey", "API Key is invalid");
|
||||
return new ValidationFailure(string.Empty, $"Radarr version should be at least {MinimumApplicationV4Version.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Radarr cannot connect to Prowlarr");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.SeeOther)
|
||||
{
|
||||
_logger.Error(ex, "Radarr returned redirect and is invalid");
|
||||
return new ValidationFailure("BaseUrl", "Radarr url is invalid, Prowlarr cannot connect to Radarr - are you missing a url base?");
|
||||
}
|
||||
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("BaseUrl", "Unable to complete application test");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("", "Unable to send test message");
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -192,8 +173,10 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode);
|
||||
throw;
|
||||
break;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
@@ -205,15 +188,15 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
_logger.Error(ex, "Unable to add or update indexer");
|
||||
throw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpRequest BuildRequest(RadarrSettings settings, string resource, HttpMethod method)
|
||||
{
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
|
||||
var request = new HttpRequestBuilder(baseUrl).Resource(resource)
|
||||
var request = new HttpRequestBuilder(baseUrl)
|
||||
.Resource(resource)
|
||||
.Accept(HttpAccept.Json)
|
||||
.SetHeader("X-Api-Key", settings.ApiKey)
|
||||
.Build();
|
||||
|
||||
@@ -230,9 +213,12 @@ namespace NzbDrone.Core.Applications.Radarr
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return results;
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
@@ -49,10 +51,37 @@ namespace NzbDrone.Core.Applications.Readarr
|
||||
{
|
||||
failures.AddIfNotNull(_readarrV1Proxy.TestConnection(BuildReadarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings));
|
||||
}
|
||||
catch (WebException ex)
|
||||
catch (HttpException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Readarr"));
|
||||
switch (ex.Response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Unauthorized:
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid"));
|
||||
break;
|
||||
case HttpStatusCode.BadRequest:
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Readarr cannot connect to Prowlarr"));
|
||||
break;
|
||||
case HttpStatusCode.SeeOther:
|
||||
_logger.Error(ex, "Readarr returned redirect and is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Readarr URL is invalid, Prowlarr cannot connect to Readarr - are you missing a URL base?"));
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Readarr. {ex.Message}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to parse JSON response from application");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Readarr. {ex.Message}"));
|
||||
}
|
||||
|
||||
return new ValidationResult(failures);
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace NzbDrone.Core.Applications.Readarr
|
||||
[FieldDefinition(1, Label = "Readarr Server", HelpText = "URL used to connect to Readarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8787")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Readarr in Settings/General")]
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Readarr in Settings/General")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
|
||||
@@ -82,13 +82,25 @@ namespace NzbDrone.Core.Applications.Readarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
try
|
||||
{
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Debug("Retrying to add indexer forcefully");
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
public ReadarrIndexer UpdateIndexer(ReadarrIndexer indexer, ReadarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put);
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
@@ -100,38 +112,7 @@ namespace NzbDrone.Core.Applications.Readarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
try
|
||||
{
|
||||
Execute<ReadarrIndexer>(request);
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
if (ex.Response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
return new ValidationFailure("ApiKey", "API Key is invalid");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Readarr cannot connect to Prowlarr");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.SeeOther)
|
||||
{
|
||||
_logger.Error(ex, "Readarr returned redirect and is invalid");
|
||||
return new ValidationFailure("BaseUrl", "Readarr url is invalid, Prowlarr cannot connect to Readarr - are you missing a url base?");
|
||||
}
|
||||
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("BaseUrl", "Unable to complete application test");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("", "Unable to send test message");
|
||||
}
|
||||
_httpClient.Post(request);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -166,8 +147,10 @@ namespace NzbDrone.Core.Applications.Readarr
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode);
|
||||
throw;
|
||||
break;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
@@ -179,15 +162,15 @@ namespace NzbDrone.Core.Applications.Readarr
|
||||
_logger.Error(ex, "Unable to add or update indexer");
|
||||
throw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpRequest BuildRequest(ReadarrSettings settings, string resource, HttpMethod method)
|
||||
{
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
|
||||
var request = new HttpRequestBuilder(baseUrl).Resource(resource)
|
||||
var request = new HttpRequestBuilder(baseUrl)
|
||||
.Resource(resource)
|
||||
.Accept(HttpAccept.Json)
|
||||
.SetHeader("X-Api-Key", settings.ApiKey)
|
||||
.Build();
|
||||
|
||||
@@ -204,9 +187,12 @@ namespace NzbDrone.Core.Applications.Readarr
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return results;
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
@@ -48,9 +51,40 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
{
|
||||
failures.AddIfNotNull(_sonarrV3Proxy.TestConnection(BuildSonarrIndexer(testIndexer, DownloadProtocol.Usenet), Settings));
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
switch (ex.Response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Unauthorized:
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid"));
|
||||
break;
|
||||
case HttpStatusCode.BadRequest:
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Sonarr cannot connect to Prowlarr"));
|
||||
break;
|
||||
case HttpStatusCode.SeeOther:
|
||||
_logger.Error(ex, "Sonarr returned redirect and is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Sonarr URL is invalid, Prowlarr cannot connect to Sonarr - are you missing a URL base?"));
|
||||
break;
|
||||
case HttpStatusCode.NotFound:
|
||||
_logger.Error(ex, "Sonarr not found");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Sonarr URL is invalid, Prowlarr cannot connect to Sonarr. Is Sonarr running and accessible? Sonarr v2 is not supported."));
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Sonarr. {ex.Message}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to parse JSON response from application");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Sonarr. {ex.Message}"));
|
||||
}
|
||||
|
||||
@@ -183,12 +217,12 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
{
|
||||
var cacheKey = $"{Settings.BaseUrl}";
|
||||
var schemas = _schemaCache.Get(cacheKey, () => _sonarrV3Proxy.GetIndexerSchema(Settings), TimeSpan.FromDays(7));
|
||||
var syncFields = new List<string> { "baseUrl", "apiPath", "apiKey", "categories", "animeCategories", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.seasonPackSeedTime" };
|
||||
var syncFields = new List<string> { "baseUrl", "apiPath", "apiKey", "categories", "animeCategories", "animeStandardFormatSearch", "minimumSeeders", "seedCriteria.seedRatio", "seedCriteria.seedTime", "seedCriteria.seasonPackSeedTime" };
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
// Ensuring backward compatibility with older versions on first sync
|
||||
syncFields.AddRange(new List<string> { "animeStandardFormatSearch", "additionalParameters" });
|
||||
syncFields.AddRange(new List<string> { "additionalParameters" });
|
||||
}
|
||||
|
||||
var newznab = schemas.First(i => i.Implementation == "Newznab");
|
||||
@@ -218,6 +252,11 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "categories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()));
|
||||
sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "animeCategories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()));
|
||||
|
||||
if (sonarrIndexer.Fields.Any(x => x.Name == "animeStandardFormatSearch"))
|
||||
{
|
||||
sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch").Value = Settings.SyncAnimeStandardFormatSearch;
|
||||
}
|
||||
|
||||
if (indexer.Protocol == DownloadProtocol.Torrent)
|
||||
{
|
||||
sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value = ((ITorrentIndexerSettings)indexer.Settings).TorrentBaseSettings.AppMinimumSeeders ?? indexer.AppProfile.Value.MinimumSeeders;
|
||||
|
||||
@@ -38,6 +38,10 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
var otherApiPath = other.Fields.FirstOrDefault(x => x.Name == "apiPath")?.Value == null ? null : other.Fields.FirstOrDefault(x => x.Name == "apiPath").Value;
|
||||
var apiPathCompare = apiPath.Equals(otherApiPath);
|
||||
|
||||
var animeStandardFormatSearch = Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch")?.Value == null ? null : (bool?)Convert.ToBoolean(Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch").Value);
|
||||
var otherAnimeStandardFormatSearch = other.Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch")?.Value == null ? null : (bool?)Convert.ToBoolean(other.Fields.FirstOrDefault(x => x.Name == "animeStandardFormatSearch").Value);
|
||||
var animeStandardFormatSearchCompare = animeStandardFormatSearch == otherAnimeStandardFormatSearch;
|
||||
|
||||
var minimumSeeders = Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value);
|
||||
var otherMinimumSeeders = other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders")?.Value == null ? null : (int?)Convert.ToInt32(other.Fields.FirstOrDefault(x => x.Name == "minimumSeeders").Value);
|
||||
var minimumSeedersCompare = minimumSeeders == otherMinimumSeeders;
|
||||
@@ -61,7 +65,7 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
other.Implementation == Implementation &&
|
||||
other.Priority == Priority &&
|
||||
other.Id == Id &&
|
||||
apiKey && apiPathCompare && baseUrl && cats && animeCats && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && seasonSeedTimeCompare;
|
||||
apiKey && apiPathCompare && baseUrl && cats && animeCats && animeStandardFormatSearchCompare && minimumSeedersCompare && seedRatioCompare && seedTimeCompare && seasonSeedTimeCompare;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
{
|
||||
ProwlarrUrl = "http://localhost:9696";
|
||||
BaseUrl = "http://localhost:8989";
|
||||
SyncCategories = new[] { 5000, 5010, 5020, 5030, 5040, 5045, 5050 };
|
||||
SyncCategories = new[] { 5000, 5010, 5020, 5030, 5040, 5045, 5050, 5090 };
|
||||
AnimeSyncCategories = new[] { 5070 };
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
[FieldDefinition(1, Label = "Sonarr Server", HelpText = "URL used to connect to Sonarr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:8989")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Sonarr in Settings/General")]
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Sonarr in Settings/General")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
@@ -43,6 +43,9 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
[FieldDefinition(4, Label = "Anime Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
public IEnumerable<int> AnimeSyncCategories { get; set; }
|
||||
|
||||
[FieldDefinition(5, Label = "Sync Anime Standard Format Search", Type = FieldType.Checkbox, Advanced = true, HelpText = "Sync also searching for anime using the standard numbering")]
|
||||
public bool SyncAnimeStandardFormatSearch { get; set; }
|
||||
|
||||
public NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
|
||||
@@ -85,13 +85,25 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
try
|
||||
{
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Debug("Retrying to add indexer forcefully");
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
public SonarrIndexer UpdateIndexer(SonarrIndexer indexer, SonarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put);
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
@@ -103,53 +115,16 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
try
|
||||
var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version");
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
var applicationVersion = _httpClient.Post<SonarrIndexer>(request).Headers.GetSingleValue("X-Application-Version");
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, "Failed to fetch Sonarr version");
|
||||
}
|
||||
|
||||
if (new Version(applicationVersion) < MinimumApplicationVersion)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, $"Sonarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
return new ValidationFailure(string.Empty, "Failed to fetch Sonarr version");
|
||||
}
|
||||
catch (HttpException ex)
|
||||
|
||||
if (new Version(applicationVersion) < MinimumApplicationVersion)
|
||||
{
|
||||
if (ex.Response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
return new ValidationFailure("ApiKey", "API Key is invalid");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Sonarr cannot connect to Prowlarr");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.SeeOther)
|
||||
{
|
||||
_logger.Error(ex, "Sonarr returned redirect and is invalid");
|
||||
return new ValidationFailure("BaseUrl", "Sonarr url is invalid, Prowlarr cannot connect to Sonarr - are you missing a url base?");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.Error(ex, "Sonarr not found");
|
||||
return new ValidationFailure("BaseUrl", "Sonarr url is invalid, Prowlarr cannot connect to Sonarr. Is Sonarr running and accessible? Sonarr v2 is not supported.");
|
||||
}
|
||||
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("BaseUrl", "Unable to complete application test");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("", "Unable to send test message");
|
||||
return new ValidationFailure(string.Empty, $"Sonarr version should be at least {MinimumApplicationVersion.ToString(3)}. Version reported is {applicationVersion}", applicationVersion);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -185,8 +160,10 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode);
|
||||
throw;
|
||||
break;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
@@ -198,15 +175,15 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
_logger.Error(ex, "Unable to add or update indexer");
|
||||
throw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpRequest BuildRequest(SonarrSettings settings, string resource, HttpMethod method)
|
||||
{
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
|
||||
var request = new HttpRequestBuilder(baseUrl).Resource(resource)
|
||||
var request = new HttpRequestBuilder(baseUrl)
|
||||
.Resource(resource)
|
||||
.Accept(HttpAccept.Json)
|
||||
.SetHeader("X-Api-Key", settings.ApiKey)
|
||||
.Build();
|
||||
|
||||
@@ -223,9 +200,12 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return results;
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation.Results;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
@@ -49,10 +51,37 @@ namespace NzbDrone.Core.Applications.Whisparr
|
||||
{
|
||||
failures.AddIfNotNull(_whisparrV3Proxy.TestConnection(BuildWhisparrIndexer(testIndexer, DownloadProtocol.Usenet), Settings));
|
||||
}
|
||||
catch (WebException ex)
|
||||
catch (HttpException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Whisparr"));
|
||||
switch (ex.Response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Unauthorized:
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ApiKey", "API Key is invalid"));
|
||||
break;
|
||||
case HttpStatusCode.BadRequest:
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Whisparr cannot connect to Prowlarr"));
|
||||
break;
|
||||
case HttpStatusCode.SeeOther:
|
||||
_logger.Error(ex, "Whisparr returned redirect and is invalid");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Whisparr URL is invalid, Prowlarr cannot connect to Whisparr - are you missing a URL base?"));
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Whisparr. {ex.Message}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to parse JSON response from application");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to parse JSON response from application. {ex.Message}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to complete application test");
|
||||
failures.AddIfNotNull(new ValidationFailure("BaseUrl", $"Unable to complete application test, cannot connect to Whisparr. {ex.Message}"));
|
||||
}
|
||||
|
||||
return new ValidationResult(failures);
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace NzbDrone.Core.Applications.Whisparr
|
||||
[FieldDefinition(1, Label = "Whisparr Server", HelpText = "URL used to connect to Whisparr server, including http(s)://, port, and urlbase if required", Placeholder = "http://localhost:6969")]
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Whisparr in Settings/General")]
|
||||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Whisparr in Settings/General")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Sync Categories", Type = FieldType.Select, SelectOptions = typeof(NewznabCategoryFieldConverter), Advanced = true, HelpText = "Only Indexers that support these categories will be synced")]
|
||||
|
||||
@@ -82,13 +82,23 @@ namespace NzbDrone.Core.Applications.Whisparr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return Execute<WhisparrIndexer>(request);
|
||||
try
|
||||
{
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
public WhisparrIndexer UpdateIndexer(WhisparrIndexer indexer, WhisparrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexer.Id}", HttpMethod.Put);
|
||||
|
||||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
return ExecuteIndexerRequest(request);
|
||||
@@ -100,38 +110,7 @@ namespace NzbDrone.Core.Applications.Whisparr
|
||||
|
||||
request.SetContent(indexer.ToJson());
|
||||
|
||||
try
|
||||
{
|
||||
Execute<WhisparrIndexer>(request);
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
if (ex.Response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.Error(ex, "API Key is invalid");
|
||||
return new ValidationFailure("ApiKey", "API Key is invalid");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_logger.Error(ex, "Prowlarr URL is invalid");
|
||||
return new ValidationFailure("ProwlarrUrl", "Prowlarr url is invalid, Whisparr cannot connect to Prowlarr");
|
||||
}
|
||||
|
||||
if (ex.Response.StatusCode == HttpStatusCode.SeeOther)
|
||||
{
|
||||
_logger.Error(ex, "Whisparr returned redirect and is invalid");
|
||||
return new ValidationFailure("BaseUrl", "Whisparr url is invalid, Prowlarr cannot connect to Whisparr - are you missing a url base?");
|
||||
}
|
||||
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("BaseUrl", "Unable to complete application test");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to send test message");
|
||||
return new ValidationFailure("", "Unable to send test message");
|
||||
}
|
||||
_httpClient.Post(request);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -166,8 +145,10 @@ namespace NzbDrone.Core.Applications.Whisparr
|
||||
break;
|
||||
default:
|
||||
_logger.Error(ex, "Unexpected response status code: {0}", ex.Response.StatusCode);
|
||||
throw;
|
||||
break;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
@@ -179,15 +160,15 @@ namespace NzbDrone.Core.Applications.Whisparr
|
||||
_logger.Error(ex, "Unable to add or update indexer");
|
||||
throw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpRequest BuildRequest(WhisparrSettings settings, string resource, HttpMethod method)
|
||||
{
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
|
||||
var request = new HttpRequestBuilder(baseUrl).Resource(resource)
|
||||
var request = new HttpRequestBuilder(baseUrl)
|
||||
.Resource(resource)
|
||||
.Accept(HttpAccept.Json)
|
||||
.SetHeader("X-Api-Key", settings.ApiKey)
|
||||
.Build();
|
||||
|
||||
@@ -204,9 +185,12 @@ namespace NzbDrone.Core.Applications.Whisparr
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
return results;
|
||||
return Json.Deserialize<TResource>(response.Content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ namespace NzbDrone.Core.Configuration
|
||||
|
||||
public int HistoryCleanupDays
|
||||
{
|
||||
get { return GetValueInt("HistoryCleanupDays", 365); }
|
||||
get { return GetValueInt("HistoryCleanupDays", 30); }
|
||||
set { SetValue("HistoryCleanupDays", value); }
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace NzbDrone.Core.Datastore
|
||||
{
|
||||
IEnumerable<TModel> All();
|
||||
int Count();
|
||||
TModel Find(int id);
|
||||
TModel Get(int id);
|
||||
TModel Insert(TModel model);
|
||||
TModel Update(TModel model);
|
||||
@@ -87,10 +88,17 @@ namespace NzbDrone.Core.Datastore
|
||||
return Query(Builder());
|
||||
}
|
||||
|
||||
public TModel Get(int id)
|
||||
public TModel Find(int id)
|
||||
{
|
||||
var model = Query(x => x.Id == id).FirstOrDefault();
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public TModel Get(int id)
|
||||
{
|
||||
var model = Find(id);
|
||||
|
||||
if (model == null)
|
||||
{
|
||||
throw new ModelNotFoundException(typeof(TModel), id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using FluentMigrator;
|
||||
@@ -17,33 +18,43 @@ namespace NzbDrone.Core.Datastore.Migration
|
||||
|
||||
private void MigrateToServerUrl(IDbConnection conn, IDbTransaction tran)
|
||||
{
|
||||
using var selectCommand = conn.CreateCommand();
|
||||
selectCommand.Transaction = tran;
|
||||
selectCommand.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Notifications\" WHERE \"Implementation\" = 'Apprise'";
|
||||
var updatedNotifications = new List<object>();
|
||||
|
||||
using var reader = selectCommand.ExecuteReader();
|
||||
|
||||
while (reader.Read())
|
||||
using (var selectCommand = conn.CreateCommand())
|
||||
{
|
||||
var id = reader.GetInt32(0);
|
||||
var settings = reader.GetString(1);
|
||||
selectCommand.Transaction = tran;
|
||||
selectCommand.CommandText = "SELECT \"Id\", \"Settings\" FROM \"Notifications\" WHERE \"Implementation\" = 'Apprise'";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings))
|
||||
using var reader = selectCommand.ExecuteReader();
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
var jsonObject = Json.Deserialize<JObject>(settings);
|
||||
var id = reader.GetInt32(0);
|
||||
var settings = reader.GetString(1);
|
||||
|
||||
if (jsonObject.ContainsKey("baseUrl"))
|
||||
if (!string.IsNullOrWhiteSpace(settings))
|
||||
{
|
||||
jsonObject.Add("serverUrl", jsonObject.Value<string>("baseUrl"));
|
||||
jsonObject.Remove("baseUrl");
|
||||
var jsonObject = Json.Deserialize<JObject>(settings);
|
||||
|
||||
if (jsonObject.ContainsKey("baseUrl"))
|
||||
{
|
||||
jsonObject.Add("serverUrl", jsonObject.Value<string>("baseUrl"));
|
||||
jsonObject.Remove("baseUrl");
|
||||
}
|
||||
|
||||
settings = jsonObject.ToJson();
|
||||
}
|
||||
|
||||
settings = jsonObject.ToJson();
|
||||
updatedNotifications.Add(new
|
||||
{
|
||||
Id = id,
|
||||
Settings = settings
|
||||
});
|
||||
}
|
||||
|
||||
var parameters = new { Settings = settings, Id = id };
|
||||
conn.Execute("UPDATE Notifications SET Settings = @Settings WHERE Id = @Id", parameters, transaction: tran);
|
||||
}
|
||||
|
||||
var updateNotificationsSql = "UPDATE \"Notifications\" SET \"Settings\" = @Settings WHERE \"Id\" = @Id";
|
||||
conn.Execute(updateNotificationsSql, updatedNotifications, transaction: tran);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using FluentMigrator;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(034)]
|
||||
public class history_fix_data_titles : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Execute.WithConnection(MigrateHistoryDataTitle);
|
||||
}
|
||||
|
||||
private void MigrateHistoryDataTitle(IDbConnection conn, IDbTransaction tran)
|
||||
{
|
||||
var updatedHistory = new List<object>();
|
||||
|
||||
using (var selectCommand = conn.CreateCommand())
|
||||
{
|
||||
selectCommand.Transaction = tran;
|
||||
selectCommand.CommandText = "SELECT \"Id\", \"Data\", \"EventType\" FROM \"History\" WHERE \"EventType\" != 3";
|
||||
|
||||
using var reader = selectCommand.ExecuteReader();
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
var id = reader.GetInt32(0);
|
||||
var data = reader.GetString(1);
|
||||
var eventType = reader.GetInt32(2);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(data))
|
||||
{
|
||||
var jsonObject = Json.Deserialize<JObject>(data);
|
||||
|
||||
if (eventType == 1 && jsonObject.ContainsKey("title"))
|
||||
{
|
||||
jsonObject.Add("grabTitle", jsonObject.Value<string>("title"));
|
||||
jsonObject.Remove("title");
|
||||
}
|
||||
|
||||
if (eventType != 1 && jsonObject.ContainsKey("bookTitle"))
|
||||
{
|
||||
jsonObject.Add("title", jsonObject.Value<string>("bookTitle"));
|
||||
jsonObject.Remove("bookTitle");
|
||||
}
|
||||
|
||||
data = jsonObject.ToJson();
|
||||
|
||||
if (!jsonObject.ContainsKey("grabTitle") && !jsonObject.ContainsKey("title"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedHistory.Add(new
|
||||
{
|
||||
Id = id,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var updateHistorySql = "UPDATE \"History\" SET \"Data\" = @Data WHERE \"Id\" = @Id";
|
||||
conn.Execute(updateHistorySql, updatedHistory, transaction: tran);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(035)]
|
||||
public class download_client_per_indexer : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Alter.Table("Indexers").AddColumn("DownloadClientId").AsInt32().WithDefaultValue(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Core.Download.Clients;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
namespace NzbDrone.Core.Download
|
||||
{
|
||||
public interface IProvideDownloadClient
|
||||
{
|
||||
IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol);
|
||||
IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0);
|
||||
IEnumerable<IDownloadClient> GetDownloadClients();
|
||||
IDownloadClient Get(int id);
|
||||
}
|
||||
@@ -18,17 +19,23 @@ namespace NzbDrone.Core.Download
|
||||
private readonly Logger _logger;
|
||||
private readonly IDownloadClientFactory _downloadClientFactory;
|
||||
private readonly IDownloadClientStatusService _downloadClientStatusService;
|
||||
private readonly IIndexerFactory _indexerFactory;
|
||||
private readonly ICached<int> _lastUsedDownloadClient;
|
||||
|
||||
public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, IDownloadClientFactory downloadClientFactory, ICacheManager cacheManager, Logger logger)
|
||||
public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService,
|
||||
IDownloadClientFactory downloadClientFactory,
|
||||
IIndexerFactory indexerFactory,
|
||||
ICacheManager cacheManager,
|
||||
Logger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_downloadClientFactory = downloadClientFactory;
|
||||
_downloadClientStatusService = downloadClientStatusService;
|
||||
_indexerFactory = indexerFactory;
|
||||
_lastUsedDownloadClient = cacheManager.GetCache<int>(GetType(), "lastDownloadClientId");
|
||||
}
|
||||
|
||||
public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol)
|
||||
public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol, int indexerId = 0)
|
||||
{
|
||||
var availableProviders = _downloadClientFactory.GetAvailableProviders().Where(v => v.Protocol == downloadProtocol).ToList();
|
||||
|
||||
@@ -37,6 +44,23 @@ namespace NzbDrone.Core.Download
|
||||
return null;
|
||||
}
|
||||
|
||||
if (indexerId > 0)
|
||||
{
|
||||
var indexer = _indexerFactory.Find(indexerId);
|
||||
|
||||
if (indexer is { DownloadClientId: > 0 })
|
||||
{
|
||||
var client = availableProviders.SingleOrDefault(d => d.Definition.Id == indexer.DownloadClientId);
|
||||
|
||||
if (client == null)
|
||||
{
|
||||
throw new DownloadClientUnavailableException("Indexer specified download client is not available");
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
var blockedProviders = new HashSet<int>(_downloadClientStatusService.GetBlockedProviders().Select(v => v.ProviderId));
|
||||
|
||||
if (blockedProviders.Any())
|
||||
@@ -54,7 +78,7 @@ namespace NzbDrone.Core.Download
|
||||
}
|
||||
|
||||
// Use the first priority clients first
|
||||
availableProviders = availableProviders.GroupBy(v => (v.Definition as DownloadClientDefinition).Priority)
|
||||
availableProviders = availableProviders.GroupBy(v => ((DownloadClientDefinition)v.Definition).Priority)
|
||||
.OrderBy(v => v.Key)
|
||||
.First().OrderBy(v => v.Definition.Id).ToList();
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace NzbDrone.Core.Download
|
||||
{
|
||||
public interface IDownloadService
|
||||
{
|
||||
Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect);
|
||||
Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect, int? downloadClientId);
|
||||
Task<byte[]> DownloadReport(string link, int indexerId, string source, string host, string title);
|
||||
void RecordRedirect(string link, int indexerId, string source, string host, string title);
|
||||
}
|
||||
@@ -48,10 +48,18 @@ namespace NzbDrone.Core.Download
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect)
|
||||
public async Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect, int? downloadClientId)
|
||||
{
|
||||
var downloadClient = downloadClientId.HasValue
|
||||
? _downloadClientProvider.Get(downloadClientId.Value)
|
||||
: _downloadClientProvider.GetDownloadClient(release.DownloadProtocol, release.IndexerId);
|
||||
|
||||
await SendReportToClient(release, source, host, redirect, downloadClient);
|
||||
}
|
||||
|
||||
private async Task SendReportToClient(ReleaseInfo release, string source, string host, bool redirect, IDownloadClient downloadClient)
|
||||
{
|
||||
var downloadTitle = release.Title;
|
||||
var downloadClient = _downloadClientProvider.GetDownloadClient(release.DownloadProtocol);
|
||||
|
||||
if (downloadClient == null)
|
||||
{
|
||||
|
||||
@@ -28,13 +28,12 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
{
|
||||
var enabledProviders = _providerFactory.GetAvailableProviders();
|
||||
var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(),
|
||||
i => i.Definition.Id,
|
||||
s => s.ProviderId,
|
||||
(i, s) => new { Provider = i, Status = s })
|
||||
.Where(p => p.Status.InitialFailure.HasValue &&
|
||||
p.Status.InitialFailure.Value.Before(
|
||||
DateTime.UtcNow.AddHours(-6)))
|
||||
.ToList();
|
||||
i => i.Definition.Id,
|
||||
s => s.ProviderId,
|
||||
(i, s) => new { Provider = i, Status = s })
|
||||
.Where(p => p.Status.InitialFailure.HasValue &&
|
||||
p.Status.InitialFailure.Value.Before(DateTime.UtcNow.AddHours(-6)))
|
||||
.ToList();
|
||||
|
||||
if (backOffProviders.Empty())
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
[CheckOn(typeof(ProviderAddedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderUpdatedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderDeletedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderBulkUpdatedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderBulkDeletedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderStatusChangedEvent<IIndexer>))]
|
||||
public class IndexerCheck : HealthCheckBase
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
{
|
||||
[CheckOn(typeof(ProviderUpdatedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderDeletedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderBulkUpdatedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderBulkDeletedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderStatusChangedEvent<IIndexer>))]
|
||||
public class IndexerLongTermStatusCheck : HealthCheckBase
|
||||
@@ -29,13 +30,12 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
{
|
||||
var enabledProviders = _providerFactory.GetAvailableProviders();
|
||||
var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(),
|
||||
i => i.Definition.Id,
|
||||
s => s.ProviderId,
|
||||
(i, s) => new { Provider = i, Status = s })
|
||||
.Where(p => p.Status.InitialFailure.HasValue &&
|
||||
p.Status.InitialFailure.Value.Before(
|
||||
DateTime.UtcNow.AddHours(-6)))
|
||||
.ToList();
|
||||
i => i.Definition.Id,
|
||||
s => s.ProviderId,
|
||||
(i, s) => new { Provider = i, Status = s })
|
||||
.Where(p => p.Status.InitialFailure.HasValue &&
|
||||
p.Status.InitialFailure.Value.Before(DateTime.UtcNow.AddHours(-6)))
|
||||
.ToList();
|
||||
|
||||
if (backOffProviders.Empty())
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
{
|
||||
[CheckOn(typeof(ProviderUpdatedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderDeletedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderBulkUpdatedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderBulkDeletedEvent<IIndexer>))]
|
||||
[CheckOn(typeof(ProviderStatusChangedEvent<IIndexer>))]
|
||||
public class IndexerStatusCheck : HealthCheckBase
|
||||
@@ -27,13 +28,12 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
{
|
||||
var enabledProviders = _providerFactory.GetAvailableProviders();
|
||||
var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(),
|
||||
i => i.Definition.Id,
|
||||
s => s.ProviderId,
|
||||
(i, s) => new { Provider = i, Status = s })
|
||||
.Where(p => p.Status.InitialFailure.HasValue &&
|
||||
p.Status.InitialFailure.Value.After(
|
||||
DateTime.UtcNow.AddHours(-6)))
|
||||
.ToList();
|
||||
i => i.Definition.Id,
|
||||
s => s.ProviderId,
|
||||
(i, s) => new { Provider = i, Status = s })
|
||||
.Where(p => p.Status.InitialFailure.HasValue &&
|
||||
p.Status.InitialFailure.Value.After(DateTime.UtcNow.AddHours(-6)))
|
||||
.ToList();
|
||||
|
||||
if (backOffProviders.Empty())
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
var currentDefs = _indexerDefinitionUpdateService.All();
|
||||
|
||||
var noDefIndexers = _indexerFactory.AllProviders(false)
|
||||
.Where(i => i.Definition.Implementation == "Cardigann" && !currentDefs.Any(d => d.File == ((CardigannSettings)i.Definition.Settings).DefinitionFile)).ToList();
|
||||
.Where(i => i.Definition.Implementation == "Cardigann" && currentDefs.All(d => d.File != ((CardigannSettings)i.Definition.Settings).DefinitionFile)).ToList();
|
||||
|
||||
if (noDefIndexers.Count == 0)
|
||||
{
|
||||
|
||||
@@ -128,52 +128,58 @@ namespace NzbDrone.Core.History
|
||||
Successful = response?.StatusCode == HttpStatusCode.OK || (response is { Request: { SuppressHttpError: true, SuppressHttpErrorStatusCodes: not null } } && response.Request.SuppressHttpErrorStatusCodes.Contains(response.StatusCode))
|
||||
};
|
||||
|
||||
if (message.Query is MovieSearchCriteria)
|
||||
if (message.Query is MovieSearchCriteria movieSearchCriteria)
|
||||
{
|
||||
history.Data.Add("ImdbId", ((MovieSearchCriteria)message.Query).FullImdbId ?? string.Empty);
|
||||
history.Data.Add("TmdbId", ((MovieSearchCriteria)message.Query).TmdbId?.ToString() ?? string.Empty);
|
||||
history.Data.Add("TraktId", ((MovieSearchCriteria)message.Query).TraktId?.ToString() ?? string.Empty);
|
||||
history.Data.Add("Year", ((MovieSearchCriteria)message.Query).Year?.ToString() ?? string.Empty);
|
||||
history.Data.Add("Genre", ((MovieSearchCriteria)message.Query).Genre ?? string.Empty);
|
||||
history.Data.Add("ImdbId", movieSearchCriteria.FullImdbId);
|
||||
history.Data.Add("TmdbId", movieSearchCriteria.TmdbId?.ToString());
|
||||
history.Data.Add("TraktId", movieSearchCriteria.TraktId?.ToString());
|
||||
history.Data.Add("Year", movieSearchCriteria.Year?.ToString());
|
||||
history.Data.Add("Genre", movieSearchCriteria.Genre);
|
||||
}
|
||||
|
||||
if (message.Query is TvSearchCriteria)
|
||||
if (message.Query is TvSearchCriteria tvSearchCriteria)
|
||||
{
|
||||
history.Data.Add("ImdbId", ((TvSearchCriteria)message.Query).FullImdbId ?? string.Empty);
|
||||
history.Data.Add("TvdbId", ((TvSearchCriteria)message.Query).TvdbId?.ToString() ?? string.Empty);
|
||||
history.Data.Add("TmdbId", ((TvSearchCriteria)message.Query).TmdbId?.ToString() ?? string.Empty);
|
||||
history.Data.Add("TraktId", ((TvSearchCriteria)message.Query).TraktId?.ToString() ?? string.Empty);
|
||||
history.Data.Add("RId", ((TvSearchCriteria)message.Query).RId?.ToString() ?? string.Empty);
|
||||
history.Data.Add("TvMazeId", ((TvSearchCriteria)message.Query).TvMazeId?.ToString() ?? string.Empty);
|
||||
history.Data.Add("Season", ((TvSearchCriteria)message.Query).Season?.ToString() ?? string.Empty);
|
||||
history.Data.Add("Episode", ((TvSearchCriteria)message.Query).Episode ?? string.Empty);
|
||||
history.Data.Add("Year", ((TvSearchCriteria)message.Query).Year?.ToString() ?? string.Empty);
|
||||
history.Data.Add("Genre", ((TvSearchCriteria)message.Query).Genre ?? string.Empty);
|
||||
history.Data.Add("ImdbId", tvSearchCriteria.FullImdbId);
|
||||
history.Data.Add("TvdbId", tvSearchCriteria.TvdbId?.ToString());
|
||||
history.Data.Add("TmdbId", tvSearchCriteria.TmdbId?.ToString());
|
||||
history.Data.Add("TraktId", tvSearchCriteria.TraktId?.ToString());
|
||||
history.Data.Add("RId", tvSearchCriteria.RId?.ToString());
|
||||
history.Data.Add("TvMazeId", tvSearchCriteria.TvMazeId?.ToString());
|
||||
history.Data.Add("Season", tvSearchCriteria.Season?.ToString());
|
||||
history.Data.Add("Episode", tvSearchCriteria.Episode);
|
||||
history.Data.Add("Year", tvSearchCriteria.Year?.ToString());
|
||||
history.Data.Add("Genre", tvSearchCriteria.Genre);
|
||||
}
|
||||
|
||||
if (message.Query is MusicSearchCriteria)
|
||||
if (message.Query is MusicSearchCriteria musicSearchCriteria)
|
||||
{
|
||||
history.Data.Add("Artist", ((MusicSearchCriteria)message.Query).Artist ?? string.Empty);
|
||||
history.Data.Add("Album", ((MusicSearchCriteria)message.Query).Album ?? string.Empty);
|
||||
history.Data.Add("Track", ((MusicSearchCriteria)message.Query).Track ?? string.Empty);
|
||||
history.Data.Add("Label", ((MusicSearchCriteria)message.Query).Label ?? string.Empty);
|
||||
history.Data.Add("Year", ((MusicSearchCriteria)message.Query).Year?.ToString() ?? string.Empty);
|
||||
history.Data.Add("Genre", ((MusicSearchCriteria)message.Query).Genre ?? string.Empty);
|
||||
history.Data.Add("Artist", musicSearchCriteria.Artist);
|
||||
history.Data.Add("Album", musicSearchCriteria.Album);
|
||||
history.Data.Add("Track", musicSearchCriteria.Track);
|
||||
history.Data.Add("Label", musicSearchCriteria.Label);
|
||||
history.Data.Add("Year", musicSearchCriteria.Year?.ToString());
|
||||
history.Data.Add("Genre", musicSearchCriteria.Genre);
|
||||
}
|
||||
|
||||
if (message.Query is BookSearchCriteria)
|
||||
if (message.Query is BookSearchCriteria bookSearchCriteria)
|
||||
{
|
||||
history.Data.Add("Author", ((BookSearchCriteria)message.Query).Author ?? string.Empty);
|
||||
history.Data.Add("BookTitle", ((BookSearchCriteria)message.Query).Title ?? string.Empty);
|
||||
history.Data.Add("Publisher", ((BookSearchCriteria)message.Query).Publisher ?? string.Empty);
|
||||
history.Data.Add("Year", ((BookSearchCriteria)message.Query).Year?.ToString() ?? string.Empty);
|
||||
history.Data.Add("Genre", ((BookSearchCriteria)message.Query).Genre ?? string.Empty);
|
||||
history.Data.Add("Author", bookSearchCriteria.Author);
|
||||
history.Data.Add("Title", bookSearchCriteria.Title);
|
||||
history.Data.Add("Publisher", bookSearchCriteria.Publisher);
|
||||
history.Data.Add("Year", bookSearchCriteria.Year?.ToString());
|
||||
history.Data.Add("Genre", bookSearchCriteria.Genre);
|
||||
}
|
||||
|
||||
history.Data.Add("Limit", message.Query.Limit?.ToString());
|
||||
history.Data.Add("Offset", message.Query.Offset?.ToString());
|
||||
|
||||
// Clean empty data
|
||||
history.Data = history.Data.Where(d => d.Value != null).ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
history.Data.Add("ElapsedTime", message.QueryResult.Cached ? "0" : message.QueryResult.Response?.ElapsedTime.ToString() ?? string.Empty);
|
||||
history.Data.Add("Query", message.Query.SearchTerm ?? string.Empty);
|
||||
history.Data.Add("QueryType", message.Query.SearchType ?? string.Empty);
|
||||
history.Data.Add("Categories", string.Join(",", message.Query.Categories) ?? string.Empty);
|
||||
history.Data.Add("Categories", string.Join(",", message.Query.Categories ?? Array.Empty<int>()));
|
||||
history.Data.Add("Source", message.Query.Source ?? string.Empty);
|
||||
history.Data.Add("Host", message.Query.Host ?? string.Empty);
|
||||
history.Data.Add("QueryResults", message.QueryResult.Releases?.Count.ToString() ?? string.Empty);
|
||||
@@ -196,7 +202,7 @@ namespace NzbDrone.Core.History
|
||||
history.Data.Add("Source", message.Source ?? string.Empty);
|
||||
history.Data.Add("Host", message.Host ?? string.Empty);
|
||||
history.Data.Add("GrabMethod", message.Redirect ? "Redirect" : "Proxy");
|
||||
history.Data.Add("Title", message.Title);
|
||||
history.Data.Add("GrabTitle", message.Title);
|
||||
history.Data.Add("Url", message.Url ?? string.Empty);
|
||||
|
||||
_historyRepository.Insert(history);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using NzbDrone.Common.Extensions;
|
||||
|
||||
namespace NzbDrone.Core.IndexerSearch
|
||||
{
|
||||
public class NewznabRequest
|
||||
{
|
||||
private static readonly Regex TvRegex = new Regex(@"\{((?:imdbid\:)(?<imdbid>[^{]+)|(?:rid\:)(?<rid>[^{]+)|(?:tvdbid\:)(?<tvdbid>[^{]+)|(?:tmdbid\:)(?<tmdbid>[^{]+)|(?:doubanid\:)(?<doubanid>[^{]+)|(?:season\:)(?<season>[^{]+)|(?:episode\:)(?<episode>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex MovieRegex = new Regex(@"\{((?:imdbid\:)(?<imdbid>[^{]+)|(?:doubanid\:)(?<doubanid>[^{]+)|(?:tmdbid\:)(?<tmdbid>[^{]+)|(?:traktid\:)(?<traktid>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex MusicRegex = new Regex(@"\{((?:artist\:)(?<artist>[^{]+)|(?:album\:)(?<album>[^{]+)|(?:track\:)(?<track>[^{]+)|(?:label\:)(?<label>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex BookRegex = new Regex(@"\{((?:author\:)(?<author>[^{]+)|(?:publisher\:)(?<publisher>[^{]+)|(?:title\:)(?<title>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex TvRegex = new (@"\{((?:imdbid\:)(?<imdbid>[^{]+)|(?:rid\:)(?<rid>[^{]+)|(?:tvdbid\:)(?<tvdbid>[^{]+)|(?:tmdbid\:)(?<tmdbid>[^{]+)|(?:doubanid\:)(?<doubanid>[^{]+)|(?:season\:)(?<season>[^{]+)|(?:episode\:)(?<episode>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex MovieRegex = new (@"\{((?:imdbid\:)(?<imdbid>[^{]+)|(?:doubanid\:)(?<doubanid>[^{]+)|(?:tmdbid\:)(?<tmdbid>[^{]+)|(?:traktid\:)(?<traktid>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex MusicRegex = new (@"\{((?:artist\:)(?<artist>[^{]+)|(?:album\:)(?<album>[^{]+)|(?:track\:)(?<track>[^{]+)|(?:label\:)(?<label>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex BookRegex = new (@"\{((?:author\:)(?<author>[^{]+)|(?:publisher\:)(?<publisher>[^{]+)|(?:title\:)(?<title>[^{]+)|(?:year\:)(?<year>[^{]+)|(?:genre\:)(?<genre>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public string t { get; set; }
|
||||
public string q { get; set; }
|
||||
@@ -40,6 +41,11 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
|
||||
public void QueryToParams()
|
||||
{
|
||||
if (q.IsNullOrWhiteSpace())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (t == "tvsearch")
|
||||
{
|
||||
var matches = TvRegex.Matches(q);
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using NzbDrone.Core.Exceptions;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.Events;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
@@ -157,15 +158,22 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
{
|
||||
var indexers = _indexerFactory.Enabled();
|
||||
|
||||
if (criteriaBase.IndexerIds != null && criteriaBase.IndexerIds.Count > 0)
|
||||
if (criteriaBase.IndexerIds is { Count: > 0 })
|
||||
{
|
||||
indexers = indexers.Where(i => criteriaBase.IndexerIds.Contains(i.Definition.Id) ||
|
||||
(criteriaBase.IndexerIds.Contains(-1) && i.Protocol == DownloadProtocol.Usenet) ||
|
||||
(criteriaBase.IndexerIds.Contains(-2) && i.Protocol == DownloadProtocol.Torrent))
|
||||
.ToList();
|
||||
|
||||
if (indexers.Count == 0)
|
||||
{
|
||||
_logger.Debug("Search failed due to all selected indexers being unavailable: {0}", string.Join(", ", criteriaBase.IndexerIds));
|
||||
|
||||
throw new SearchFailedException("Search failed due to all selected indexers being unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
if (criteriaBase.Categories != null && criteriaBase.Categories.Length > 0)
|
||||
if (criteriaBase.Categories is { Length: > 0 })
|
||||
{
|
||||
//Only query supported indexers
|
||||
indexers = indexers.Where(i => ((IndexerDefinition)i.Definition).Capabilities.Categories.SupportedCategories(criteriaBase.Categories).Any()).ToList();
|
||||
@@ -173,6 +181,7 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
if (indexers.Count == 0)
|
||||
{
|
||||
_logger.Debug("All provided categories are unsupported by selected indexers: {0}", string.Join(", ", criteriaBase.Categories));
|
||||
|
||||
return Array.Empty<ReleaseInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,14 @@ namespace NzbDrone.Core.IndexerStats
|
||||
};
|
||||
|
||||
var sortedEvents = indexer.OrderBy(v => v.Date)
|
||||
.ThenBy(v => v.Id)
|
||||
.ToArray();
|
||||
var temp = 0;
|
||||
.ThenBy(v => v.Id)
|
||||
.ToArray();
|
||||
|
||||
var elapsedTimeEvents = sortedEvents.Where(h => int.TryParse(h.Data.GetValueOrDefault("elapsedTime"), out temp))
|
||||
.Select(h => temp);
|
||||
var temp = 0;
|
||||
var elapsedTimeEvents = sortedEvents
|
||||
.Where(h => int.TryParse(h.Data.GetValueOrDefault("elapsedTime"), out temp) && h.Data.GetValueOrDefault("cached") != "1")
|
||||
.Select(h => temp)
|
||||
.ToArray();
|
||||
|
||||
indexerStats.AverageResponseTime = elapsedTimeEvents.Any() ? (int)elapsedTimeEvents.Average() : 0;
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Description => "Anidex is a Public torrent tracker and indexer, primarily for English fansub groups of anime";
|
||||
public override string Language => "en-US";
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Public;
|
||||
public override IndexerCapabilities Capabilities => SetCapabilities();
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Description => "Anidub is RUSSIAN anime voiceover group and eponymous anime tracker.";
|
||||
public override string Language => "ru-RU";
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.SemiPrivate;
|
||||
public override IndexerCapabilities Capabilities => SetCapabilities();
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Description => "AnimeBytes (AB) is the largest private torrent tracker that specialises in anime and anime-related content.";
|
||||
public override string Language => "en-US";
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
|
||||
public override IndexerCapabilities Capabilities => SetCapabilities();
|
||||
public override TimeSpan RateLimit => TimeSpan.FromSeconds(4);
|
||||
|
||||
@@ -24,7 +24,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Name => "AnimeTorrents";
|
||||
public override string[] IndexerUrls => new[] { "https://animetorrents.me/" };
|
||||
public override string Description => "Definitive source for anime and manga";
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
|
||||
public override bool SupportsPagination => true;
|
||||
public override TimeSpan RateLimit => TimeSpan.FromSeconds(4);
|
||||
|
||||
@@ -24,7 +24,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Description => "Animedia is RUSSIAN anime voiceover group and eponymous anime tracker.";
|
||||
public override string Language => "ru-RU";
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Public;
|
||||
public override IndexerCapabilities Capabilities => SetCapabilities();
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Description => "A movies tracker";
|
||||
public override string Language => "en-US";
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
|
||||
public override IndexerCapabilities Capabilities => SetCapabilities();
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ public class AudioBookBay : TorrentIndexerBase<NoAuthTorrentBaseSettings>
|
||||
};
|
||||
public override string Description => "AudioBook Bay (ABB) is a public Torrent Tracker for AUDIOBOOKS";
|
||||
public override string Language => "en-US";
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Public;
|
||||
public override int PageSize => 15;
|
||||
public override IndexerCapabilities Capabilities => SetCapabilities();
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace NzbDrone.Core.Indexers.Definitions.Avistaz
|
||||
{
|
||||
public abstract class AvistazBase : TorrentIndexerBase<AvistazSettings>
|
||||
{
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override bool SupportsRss => true;
|
||||
public override bool SupportsSearch => true;
|
||||
public override bool SupportsPagination => true;
|
||||
|
||||
@@ -28,7 +28,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Description => "BB is a Private Torrent Tracker for 0DAY / GENERAL";
|
||||
public override string Language => "en-US";
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
|
||||
public override IndexerCapabilities Capabilities => SetCapabilities();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user