Compare commits

...

54 Commits

Author SHA1 Message Date
ta264
1d20b9d429 Fixed: Don't delete tags used by indexer proxies 2021-08-23 22:12:40 +01:00
bakerboy448
46e1cce632 New: (TorrentLeech) Add VIP Expiration
Closes #419
bonus- sort en.json
2021-08-23 22:20:41 -05:00
Qstick
03f821f484 New: Make VIP Check Generic 2021-08-23 22:35:28 -04:00
Qstick
c72222a696 Mylar code cleanup 2021-08-22 22:24:46 -04:00
bakerboy448
f4cee1d5f4 Fixed: UserAgent Parsing
Fixes #448

New - add user agent parser tests
2021-08-22 15:00:59 -05:00
Qstick
ab7bc85368 fix mass delete 2021-08-22 15:53:30 -04:00
bakerboy448
d50e1d7cc0 update readme [skip ci] 2021-08-22 10:25:37 -05:00
Qstick
ab1545e834 Fixed: Mass Indexer delete fails with 415 2021-08-22 11:04:00 -04:00
Weblate
c8cc48229c Translated using Weblate (French)
Currently translated at 97.9% (429 of 438 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (438 of 438 strings)

Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: ProBatou <baptiste2105@hotmail.fr>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translation: Servarr/Prowlarr
2021-08-21 23:00:33 -04:00
Steve Adams
b513fac2f7 Fixed: (HDBits) Not parsing the search term for TV (#444)
* Fixing HDBits not parsing the search term

* remove debug vars

* Handle TVDB Properly
2021-08-21 22:59:53 -04:00
Qstick
368e0755a0 Update README.md
[skip ci]
2021-08-21 22:56:50 -04:00
PearsonFlyer
27064cd293 Fixed: Clarify IPT cookie help text (#421)
* Fixed: Clarify IPT cookie help text

* Update src/NzbDrone.Core/Indexers/Definitions/IPTorrents.cs

Co-authored-by: bakerboy448 <55419169+bakerboy448@users.noreply.github.com>

Co-authored-by: PearsonFlyer <PearsonFlyer@github.com>
Co-authored-by: Qstick <qstick@gmail.com>
Co-authored-by: bakerboy448 <55419169+bakerboy448@users.noreply.github.com>
2021-08-21 00:12:39 -04:00
Qstick
1fe8c63d41 Update README.md
[skip ci]
2021-08-20 23:45:24 -04:00
Qstick
b18e226718 Bump to 0.1.1 2021-08-20 23:29:08 -04:00
Qstick
682afc2c75 Fixed: (Rarbg) Blank baseUrl drop-down (#443)
* Fixed: (Rarbg) Blank baseUrl drop-down

* fixup! Using missing
2021-08-20 22:50:59 -04:00
Qstick
1ef43c40c0 Fixed: (BakaBT) Torrent downloads failing
Fixes #442
2021-08-19 23:37:14 -04:00
Robin Dadswell
b46e2c6ad1 New: Renamed Blacklist to Blocklist 2021-08-19 20:08:26 -04:00
Qstick
0a17b7e8ae Fixed: Send Link element in nab response 2021-08-18 22:23:22 -04:00
bakerboy448
b54c7e220e Fixed: Update AppIndexerRegex to support indexer ids over 100
Fixed Update AppIndexerRegex to support /api trailing (Mylar3)
2021-08-18 21:23:22 -04:00
bakerboy448
efb2a5751c Fixed: (Mylar3) Indexer Host needs to include trailing /api 2021-08-18 21:23:22 -04:00
Yukine
2cd0dde4e2 Fixed: (Indexer) AnimeBytes Synonymns & Links are optional 2021-08-17 18:50:32 -04:00
Weblate
de86274b08 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.0% (434 of 438 strings)

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translation: Servarr/Prowlarr
2021-08-17 18:50:02 -04:00
Qstick
4a957b618e Fixed: Proxy HealthCheck should use Proxy.Test method
Fixes #431
2021-08-17 07:45:20 -04:00
Qstick
891ca0f56b Fixed: (Sonarr) Workaround Sonarr issue with caps and basic search
Fixes #430
2021-08-16 21:09:55 -04:00
Qstick
635fa78da9 Fixed: Cursor Jumps to end in when editing search term
Fixes #290
2021-08-15 22:41:33 -04:00
Qstick
b7731faedc Fixed: (Indexer Proxy) Socks4 and Socks5 not using Credentials 2021-08-15 19:48:14 -04:00
bakerboy448
7c1f5f769d Fixed: Settings Page Translates
New: Indexer Proxies Settings Page Links
- renamed Connect to Notifications
2021-08-15 09:15:07 -05:00
bakerboy448
e8c6103cc7 Fixed: Missing Indexer Proxy Modal translate 2021-08-15 09:15:07 -05:00
Qstick
742c0d02bc New: FlareSolverr Proxy Option 2021-08-15 01:57:00 -04:00
Qstick
7480ebea85 New: Per Indexer Proxies
Fixes #281
2021-08-15 01:57:00 -04:00
Qstick
31886e8d35 New: (Newznab) Parse PosterUrl when available 2021-08-15 00:42:51 -04:00
Qstick
252b9a1b6b Fixed: (Sonarr) Correctly set anime categories 2021-08-14 23:56:37 -04:00
Qstick
77892a3885 New: Sync Indexers with Mylar3 2021-08-14 23:50:47 -04:00
bakerboy448
09d839ffb1 Fixed: Incorrectly Cleansing TorrentLeech Search URL 2021-08-14 21:36:05 -04:00
Weblate
1c15932b90 Translated using Weblate (German)
Currently translated at 98.8% (421 of 426 strings)

Translated using Weblate (German)

Currently translated at 98.3% (419 of 426 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (426 of 426 strings)

Translated using Weblate (Portuguese)

Currently translated at 85.4% (364 of 426 strings)

Co-authored-by: Nyuels <nils.faerber@rwth-aachen.de>
Co-authored-by: Nyuels <nyuels@nyuels.de>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Will Segatto <segatto.w@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translation: Servarr/Prowlarr
2021-08-14 20:25:33 -04:00
Qstick
27c643d2f5 New: (DownloadClient) Aria2 2021-08-14 20:07:54 -04:00
Qstick
9a6391873f Fixed: (HDBits) Unknown Categories on every release
Fixes #365
2021-08-14 17:15:54 -04:00
Qstick
12ae8edc50 New: (Indexer) Binsearch 2021-08-14 15:39:46 -04:00
Qstick
868f779c5d Fixed: (Newznab) Don't die if TV or Music only
Fixes #283
2021-08-13 22:16:19 -04:00
Qstick
204052de9c Bump Dapper to 2.0.90 2021-08-13 21:46:57 -04:00
Qstick
878e269e70 Bump MailKit to 2.14.0 2021-08-13 21:42:26 -04:00
Qstick
1e317dac6b Bump Net5 to 5.0.9 2021-08-13 21:40:34 -04:00
Qstick
305df2fb7b Bump Microsoft.NET.Test.Sdk to 16.11.0 2021-08-13 21:38:31 -04:00
Qstick
3e07a9397c Bump DryIOC to 4.8.1 2021-08-13 21:34:39 -04:00
Qstick
3e2d3c510a Bump AngleSharp to 0.16.0 2021-08-13 21:31:13 -04:00
Qstick
bdcead007c Cleanup: (Rutracker) Purge unused using statements 2021-08-13 21:28:01 -04:00
Qstick
6c5d48621f Fixed: (Indexer) Use Indexer Encoding for Query and Auth
Fixes #370
2021-08-13 21:25:52 -04:00
Qstick
a95465195d New: (Indexer) BB 2021-08-13 18:20:40 -04:00
Qstick
5d5e2042d0 Fixed: Handle response when magnet is empty
Fixes #404
2021-08-13 16:46:48 -04:00
Qstick
5d980a175c Fixed: (DanishBytes) Handle response with null bumped_at
Fixes #384
2021-08-13 16:42:49 -04:00
Qstick
2114db02d8 Fixed: (Rarbg) Advertise IMDB, TMDB, TVDB Search
Fixes #392
2021-08-13 16:00:09 -04:00
Qstick
4bd23a60bd Fixed: Pad seasons parameter for newznab tvsearch 2021-08-13 15:37:25 -04:00
chryzsh
fe324dcc0c Fixed: (Indexer) PTP IMDB search 2021-08-13 07:42:21 -04:00
johnpyp
d5b34e8c03 Fixed: (Indexer) BroadcastheNet - Report TvdbId and RId 2021-08-09 22:40:14 -05:00
215 changed files with 4552 additions and 610 deletions

View File

@@ -7,17 +7,18 @@
[![Backers on Open Collective](https://opencollective.com/Prowlarr/backers/badge.svg)](#backers)
[![Sponsors on Open Collective](https://opencollective.com/Prowlarr/sponsors/badge.svg)](#sponsors)
Prowlarr is a indexer manager/proxy built on the popular arr .net/reactjs base stack to integrate with your various PVR apps. Prowlarr supports both Torrent Trackers and Usenet Indexers. It integrates seamlessly with Sonarr, Radarr, Lidarr, and Readarr offering complete management of your indexers with no per app Indexer setup required (we do it all).
Prowlarr is an indexer manager/proxy built on the popular arr .net/reactjs base stack to integrate with your various PVR apps. Prowlarr supports management of both Torrent Trackers and Usenet Indexers. It integrates seamlessly with Lidarr, Mylar3, Radarr, Readarr, and Sonarr offering complete management of your indexers with no per app Indexer setup required (we do it all).
## Major Features Include:
- Usenet support for 24 indexers natively, including Headphones VIP, and support for any Newznab compatible indexer via "Generic Newznab"
- Torrent support for over 500 trackers with more added all the time
- Torrent support for any Torznab compatible tracker via "Generic Torznab"
- Indexer Sync to Sonarr/Radarr/Readarr/Lidarr, so no manual configuration of the other applications are required
- Indexer History and Statistics
- Manual Searching of Trackers & Indexers at a category level
- Indexer Sync to Sonarr/Radarr/Readarr/Lidarr/Mylar3, so no manual configuration of the other applications are required
- Indexer history and statistics
- Manual searching of Trackers & Indexers at a category level
- Support for pushing releases directly to your download clients from Prowlarr
- Indexer health and status notifications
- Per Indexer proxy support (SOCKS4, SOCKS5, HTTP, Flaresolverr)
## Support
Note: Prowlarr is currently early in life, thus bugs should be expected
@@ -35,9 +36,13 @@ Note: Prowlarr is currently early in life, thus bugs should be expected
- Request or vote on an existing request for a new tracker/indexer
## Contributors & Developers
This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md).
<a href="https://github.com/Prowlarr/Prowlarr/graphs/contributors"><img src="https://opencollective.com/Prowlarr/contributors.svg?width=890&button=false" /></a>
- [Contribute (GitHub)](CONTRIBUTING.md)
- [Contribution (Wiki Article)](https://wiki.servarr.com/prowlarr/contributing)
- [YML Indexer Defintion (Wiki Article)](https://wiki.servarr.com/prowlarr/cardigann-yml-definition)
This project exists thanks to all the people who contribute.
<a href="https://github.com/Prowlarr/Prowlarr/graphs/contributors"><img src="https://opencollective.com/Prowlarr/contributors.svg?width=890&button=false" /></a>
## Backers

View File

@@ -7,13 +7,13 @@ variables:
outputFolder: './_output'
artifactsFolder: './_artifacts'
testsFolder: './_tests'
majorVersion: '0.1.0'
majorVersion: '0.1.1'
minorVersion: $[counter('minorVersion', 1)]
prowlarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '5.0.302'
dotnetVersion: '5.0.400'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
trigger:

View File

@@ -11,6 +11,7 @@ import ApplicationSettingsConnector from 'Settings/Applications/ApplicationSetti
import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
import Settings from 'Settings/Settings';
import TagSettings from 'Settings/Tags/TagSettings';
@@ -90,6 +91,11 @@ function AppRoutes(props) {
component={Settings}
/>
<Route
path="/settings/indexers"
component={IndexerSettings}
/>
<Route
path="/settings/applications"
component={ApplicationSettingsConnector}

View File

@@ -11,8 +11,6 @@ class InfoInput extends Component {
value
} = this.props;
console.log(this.props);
return (
<span dangerouslySetInnerHTML={{ __html: value }} />
);

View File

@@ -48,6 +48,10 @@ const links = [
title: translate('Settings'),
to: '/settings',
children: [
{
title: translate('Indexers'),
to: '/settings/indexers'
},
{
title: translate('Apps'),
to: '/settings/applications'

View File

@@ -228,4 +228,4 @@ export const UNSAVED_SETTING = farDotCircle;
export const VIEW = fasEye;
export const WARNING = fasExclamationTriangle;
export const WIKI = fasBookReader;
export const BLACKLIST = fasBan;
export const BLOCKLIST = fasBan;

View File

@@ -45,6 +45,7 @@ function EditIndexerModalContent(props) {
supportsRss,
supportsRedirect,
appProfileId,
tags,
fields,
priority
} = item;
@@ -89,7 +90,6 @@ function EditIndexerModalContent(props) {
type={inputTypes.CHECK}
name="enable"
helpTextWarning={supportsRss.value ? undefined : translate('RSSIsNotSupportedWithThisIndexer')}
isDisabled={!supportsRss.value}
{...enable}
onChange={onInputChange}
/>
@@ -152,6 +152,18 @@ function EditIndexerModalContent(props) {
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText="Use tags to specify default clients, specify Indexer Proxies, or just to organize your indexers."
{...tags}
onChange={onInputChange}
/>
</FormGroup>
</Form>
}
</ModalBody>

View File

@@ -59,7 +59,6 @@ class EditIndexerModalContentConnector extends Component {
}
onAdvancedSettingsPress = () => {
console.log('settings');
this.props.toggleAdvancedSettings();
}

View File

@@ -31,7 +31,7 @@ function IndexerStatusCell(props) {
<Icon
className={styles.statusIcon}
kind={enabled ? enableKind : kinds.DEFAULT}
name={enabled ? enableIcon: icons.BLACKLIST}
name={enabled ? enableIcon: icons.BLOCKLIST}
title={enabled ? enableTitle : 'Indexer is Disabled'}
/>
}

View File

@@ -27,7 +27,7 @@ class SearchFooter extends Component {
this.state = {
searchingReleases: false,
searchQuery: defaultSearchQuery,
searchQuery: defaultSearchQuery || '',
searchIndexerIds: defaultIndexerIds,
searchCategories: defaultCategories
};
@@ -52,14 +52,12 @@ class SearchFooter extends Component {
isFetching,
defaultIndexerIds,
defaultCategories,
defaultSearchQuery,
searchError
} = this.props;
const {
searchIndexerIds,
searchCategories,
searchQuery
searchCategories
} = this.state;
const newState = {};
@@ -72,10 +70,6 @@ class SearchFooter extends Component {
newState.searchCategories = defaultCategories;
}
if (searchQuery !== defaultSearchQuery) {
newState.searchQuery = defaultSearchQuery;
}
if (prevProps.isFetching && !isFetching && !searchError) {
newState.searchingReleases = false;
}
@@ -92,6 +86,10 @@ class SearchFooter extends Component {
this.props.onSearchPress(this.state.searchQuery, this.state.searchIndexerIds, this.state.searchCategories);
}
onSearchInputChange = ({ value }) => {
this.setState({ searchQuery: value });
}
//
// Render
@@ -121,7 +119,7 @@ class SearchFooter extends Component {
autoFocus={true}
value={searchQuery}
isDisabled={isFetching}
onChange={onInputChange}
onChange={this.onSearchInputChange}
/>
</div>

View File

@@ -0,0 +1,44 @@
.indexerProxy {
composes: card from '~Components/Card.css';
position: relative;
width: 300px;
height: 100px;
}
.underlay {
@add-mixin cover;
}
.overlay {
@add-mixin linkOverlay;
padding: 10px;
}
.name {
text-align: center;
font-weight: lighter;
font-size: 24px;
}
.actions {
margin-top: 20px;
text-align: right;
}
.presetsMenu {
composes: menu from '~Components/Menu/Menu.css';
display: inline-block;
margin: 0 5px;
}
.presetsMenuButton {
composes: button from '~Components/Link/Button.css';
&::after {
margin-left: 5px;
content: '\25BE';
}
}

View File

@@ -0,0 +1,111 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddIndexerProxyPresetMenuItem from './AddIndexerProxyPresetMenuItem';
import styles from './AddIndexerProxyItem.css';
class AddIndexerProxyItem extends Component {
//
// Listeners
onIndexerProxySelect = () => {
const {
implementation
} = this.props;
this.props.onIndexerProxySelect({ implementation });
}
//
// Render
render() {
const {
implementation,
implementationName,
infoLink,
presets,
onIndexerProxySelect
} = this.props;
const hasPresets = !!presets && !!presets.length;
return (
<div
className={styles.indexerProxy}
>
<Link
className={styles.underlay}
onPress={this.onIndexerProxySelect}
/>
<div className={styles.overlay}>
<div className={styles.name}>
{implementationName}
</div>
<div className={styles.actions}>
{
hasPresets &&
<span>
<Button
size={sizes.SMALL}
onPress={this.onIndexerProxySelect}
>
Custom
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
Presets
</Button>
<MenuContent>
{
presets.map((preset) => {
return (
<AddIndexerProxyPresetMenuItem
key={preset.name}
name={preset.name}
implementation={implementation}
onPress={onIndexerProxySelect}
/>
);
})
}
</MenuContent>
</Menu>
</span>
}
<Button
to={infoLink}
size={sizes.SMALL}
>
{translate('MoreInfo')}
</Button>
</div>
</div>
</div>
);
}
}
AddIndexerProxyItem.propTypes = {
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
infoLink: PropTypes.string.isRequired,
presets: PropTypes.arrayOf(PropTypes.object),
onIndexerProxySelect: PropTypes.func.isRequired
};
export default AddIndexerProxyItem;

View File

@@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddIndexerProxyModalContentConnector from './AddIndexerProxyModalContentConnector';
function AddIndexerProxyModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddIndexerProxyModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddIndexerProxyModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddIndexerProxyModal;

View File

@@ -0,0 +1,5 @@
.indexerProxies {
display: flex;
justify-content: center;
flex-wrap: wrap;
}

View File

@@ -0,0 +1,88 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 translate from 'Utilities/String/translate';
import AddIndexerProxyItem from './AddIndexerProxyItem';
import styles from './AddIndexerProxyModalContent.css';
class AddIndexerProxyModalContent extends Component {
//
// Render
render() {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema,
onIndexerProxySelect,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AddIndexerProxy')}
</ModalHeader>
<ModalBody>
{
isSchemaFetching &&
<LoadingIndicator />
}
{
!isSchemaFetching && !!schemaError &&
<div>
{translate('UnableToAddANewIndexerProxyPleaseTryAgain')}
</div>
}
{
isSchemaPopulated && !schemaError &&
<div>
<div className={styles.indexerProxies}>
{
schema.map((indexerProxy) => {
return (
<AddIndexerProxyItem
key={indexerProxy.implementation}
implementation={indexerProxy.implementation}
{...indexerProxy}
onIndexerProxySelect={onIndexerProxySelect}
/>
);
})
}
</div>
</div>
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
AddIndexerProxyModalContent.propTypes = {
isSchemaFetching: PropTypes.bool.isRequired,
isSchemaPopulated: PropTypes.bool.isRequired,
schemaError: PropTypes.object,
schema: PropTypes.arrayOf(PropTypes.object).isRequired,
onIndexerProxySelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddIndexerProxyModalContent;

View File

@@ -0,0 +1,70 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchIndexerProxySchema, selectIndexerProxySchema } from 'Store/Actions/settingsActions';
import AddIndexerProxyModalContent from './AddIndexerProxyModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.indexerProxies,
(indexerProxies) => {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
} = indexerProxies;
return {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
};
}
);
}
const mapDispatchToProps = {
fetchIndexerProxySchema,
selectIndexerProxySchema
};
class AddIndexerProxyModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchIndexerProxySchema();
}
//
// Listeners
onIndexerProxySelect = ({ implementation, name }) => {
this.props.selectIndexerProxySchema({ implementation, presetName: name });
this.props.onModalClose({ indexerProxySelected: true });
}
//
// Render
render() {
return (
<AddIndexerProxyModalContent
{...this.props}
onIndexerProxySelect={this.onIndexerProxySelect}
/>
);
}
}
AddIndexerProxyModalContentConnector.propTypes = {
fetchIndexerProxySchema: PropTypes.func.isRequired,
selectIndexerProxySchema: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerProxyModalContentConnector);

View File

@@ -0,0 +1,49 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MenuItem from 'Components/Menu/MenuItem';
class AddIndexerProxyPresetMenuItem extends Component {
//
// Listeners
onPress = () => {
const {
name,
implementation
} = this.props;
this.props.onPress({
name,
implementation
});
}
//
// Render
render() {
const {
name,
implementation,
...otherProps
} = this.props;
return (
<MenuItem
{...otherProps}
onPress={this.onPress}
>
{name}
</MenuItem>
);
}
}
AddIndexerProxyPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
export default AddIndexerProxyPresetMenuItem;

View File

@@ -0,0 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EditIndexerProxyModalContentConnector from './EditIndexerProxyModalContentConnector';
function EditIndexerProxyModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditIndexerProxyModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditIndexerProxyModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditIndexerProxyModal;

View File

@@ -0,0 +1,65 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { cancelSaveIndexerProxy, cancelTestIndexerProxy } from 'Store/Actions/settingsActions';
import EditIndexerProxyModal from './EditIndexerProxyModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.indexerProxies';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
},
dispatchCancelTestIndexerProxy() {
dispatch(cancelTestIndexerProxy({ section }));
},
dispatchCancelSaveIndexerProxy() {
dispatch(cancelSaveIndexerProxy({ section }));
}
};
}
class EditIndexerProxyModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges();
this.props.dispatchCancelTestIndexerProxy();
this.props.dispatchCancelSaveIndexerProxy();
this.props.onModalClose();
}
//
// Render
render() {
const {
dispatchClearPendingChanges,
dispatchCancelTestIndexerProxy,
dispatchCancelSaveIndexerProxy,
...otherProps
} = this.props;
return (
<EditIndexerProxyModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
EditIndexerProxyModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchCancelTestIndexerProxy: PropTypes.func.isRequired,
dispatchCancelSaveIndexerProxy: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(EditIndexerProxyModalConnector);

View File

@@ -0,0 +1,11 @@
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}
.message {
composes: alert from '~Components/Alert.css';
margin-bottom: 30px;
}

View File

@@ -0,0 +1,175 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './EditIndexerProxyModalContent.css';
function EditIndexerProxyModalContent(props) {
const {
advancedSettings,
isFetching,
error,
isSaving,
isTesting,
saveError,
item,
onInputChange,
onFieldChange,
onModalClose,
onSavePress,
onTestPress,
onDeleteIndexerProxyPress,
...otherProps
} = props;
const {
id,
implementationName,
name,
tags,
fields,
message
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{`${id ? 'Edit' : 'Add'} Proxy - ${implementationName}`}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
{translate('UnableToAddANewIndexerProxyPleaseTryAgain')}
</div>
}
{
!isFetching && !error &&
<Form {...otherProps}>
{
!!message &&
<Alert
className={styles.message}
kind={message.value.type}
>
{message.value.message}
</Alert>
}
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>
</FormGroup>
{
fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="indexerProxy"
providerData={item}
section="settings.indexerProxies"
{...field}
onChange={onFieldChange}
/>
);
})
}
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteIndexerProxyPress}
>
{translate('Delete')}
</Button>
}
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
onPress={onTestPress}
>
{translate('Test')}
</SpinnerErrorButton>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditIndexerProxyModalContent.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
isTesting: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onDeleteIndexerProxyPress: PropTypes.func
};
export default EditIndexerProxyModalContent;

View File

@@ -0,0 +1,88 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveIndexerProxy, setIndexerProxyFieldValue, setIndexerProxyValue, testIndexerProxy } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditIndexerProxyModalContent from './EditIndexerProxyModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('indexerProxies'),
(advancedSettings, indexerProxy) => {
return {
advancedSettings,
...indexerProxy
};
}
);
}
const mapDispatchToProps = {
setIndexerProxyValue,
setIndexerProxyFieldValue,
saveIndexerProxy,
testIndexerProxy
};
class EditIndexerProxyModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setIndexerProxyValue({ name, value });
}
onFieldChange = ({ name, value }) => {
this.props.setIndexerProxyFieldValue({ name, value });
}
onSavePress = () => {
this.props.saveIndexerProxy({ id: this.props.id });
}
onTestPress = () => {
this.props.testIndexerProxy({ id: this.props.id });
}
//
// Render
render() {
return (
<EditIndexerProxyModalContent
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditIndexerProxyModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setIndexerProxyValue: PropTypes.func.isRequired,
setIndexerProxyFieldValue: PropTypes.func.isRequired,
saveIndexerProxy: PropTypes.func.isRequired,
testIndexerProxy: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerProxyModalContentConnector);

View File

@@ -0,0 +1,20 @@
.indexerProxies {
display: flex;
flex-wrap: wrap;
}
.addIndexerProxy {
composes: indexerProxy from '~./IndexerProxy.css';
background-color: $cardAlternateBackgroundColor;
color: $gray;
text-align: center;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
}

View File

@@ -0,0 +1,121 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddIndexerProxyModal from './AddIndexerProxyModal';
import EditIndexerProxyModalConnector from './EditIndexerProxyModalConnector';
import IndexerProxy from './IndexerProxy';
import styles from './IndexerProxies.css';
class IndexerProxies extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddIndexerProxyModalOpen: false,
isEditIndexerProxyModalOpen: false
};
}
//
// Listeners
onAddIndexerProxyPress = () => {
this.setState({ isAddIndexerProxyModalOpen: true });
}
onAddIndexerProxyModalClose = ({ indexerProxySelected = false } = {}) => {
this.setState({
isAddIndexerProxyModalOpen: false,
isEditIndexerProxyModalOpen: indexerProxySelected
});
}
onEditIndexerProxyModalClose = () => {
this.setState({ isEditIndexerProxyModalOpen: false });
}
//
// Render
render() {
const {
items,
tagList,
indexerList,
onConfirmDeleteIndexerProxy,
...otherProps
} = this.props;
const {
isAddIndexerProxyModalOpen,
isEditIndexerProxyModalOpen
} = this.state;
return (
<FieldSet legend={translate('Indexer Proxies')}>
<PageSectionContent
errorMessage={translate('UnableToLoadIndexerProxies')}
{...otherProps}
>
<div className={styles.indexerProxies}>
{
items.map((item) => {
return (
<IndexerProxy
key={item.id}
{...item}
tagList={tagList}
indexerList={indexerList}
onConfirmDeleteIndexerProxy={onConfirmDeleteIndexerProxy}
/>
);
})
}
<Card
className={styles.addIndexerProxy}
onPress={this.onAddIndexerProxyPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<AddIndexerProxyModal
isOpen={isAddIndexerProxyModalOpen}
onModalClose={this.onAddIndexerProxyModalClose}
/>
<EditIndexerProxyModalConnector
isOpen={isEditIndexerProxyModalOpen}
onModalClose={this.onEditIndexerProxyModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
IndexerProxies.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteIndexerProxy: PropTypes.func.isRequired
};
export default IndexerProxies;

View File

@@ -0,0 +1,65 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteIndexerProxy, fetchIndexerProxies } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByName from 'Utilities/Array/sortByName';
import IndexerProxies from './IndexerProxies';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.indexerProxies', sortByName),
createSortedSectionSelector('indexers', sortByName),
createTagsSelector(),
(indexerProxies, indexers, tagList) => {
return {
...indexerProxies,
indexerList: indexers.items,
tagList
};
}
);
}
const mapDispatchToProps = {
fetchIndexerProxies,
deleteIndexerProxy
};
class IndexerProxiesConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchIndexerProxies();
}
//
// Listeners
onConfirmDeleteIndexerProxy = (id) => {
this.props.deleteIndexerProxy({ id });
}
//
// Render
render() {
return (
<IndexerProxies
{...this.props}
onConfirmDeleteIndexerProxy={this.onConfirmDeleteIndexerProxy}
/>
);
}
}
IndexerProxiesConnector.propTypes = {
fetchIndexerProxies: PropTypes.func.isRequired,
deleteIndexerProxy: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(IndexerProxiesConnector);

View File

@@ -0,0 +1,23 @@
.indexerProxy {
composes: card from '~Components/Card.css';
width: 290px;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.indexers {
flex: 1 0 auto;
}
.enabled {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
}

View File

@@ -0,0 +1,144 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditIndexerProxyModalConnector from './EditIndexerProxyModalConnector';
import styles from './IndexerProxy.css';
class IndexerProxy extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditIndexerProxyModalOpen: false,
isDeleteIndexerProxyModalOpen: false
};
}
//
// Listeners
onEditIndexerProxyPress = () => {
this.setState({ isEditIndexerProxyModalOpen: true });
}
onEditIndexerProxyModalClose = () => {
this.setState({ isEditIndexerProxyModalOpen: false });
}
onDeleteIndexerProxyPress = () => {
this.setState({
isEditIndexerProxyModalOpen: false,
isDeleteIndexerProxyModalOpen: true
});
}
onDeleteIndexerProxyModalClose= () => {
this.setState({ isDeleteIndexerProxyModalOpen: false });
}
onConfirmDeleteIndexerProxy = () => {
this.props.onConfirmDeleteIndexerProxy(this.props.id);
}
//
// Render
render() {
const {
id,
name,
tags,
tagList,
indexerList
} = this.props;
return (
<Card
className={styles.indexerProxy}
overlayContent={true}
onPress={this.onEditIndexerProxyPress}
>
<div className={styles.name}>
{name}
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<div className={styles.indexers}>
{
tags.map((t) => {
const indexers = _.filter(indexerList, { tags: [t] });
if (!indexers || indexers.length === 0) {
return null;
}
return indexers.map((i) => {
return (
<Label
key={i.name}
kind={kinds.SUCCESS}
>
{i.name}
</Label>
);
});
})
}
</div>
{
!tags || tags.length === 0 ?
<Label
kind={kinds.DISABLED}
outline={true}
>
Disabled
</Label> :
null
}
<EditIndexerProxyModalConnector
id={id}
isOpen={this.state.isEditIndexerProxyModalOpen}
onModalClose={this.onEditIndexerProxyModalClose}
onDeleteIndexerProxyPress={this.onDeleteIndexerProxyPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteIndexerProxyModalOpen}
kind={kinds.DANGER}
title={translate('DeleteIndexerProxy')}
message={translate('DeleteIndexerProxyMessageText', [name])}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteIndexerProxy}
onCancel={this.onDeleteIndexerProxyModalClose}
/>
</Card>
);
}
}
IndexerProxy.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteIndexerProxy: PropTypes.func.isRequired
};
export default IndexerProxy;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import IndexerProxiesConnector from './IndexerProxies/IndexerProxiesConnector';
function IndexerSettings() {
return (
<PageContent title={translate('Proxies')}>
<SettingsToolbarConnector
showSave={false}
/>
<PageContentBody>
<IndexerProxiesConnector />
</PageContentBody>
</PageContent>
);
}
export default IndexerSettings;

View File

@@ -16,13 +16,24 @@ function Settings() {
<PageContentBody>
<Link
className={styles.link}
to="/settings/applications"
to="/settings/indexers"
>
Applications
{translate('Indexers')}
</Link>
<div className={styles.summary}>
Applications and settings to configure how prowlarr interacts with your PVR programs
{translate('IndexerSettingsSummary')}
</div>
<Link
className={styles.link}
to="/settings/applications"
>
{translate('Apps')}
</Link>
<div className={styles.summary}>
{translate('AppSettingsSummary')}
</div>
<Link
@@ -40,7 +51,7 @@ function Settings() {
className={styles.link}
to="/settings/connect"
>
Notifications
{translate('Notifications')}
</Link>
<div className={styles.summary}>

View File

@@ -1,47 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import titleCase from 'Utilities/String/titleCase';
function TagDetailsDelayProfile(props) {
const {
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay
} = props;
return (
<div>
<div>
Protocol: {titleCase(preferredProtocol)}
</div>
<div>
{
enableUsenet ?
`Usenet Delay: ${usenetDelay}` :
'Usenet disabled'
}
</div>
<div>
{
enableTorrent ?
`Torrent Delay: ${torrentDelay}` :
'Torrents disabled'
}
</div>
</div>
);
}
TagDetailsDelayProfile.propTypes = {
preferredProtocol: PropTypes.string.isRequired,
enableUsenet: PropTypes.bool.isRequired,
enableTorrent: PropTypes.bool.isRequired,
usenetDelay: PropTypes.number.isRequired,
torrentDelay: PropTypes.number.isRequired
};
export default TagDetailsDelayProfile;

View File

@@ -1,26 +1,22 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
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 { kinds } from 'Helpers/Props';
import split from 'Utilities/String/split';
import translate from 'Utilities/String/translate';
import TagDetailsDelayProfile from './TagDetailsDelayProfile';
import styles from './TagDetailsModalContent.css';
function TagDetailsModalContent(props) {
const {
label,
isTagUsed,
movies,
delayProfiles,
indexers,
notifications,
restrictions,
indexerProxies,
onModalClose,
onDeleteTagPress
} = props;
@@ -40,13 +36,13 @@ function TagDetailsModalContent(props) {
}
{
!!movies.length &&
<FieldSet legend={translate('Movies')}>
!!indexers.length &&
<FieldSet legend={translate('Indexers')}>
{
movies.map((item) => {
indexers.map((item) => {
return (
<div key={item.id}>
{item.title}
{item.name}
</div>
);
})
@@ -54,35 +50,6 @@ function TagDetailsModalContent(props) {
</FieldSet>
}
{
!!delayProfiles.length &&
<FieldSet legend={translate('DelayProfile')}>
{
delayProfiles.map((item) => {
const {
id,
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay
} = item;
return (
<TagDetailsDelayProfile
key={id}
preferredProtocol={preferredProtocol}
enableUsenet={enableUsenet}
enableTorrent={enableTorrent}
usenetDelay={usenetDelay}
torrentDelay={torrentDelay}
/>
);
})
}
</FieldSet>
}
{
!!notifications.length &&
<FieldSet legend={translate('Connections')}>
@@ -99,44 +66,13 @@ function TagDetailsModalContent(props) {
}
{
!!restrictions.length &&
<FieldSet legend={translate('Restrictions')}>
!!indexerProxies.length &&
<FieldSet legend={translate('Indexer Proxies')}>
{
restrictions.map((item) => {
indexerProxies.map((item) => {
return (
<div
key={item.id}
className={styles.restriction}
>
<div>
{
split(item.required).map((r) => {
return (
<Label
key={r}
kind={kinds.SUCCESS}
>
{r}
</Label>
);
})
}
</div>
<div>
{
split(item.ignored).map((i) => {
return (
<Label
key={i}
kind={kinds.DANGER}
>
{i}
</Label>
);
})
}
</div>
<div key={item.id}>
{item.name}
</div>
);
})
@@ -171,10 +107,9 @@ function TagDetailsModalContent(props) {
TagDetailsModalContent.propTypes = {
label: PropTypes.string.isRequired,
isTagUsed: PropTypes.bool.isRequired,
movies: PropTypes.arrayOf(PropTypes.object).isRequired,
delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
restrictions: PropTypes.arrayOf(PropTypes.object).isRequired,
indexerProxies: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteTagPress: PropTypes.func.isRequired
};

View File

@@ -1,6 +1,5 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
import TagDetailsModalContent from './TagDetailsModalContent';
function findMatchingItems(ids, items) {
@@ -9,35 +8,15 @@ function findMatchingItems(ids, items) {
});
}
function createUnorderedMatchingMoviesSelector() {
function createMatchingIndexersSelector() {
return createSelector(
(state, { indexerIds }) => indexerIds,
createAllIndexersSelector(),
(state) => state.indexers.items,
findMatchingItems
);
}
function createMatchingMoviesSelector() {
return createSelector(
createUnorderedMatchingMoviesSelector(),
(movies) => {
return movies.sort((movieA, movieB) => {
const sortTitleA = movieA.sortTitle;
const sortTitleB = movieB.sortTitle;
if (sortTitleA > sortTitleB) {
return 1;
} else if (sortTitleA < sortTitleB) {
return -1;
}
return 0;
});
}
);
}
function createMatchingNotificationsSelector() {
function createMatchingIndexerProxiesSelector() {
return createSelector(
(state, { notificationIds }) => notificationIds,
(state) => state.settings.notifications.items,
@@ -45,13 +24,23 @@ function createMatchingNotificationsSelector() {
);
}
function createMatchingNotificationsSelector() {
return createSelector(
(state, { indexerProxyIds }) => indexerProxyIds,
(state) => state.settings.indexerProxies.items,
findMatchingItems
);
}
function createMapStateToProps() {
return createSelector(
createMatchingMoviesSelector(),
createMatchingIndexersSelector(),
createMatchingIndexerProxiesSelector(),
createMatchingNotificationsSelector(),
(movies, notifications) => {
(indexers, indexerProxies, notifications) => {
return {
movies,
indexers,
indexerProxies,
notifications
};
}

View File

@@ -53,11 +53,9 @@ class Tag extends Component {
render() {
const {
label,
delayProfileIds,
notificationIds,
restrictionIds,
importListIds,
movieIds
indexerIds,
indexerProxyIds
} = this.props;
const {
@@ -66,11 +64,9 @@ class Tag extends Component {
} = this.state;
const isTagUsed = !!(
delayProfileIds.length ||
indexerIds.length ||
notificationIds.length ||
restrictionIds.length ||
importListIds.length ||
movieIds.length
indexerProxyIds.length
);
return (
@@ -87,16 +83,9 @@ class Tag extends Component {
isTagUsed &&
<div>
{
!!movieIds.length &&
!!indexerIds.length &&
<div>
{movieIds.length} movies
</div>
}
{
!!delayProfileIds.length &&
<div>
{delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
{indexerIds.length} indexer{indexerIds.length > 1 && 's'}
</div>
}
@@ -108,16 +97,9 @@ class Tag extends Component {
}
{
!!restrictionIds.length &&
!!indexerProxyIds.length &&
<div>
{restrictionIds.length} restriction{restrictionIds.length > 1 && 's'}
</div>
}
{
!!importListIds.length &&
<div>
{importListIds.length} list{importListIds.length > 1 && 's'}
{indexerProxyIds.length} indexerProxy{indexerProxyIds.length > 1 && 's'}
</div>
}
</div>
@@ -133,11 +115,9 @@ class Tag extends Component {
<TagDetailsModal
label={label}
isTagUsed={isTagUsed}
movieIds={movieIds}
delayProfileIds={delayProfileIds}
indexerIds={indexerIds}
notificationIds={notificationIds}
restrictionIds={restrictionIds}
importListIds={importListIds}
indexerProxyIds={indexerProxyIds}
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
onDeleteTagPress={this.onDeleteTagPress}
@@ -160,20 +140,16 @@ class Tag extends Component {
Tag.propTypes = {
id: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired,
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
importListIds: PropTypes.arrayOf(PropTypes.number).isRequired,
movieIds: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerProxyIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onConfirmDeleteTag: PropTypes.func.isRequired
};
Tag.defaultProps = {
delayProfileIds: [],
indexerIds: [],
notificationIds: [],
restrictionIds: [],
importListIds: [],
movieIds: []
indexerProxyIds: []
};
export default Tag;

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchNotifications } from 'Store/Actions/settingsActions';
import { fetchIndexerProxies, fetchNotifications } from 'Store/Actions/settingsActions';
import { fetchTagDetails } from 'Store/Actions/tagActions';
import Tags from './Tags';
@@ -26,7 +26,8 @@ function createMapStateToProps() {
const mapDispatchToProps = {
dispatchFetchTagDetails: fetchTagDetails,
dispatchFetchNotifications: fetchNotifications
dispatchFetchNotifications: fetchNotifications,
dispatchFetchIndexerProxies: fetchIndexerProxies
};
class MetadatasConnector extends Component {
@@ -37,11 +38,13 @@ class MetadatasConnector extends Component {
componentDidMount() {
const {
dispatchFetchTagDetails,
dispatchFetchNotifications
dispatchFetchNotifications,
dispatchFetchIndexerProxies
} = this.props;
dispatchFetchTagDetails();
dispatchFetchNotifications();
dispatchFetchIndexerProxies();
}
//
@@ -58,7 +61,8 @@ class MetadatasConnector extends Component {
MetadatasConnector.propTypes = {
dispatchFetchTagDetails: PropTypes.func.isRequired,
dispatchFetchNotifications: PropTypes.func.isRequired
dispatchFetchNotifications: PropTypes.func.isRequired,
dispatchFetchIndexerProxies: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);

View File

@@ -10,7 +10,7 @@ import {
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
const blacklistedProperties = [
const omittedProperties = [
'section',
'id'
];
@@ -31,7 +31,7 @@ export default function createHandleActions(handlers, defaultState, section) {
if (section === baseSection) {
const newState = Object.assign(getSectionState(state, payloadSection),
_.omit(payload, blacklistedProperties));
_.omit(payload, omittedProperties));
return updateSectionState(state, payloadSection, newState);
}

View File

@@ -0,0 +1,110 @@
import { createAction } from 'redux-actions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
//
// Variables
const section = 'settings.indexerProxies';
//
// Actions Types
export const FETCH_INDEXER_PROXYS = 'settings/indexerProxies/fetchIndexerProxies';
export const FETCH_INDEXER_PROXY_SCHEMA = 'settings/indexerProxies/fetchIndexerProxySchema';
export const SELECT_INDEXER_PROXY_SCHEMA = 'settings/indexerProxies/selectIndexerProxySchema';
export const SET_INDEXER_PROXY_VALUE = 'settings/indexerProxies/setIndexerProxyValue';
export const SET_INDEXER_PROXY_FIELD_VALUE = 'settings/indexerProxies/setIndexerProxyFieldValue';
export const SAVE_INDEXER_PROXY = 'settings/indexerProxies/saveIndexerProxy';
export const CANCEL_SAVE_INDEXER_PROXY = 'settings/indexerProxies/cancelSaveIndexerProxy';
export const DELETE_INDEXER_PROXY = 'settings/indexerProxies/deleteIndexerProxy';
export const TEST_INDEXER_PROXY = 'settings/indexerProxies/testIndexerProxy';
export const CANCEL_TEST_INDEXER_PROXY = 'settings/indexerProxies/cancelTestIndexerProxy';
//
// Action Creators
export const fetchIndexerProxies = createThunk(FETCH_INDEXER_PROXYS);
export const fetchIndexerProxySchema = createThunk(FETCH_INDEXER_PROXY_SCHEMA);
export const selectIndexerProxySchema = createAction(SELECT_INDEXER_PROXY_SCHEMA);
export const saveIndexerProxy = createThunk(SAVE_INDEXER_PROXY);
export const cancelSaveIndexerProxy = createThunk(CANCEL_SAVE_INDEXER_PROXY);
export const deleteIndexerProxy = createThunk(DELETE_INDEXER_PROXY);
export const testIndexerProxy = createThunk(TEST_INDEXER_PROXY);
export const cancelTestIndexerProxy = createThunk(CANCEL_TEST_INDEXER_PROXY);
export const setIndexerProxyValue = createAction(SET_INDEXER_PROXY_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setIndexerProxyFieldValue = createAction(SET_INDEXER_PROXY_FIELD_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: [],
selectedSchema: {},
isSaving: false,
saveError: null,
isTesting: false,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_INDEXER_PROXYS]: createFetchHandler(section, '/indexerProxy'),
[FETCH_INDEXER_PROXY_SCHEMA]: createFetchSchemaHandler(section, '/indexerProxy/schema'),
[SAVE_INDEXER_PROXY]: createSaveProviderHandler(section, '/indexerProxy'),
[CANCEL_SAVE_INDEXER_PROXY]: createCancelSaveProviderHandler(section),
[DELETE_INDEXER_PROXY]: createRemoveItemHandler(section, '/indexerProxy'),
[TEST_INDEXER_PROXY]: createTestProviderHandler(section, '/indexerProxy'),
[CANCEL_TEST_INDEXER_PROXY]: createCancelTestProviderHandler(section)
},
//
// Reducers
reducers: {
[SET_INDEXER_PROXY_VALUE]: createSetSettingValueReducer(section),
[SET_INDEXER_PROXY_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SELECT_INDEXER_PROXY_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
return selectedSchema;
});
}
}
};

View File

@@ -7,6 +7,7 @@ import development from './Settings/development';
import downloadClients from './Settings/downloadClients';
import general from './Settings/general';
import indexerCategories from './Settings/indexerCategories';
import indexerProxies from './Settings/indexerProxies';
import languages from './Settings/languages';
import notifications from './Settings/notifications';
import ui from './Settings/ui';
@@ -14,6 +15,7 @@ import ui from './Settings/ui';
export * from './Settings/downloadClients';
export * from './Settings/general';
export * from './Settings/indexerCategories';
export * from './Settings/indexerProxies';
export * from './Settings/languages';
export * from './Settings/notifications';
export * from './Settings/applications';
@@ -35,6 +37,7 @@ export const defaultState = {
downloadClients: downloadClients.defaultState,
general: general.defaultState,
indexerCategories: indexerCategories.defaultState,
indexerProxies: indexerProxies.defaultState,
languages: languages.defaultState,
notifications: notifications.defaultState,
applications: applications.defaultState,
@@ -64,6 +67,7 @@ export const actionHandlers = handleThunks({
...downloadClients.actionHandlers,
...general.actionHandlers,
...indexerCategories.actionHandlers,
...indexerProxies.actionHandlers,
...languages.actionHandlers,
...notifications.actionHandlers,
...applications.actionHandlers,
@@ -84,6 +88,7 @@ export const reducers = createHandleActions({
...downloadClients.reducers,
...general.reducers,
...indexerCategories.reducers,
...indexerProxies.reducers,
...languages.reducers,
...notifications.reducers,
...applications.reducers,

View File

@@ -7,18 +7,6 @@ function isRelative(ajaxOptions) {
return !absUrlRegex.test(ajaxOptions.url);
}
function moveBodyToQuery(ajaxOptions) {
if (ajaxOptions.data && ajaxOptions.type === 'DELETE') {
if (ajaxOptions.url.contains('?')) {
ajaxOptions.url += '&';
} else {
ajaxOptions.url += '?';
}
ajaxOptions.url += $.param(ajaxOptions.data);
delete ajaxOptions.data;
}
}
function addRootUrl(ajaxOptions) {
ajaxOptions.url = apiRoot + ajaxOptions.url;
}
@@ -32,7 +20,7 @@ function addContentType(ajaxOptions) {
if (
ajaxOptions.contentType == null &&
ajaxOptions.dataType === 'json' &&
(ajaxOptions.method === 'PUT' || ajaxOptions.method === 'POST')) {
(ajaxOptions.method === 'PUT' || ajaxOptions.method === 'POST' || ajaxOptions.method === 'DELETE')) {
ajaxOptions.contentType = 'application/json';
}
}
@@ -52,7 +40,6 @@ export default function createAjaxRequest(originalAjaxOptions) {
const ajaxOptions = { dataType: 'json', ...originalAjaxOptions };
if (isRelative(ajaxOptions)) {
moveBodyToQuery(ajaxOptions);
addRootUrl(ajaxOptions);
addApiKey(ajaxOptions);
addContentType(ajaxOptions);

View File

@@ -30,7 +30,7 @@
"@fortawesome/free-regular-svg-icons": "5.15.3",
"@fortawesome/free-solid-svg-icons": "5.15.3",
"@fortawesome/react-fontawesome": "0.1.14",
"@microsoft/signalr": "5.0.8",
"@microsoft/signalr": "5.0.9",
"@sentry/browser": "6.10.0",
"@sentry/integrations": "6.10.0",
"chart.js": "3.2.0",

View File

@@ -94,7 +94,7 @@
<!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NunitXml.TestLogger" Version="3.0.97" />

View File

@@ -0,0 +1,24 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Test.Common;
namespace NzbDrone.Common.Test.Http
{
[TestFixture]
public class UserAgentParserFixture : TestBase
{
// Ref *Arr `_userAgent = $"{BuildInfo.AppName}/{BuildInfo.Version} ({osName} {osVersion})";`
// Ref Mylar `Mylar3/' +str(hash) +'(' +vers +') +http://www.github.com/mylar3/mylar3/`
[TestCase("Mylar3/ 3ee23rh23irqfq (13123123) http://www.github.com/mylar3/mylar3/", "Mylar3")]
[TestCase("Lidarr/1.0.0.2300 (ubuntu 20.04)", "Lidarr")]
[TestCase("Radarr/1.0.0.2300 (ubuntu 20.04)", "Radarr")]
[TestCase("Readarr/1.0.0.2300 (ubuntu 20.04)", "Readarr")]
[TestCase("Sonarr/3.0.6.9999 (ubuntu 20.04)", "Sonarr")]
[TestCase("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36", "Other")]
public void should_parse_user_agent(string userAgent, string parsedAgent)
{
UserAgentParser.ParseSource(userAgent).Should().Be(parsedAgent);
}
}
}

View File

@@ -102,5 +102,13 @@ namespace NzbDrone.Common.Test.InstrumentationTests
cleansedMessage.Should().Be(message);
}
[TestCase(@"https://www.torrentleech.org/torrents/browse/list/imdbID/tt8005374/categories/29,2,26,27,32,44,7,34,35")]
public void should_not_clean_url(string message)
{
var cleansedMessage = CleanseLogMessage.Cleanse(message);
cleansedMessage.Should().Be(message);
}
}
}

View File

@@ -59,7 +59,7 @@ namespace NzbDrone.Common.Http.Dispatchers
webRequest.Timeout = (int)Math.Ceiling(request.RequestTimeout.TotalMilliseconds);
}
webRequest.Proxy = GetProxy(request.Url);
webRequest.Proxy = request.Proxy ?? GetProxy(request.Url);
if (request.Headers != null)
{

View File

@@ -61,7 +61,7 @@ namespace NzbDrone.Common.Http
_cookieContainerCache = cacheManager.GetCache<CookieContainer>(typeof(HttpClient));
}
public async Task<HttpResponse> ExecuteAsync(HttpRequest request)
public virtual async Task<HttpResponse> ExecuteAsync(HttpRequest request)
{
var cookieContainer = InitializeRequestCookies(request);

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
@@ -31,6 +32,7 @@ namespace NzbDrone.Common.Http
public HttpMethod Method { get; set; }
public HttpHeader Headers { get; set; }
public Encoding Encoding { get; set; }
public IWebProxy Proxy { get; set; }
public byte[] ContentData { get; set; }
public string ContentSummary { get; set; }
public bool SuppressHttpError { get; set; }
@@ -87,6 +89,19 @@ namespace NzbDrone.Common.Http
}
}
public string GetContent()
{
if (Encoding != null)
{
return Encoding.GetString(ContentData);
}
else
{
var encoding = HttpHeader.GetEncodingFromContentType(Headers.ContentType);
return encoding.GetString(ContentData);
}
}
public void AddBasicAuthentication(string username, string password)
{
var authInfo = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes($"{username}:{password}"));

View File

@@ -9,7 +9,7 @@ namespace NzbDrone.Common.Http
{
public static class UserAgentParser
{
private static readonly Regex AppSourceRegex = new Regex(@"(?<agent>.*)\/.*(\(.*\))?",
private static readonly Regex AppSourceRegex = new Regex(@"(?<agent>[a-z0-9]*)\/.*(?:\(.*\))?",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string SimplifyUserAgent(string userAgent)

View File

@@ -13,8 +13,8 @@ namespace NzbDrone.Common.Instrumentation
// Url
new Regex(@"(?<=\?|&|: |;)(apikey|token|passkey|auth|authkey|user|uid|api|[a-z_]*apikey|account|passwd|pwd)=(?<secret>[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"(?<=\?|&| )[^=]*?(_?(?<!use)token|username|passwo?rd)=(?<secret>[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"torrentleech\.org/(?!rss)(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"torrentleech\.org/rss/download/[0-9]+/(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"rss\.torrentleech\.org/(?!rss)(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"rss\.torrentleech\.org/rss/download/[0-9]+/(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"iptorrents\.com/[/a-z0-9?&;]*?(?:[?&;](u|tp)=(?<secret>[^&=;]+?))+(?= |;|&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new Regex(@"/fetch/[a-z0-9]{32}/(?<secret>[a-z0-9]{32})", RegexOptions.Compiled),
new Regex(@"getnzb.*?(?<=\?|&)(r)=(?<secret>[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNet4.SocksProxy" Version="1.4.0.1" />
<PackageReference Include="DryIoc.dll" Version="4.7.6" />
<PackageReference Include="DryIoc.dll" Version="4.8.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.2" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />

View File

@@ -2,6 +2,8 @@ using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Housekeeping.Housekeepers;
using NzbDrone.Core.IndexerProxies;
using NzbDrone.Core.IndexerProxies.Http;
using NzbDrone.Core.Tags;
using NzbDrone.Core.Test.Framework;
@@ -22,5 +24,30 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers
Subject.Clean();
AllStoredModels.Should().BeEmpty();
}
[Test]
public void should_not_delete_used_tags()
{
var tags = Builder<Tag>
.CreateListOfSize(2)
.All()
.With(x => x.Id = 0)
.BuildList();
Db.InsertMany(tags);
var settings = Builder<HttpSettings>.CreateNew().Build();
var restrictions = Builder<IndexerProxyDefinition>.CreateListOfSize(2)
.All()
.With(x => x.Id = 0)
.With(x => x.Settings = settings)
.With(v => v.Tags.Add(tags[0].Id))
.BuildList();
Db.InsertMany(restrictions);
Subject.Clean();
AllStoredModels.Should().HaveCount(1);
}
}
}

View File

@@ -33,9 +33,9 @@ namespace NzbDrone.Core.Test.IndexerTests.AvistazTests
{
var recentFeed = ReadAllText(@"Files/Indexers/PrivateHD/recentfeed.json");
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), recentFeed)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), recentFeed)));
var releases = (await Subject.Fetch(new MovieSearchCriteria { Categories = new int[] { 2000 } })).Releases;

View File

@@ -32,9 +32,9 @@ namespace NzbDrone.Core.Test.IndexerTests.FileListTests
{
var recentFeed = ReadAllText(@"Files/Indexers/FileList/recentfeed.json");
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
var releases = (await Subject.Fetch(new MovieSearchCriteria { Categories = new int[] { 2000 } })).Releases;

View File

@@ -44,9 +44,9 @@ namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests
{
var responseJson = ReadAllText(fileName);
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.POST)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), responseJson)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.POST), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), responseJson)));
var torrents = (await Subject.Fetch(_movieSearchCriteria)).Releases;
@@ -73,9 +73,9 @@ namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests
{
var responseJson = new { status = 5, message = "Invalid authentication credentials" }.ToJson();
Mocker.GetMock<IHttpClient>()
.Setup(v => v.ExecuteAsync(It.IsAny<HttpRequest>()))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), Encoding.UTF8.GetBytes(responseJson))));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.IsAny<HttpRequest>(), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), Encoding.UTF8.GetBytes(responseJson))));
var torrents = (await Subject.Fetch(_movieSearchCriteria)).Releases;

View File

@@ -1,10 +1,12 @@
using System;
using System.Net;
using System.Threading.Tasks;
using System.Xml;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.Newznab;
using NzbDrone.Core.Test.Framework;
@@ -14,6 +16,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
public class NewznabCapabilitiesProviderFixture : CoreTest<NewznabCapabilitiesProvider>
{
private NewznabSettings _settings;
private IndexerDefinition _definition;
private string _caps;
[SetUp]
@@ -24,14 +27,24 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
BaseUrl = "http://indxer.local"
};
_definition = new IndexerDefinition()
{
Id = 5,
Name = "Newznab",
Settings = new NewznabSettings()
{
BaseUrl = "http://indexer.local/"
}
};
_caps = ReadAllText("Files/Indexers/Newznab/newznab_caps.xml");
}
private void GivenCapsResponse(string caps)
{
Mocker.GetMock<IHttpClient>()
.Setup(o => o.Get(It.IsAny<HttpRequest>()))
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), new CookieCollection(), caps));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.Execute(It.IsAny<HttpRequest>(), It.IsAny<IndexerDefinition>()))
.Returns<HttpRequest, IndexerDefinition>((r, d) => new HttpResponse(r, new HttpHeader(), new CookieCollection(), caps));
}
[Test]
@@ -39,11 +52,11 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
{
GivenCapsResponse(_caps);
Subject.GetCapabilities(_settings);
Subject.GetCapabilities(_settings);
Subject.GetCapabilities(_settings, _definition);
Subject.GetCapabilities(_settings, _definition);
Mocker.GetMock<IHttpClient>()
.Verify(o => o.Get(It.IsAny<HttpRequest>()), Times.Once());
Mocker.GetMock<IIndexerHttpClient>()
.Verify(o => o.Execute(It.IsAny<HttpRequest>(), It.IsAny<IndexerDefinition>()), Times.Once());
}
[Test]
@@ -51,7 +64,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
{
GivenCapsResponse(_caps);
var caps = Subject.GetCapabilities(_settings);
var caps = Subject.GetCapabilities(_settings, _definition);
caps.LimitsDefault.Value.Should().Be(25);
caps.LimitsMax.Value.Should().Be(60);
@@ -62,7 +75,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
{
GivenCapsResponse(_caps.Replace("<limits", "<abclimits"));
var caps = Subject.GetCapabilities(_settings);
var caps = Subject.GetCapabilities(_settings, _definition);
caps.LimitsDefault.Value.Should().Be(100);
caps.LimitsMax.Value.Should().Be(100);
@@ -71,11 +84,11 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_throw_if_failed_to_get()
{
Mocker.GetMock<IHttpClient>()
.Setup(o => o.Get(It.IsAny<HttpRequest>()))
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.Execute(It.IsAny<HttpRequest>(), It.IsAny<IndexerDefinition>()))
.Throws<Exception>();
Assert.Throws<Exception>(() => Subject.GetCapabilities(_settings));
Assert.Throws<Exception>(() => Subject.GetCapabilities(_settings, _definition));
}
[Test]
@@ -83,7 +96,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
{
GivenCapsResponse(_caps.Replace("<limits", "<>"));
Assert.Throws<XmlException>(() => Subject.GetCapabilities(_settings));
Assert.Throws<XmlException>(() => Subject.GetCapabilities(_settings, _definition));
}
[Test]
@@ -91,7 +104,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
{
GivenCapsResponse(_caps.Replace("5030", "asdf"));
var result = Subject.GetCapabilities(_settings);
var result = Subject.GetCapabilities(_settings, _definition);
result.Should().NotBeNull();
}

View File

@@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
_caps = new IndexerCapabilities();
Mocker.GetMock<INewznabCapabilitiesProvider>()
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>()))
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>(), It.IsAny<IndexerDefinition>()))
.Returns(_caps);
}
@@ -42,9 +42,9 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
{
var recentFeed = ReadAllText(@"Files/Indexers/Newznab/newznab_nzb_su.xml");
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
var releases = (await Subject.Fetch(new MovieSearchCriteria { Categories = new int[] { 2000 }, Limit = 100, Offset = 0 })).Releases;

View File

@@ -13,6 +13,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
public class NewznabRequestGeneratorFixture : CoreTest<NewznabRequestGenerator>
{
private MovieSearchCriteria _movieSearchCriteria;
private TvSearchCriteria _tvSearchCriteria;
private IndexerCapabilities _capabilities;
[SetUp]
@@ -30,10 +31,17 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
Categories = new int[] { 2000 }
};
_tvSearchCriteria = new TvSearchCriteria
{
SearchTerm = "Star Wars",
Categories = new int[] { 5000 },
Season = 0
};
_capabilities = new IndexerCapabilities();
Mocker.GetMock<INewznabCapabilitiesProvider>()
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>()))
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>(), It.IsAny<IndexerDefinition>()))
.Returns(_capabilities);
}
@@ -178,5 +186,18 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
pageTier2.Url.Query.Should().NotContain("imdbid=0076759");
pageTier2.Url.Query.Should().Contain("q=");
}
[Test]
public void should_pad_seasons_for_tv_search()
{
_capabilities.TvSearchParams = new List<TvSearchParam> { TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep };
var results = Subject.GetSearchRequests(_tvSearchCriteria);
results.Tiers.Should().Be(1);
var pageTier = results.GetTier(0).First().First();
pageTier.Url.Query.Should().Contain("season=00");
}
}
}

View File

@@ -36,13 +36,13 @@ namespace NzbDrone.Core.Test.IndexerTests.PTPTests
Json.Serialize(authResponse, authStream);
var responseJson = ReadAllText(fileName);
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.POST)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), authStream.ToString())));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.POST), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), authStream.ToString())));
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader { ContentType = HttpAccept.Json.Value }, new CookieCollection(), responseJson)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader { ContentType = HttpAccept.Json.Value }, new CookieCollection(), responseJson)));
var torrents = (await Subject.Fetch(new MovieSearchCriteria())).Releases;

View File

@@ -37,9 +37,9 @@ namespace NzbDrone.Core.Test.IndexerTests.RarbgTests
{
var recentFeed = ReadAllText(@"Files/Indexers/Rarbg/RecentFeed_v2.json");
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
var releases = (await Subject.Fetch(new MovieSearchCriteria { Categories = new int[] { 2000 } })).Releases;
@@ -64,9 +64,9 @@ namespace NzbDrone.Core.Test.IndexerTests.RarbgTests
[Test]
public async Task should_parse_error_20_as_empty_results()
{
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), "{ error_code: 20, error: \"some message\" }")));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), "{ error_code: 20, error: \"some message\" }")));
var releases = (await Subject.Fetch(new MovieSearchCriteria { Categories = new int[] { 2000 } })).Releases;
@@ -76,9 +76,9 @@ namespace NzbDrone.Core.Test.IndexerTests.RarbgTests
[Test]
public async Task should_warn_on_unknown_error()
{
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), "{ error_code: 25, error: \"some message\" }")));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), "{ error_code: 25, error: \"some message\" }")));
var releases = (await Subject.Fetch(new MovieSearchCriteria { Categories = new int[] { 2000 } })).Releases;

View File

@@ -20,7 +20,7 @@ namespace NzbDrone.Core.Test.IndexerTests
public int _supportedPageSize;
public override int PageSize => _supportedPageSize;
public TestIndexer(IHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, IValidateNzbs nzbValidationService, Logger logger)
public TestIndexer(IIndexerHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, IValidateNzbs nzbValidationService, Logger logger)
: base(httpClient, eventAggregator, indexerStatusService, configService, nzbValidationService, logger)
{
}

View File

@@ -34,7 +34,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
_caps = new IndexerCapabilities();
Mocker.GetMock<INewznabCapabilitiesProvider>()
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>()))
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>(), It.IsAny<IndexerDefinition>()))
.Returns(_caps);
}
@@ -43,9 +43,9 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
{
var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_hdaccess_net.xml");
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
var releases = (await Subject.Fetch(new MovieSearchCriteria())).Releases;
@@ -72,9 +72,9 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
{
var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_tpb.xml");
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
var releases = (await Subject.Fetch(new MovieSearchCriteria())).Releases;
@@ -102,9 +102,9 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
{
var recentFeed = ReadAllText(@"Files/Indexers/Torznab/torznab_animetosho.xml");
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
Mocker.GetMock<IIndexerHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.GET), Subject.Definition))
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), recentFeed)));
var releases = (await Subject.Fetch(new MovieSearchCriteria())).Releases;

View File

@@ -3,7 +3,7 @@
<TargetFrameworks>net5.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.0.78" />
<PackageReference Include="Dapper" Version="2.0.90" />
<PackageReference Include="NBuilder" Version="6.1.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.113.0-0" />
<PackageReference Include="YamlDotNet" Version="11.2.1" />

View File

@@ -7,5 +7,6 @@ namespace NzbDrone.Core.Applications
public int IndexerId { get; set; }
public int AppId { get; set; }
public int RemoteIndexerId { get; set; }
public string RemoteIndexerName { get; set; }
}
}

View File

@@ -8,6 +8,7 @@ namespace NzbDrone.Core.Applications
{
List<AppIndexerMap> GetMappingsForApp(int appId);
AppIndexerMap Insert(AppIndexerMap appIndexerMap);
AppIndexerMap Update(AppIndexerMap appIndexerMap);
void Delete(int mappingId);
void DeleteAllForApp(int appId);
}
@@ -41,6 +42,11 @@ namespace NzbDrone.Core.Applications
return _appIndexerMapRepository.Insert(appIndexerMap);
}
public AppIndexerMap Update(AppIndexerMap appIndexerMap)
{
return _appIndexerMapRepository.Update(appIndexerMap);
}
public void Handle(ProviderDeletedEvent<IApplication> message)
{
_appIndexerMapRepository.DeleteAllForApp(message.ProviderId);

View File

@@ -14,7 +14,7 @@ namespace NzbDrone.Core.Applications
protected readonly IAppIndexerMapService _appIndexerMapService;
protected readonly Logger _logger;
protected static readonly Regex AppIndexerRegex = new Regex(@"\/(?<indexer>\d.)\/",
protected static readonly Regex AppIndexerRegex = new Regex(@"\/(?<indexer>\d{1,3})(?:\/(?:api)?\/?)?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public abstract string Name { get; }
@@ -58,7 +58,7 @@ namespace NzbDrone.Core.Applications
public abstract void AddIndexer(IndexerDefinition indexer);
public abstract void UpdateIndexer(IndexerDefinition indexer);
public abstract void RemoveIndexer(int indexerId);
public abstract Dictionary<int, int> GetIndexerMappings();
public abstract List<AppIndexerMap> GetIndexerMappings();
public virtual object RequestAction(string action, IDictionary<string, string> query)
{

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Net;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Indexers;
@@ -110,9 +111,6 @@ namespace NzbDrone.Core.Applications
{
var indexerMappings = _appIndexerMapService.GetMappingsForApp(app.Definition.Id);
//Remote-Local mappings currently stored by Prowlarr
var prowlarrMappings = indexerMappings.ToDictionary(i => i.RemoteIndexerId, i => i.IndexerId);
//Get Dictionary of Remote Indexers point to Prowlarr and what they are mapped to
var remoteMappings = ExecuteAction(a => a.GetIndexerMappings(), app);
@@ -124,9 +122,16 @@ namespace NzbDrone.Core.Applications
//Add mappings if not already in db, these were setup manually in the app or orphaned by a table wipe
foreach (var mapping in remoteMappings)
{
if (!prowlarrMappings.ContainsKey(mapping.Key))
if (!indexerMappings.Any(m => (m.RemoteIndexerId > 0 && m.RemoteIndexerId == mapping.RemoteIndexerId) || (m.RemoteIndexerName.IsNotNullOrWhiteSpace() && m.RemoteIndexerName == mapping.RemoteIndexerName)))
{
var addMapping = new AppIndexerMap { AppId = app.Definition.Id, RemoteIndexerId = mapping.Key, IndexerId = mapping.Value };
var addMapping = new AppIndexerMap
{
AppId = app.Definition.Id,
RemoteIndexerId = mapping.RemoteIndexerId,
RemoteIndexerName = mapping.RemoteIndexerName,
IndexerId = mapping.IndexerId
};
_appIndexerMapService.Insert(addMapping);
indexerMappings.Add(addMapping);
}

View File

@@ -9,6 +9,6 @@ namespace NzbDrone.Core.Applications
void AddIndexer(IndexerDefinition indexer);
void UpdateIndexer(IndexerDefinition indexer);
void RemoveIndexer(int indexerId);
Dictionary<int, int> GetIndexerMappings();
List<AppIndexerMap> GetIndexerMappings();
}
}

View File

@@ -55,12 +55,12 @@ namespace NzbDrone.Core.Applications.Lidarr
return new ValidationResult(failures);
}
public override Dictionary<int, int> GetIndexerMappings()
public override List<AppIndexerMap> GetIndexerMappings()
{
var indexers = _lidarrV1Proxy.GetIndexers(Settings)
.Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab");
var mappings = new Dictionary<int, int>();
var mappings = new List<AppIndexerMap>();
foreach (var indexer in indexers)
{
@@ -71,7 +71,7 @@ namespace NzbDrone.Core.Applications.Lidarr
if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId))
{
//Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance
mappings.Add(indexer.Id, indexerId);
mappings.Add(new AppIndexerMap { RemoteIndexerId = indexer.Id, IndexerId = indexerId });
}
}
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using FluentValidation.Results;
using Newtonsoft.Json.Linq;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Indexers;
namespace NzbDrone.Core.Applications.Mylar
{
public class Mylar : ApplicationBase<MylarSettings>
{
public override string Name => "Mylar";
private readonly IMylarV3Proxy _mylarV3Proxy;
private readonly IConfigFileProvider _configFileProvider;
public Mylar(IMylarV3Proxy mylarV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, Logger logger)
: base(appIndexerMapService, logger)
{
_mylarV3Proxy = mylarV3Proxy;
_configFileProvider = configFileProvider;
}
public override ValidationResult Test()
{
var failures = new List<ValidationFailure>();
try
{
failures.AddIfNotNull(_mylarV3Proxy.TestConnection(Settings));
}
catch (WebException ex)
{
_logger.Error(ex, "Unable to send test message");
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Unable to complete application test, cannot connect to Mylar"));
}
return new ValidationResult(failures);
}
public override List<AppIndexerMap> GetIndexerMappings()
{
var indexers = _mylarV3Proxy.GetIndexers(Settings);
var mappings = new List<AppIndexerMap>();
foreach (var indexer in indexers)
{
if (indexer.Apikey == _configFileProvider.ApiKey)
{
var match = AppIndexerRegex.Match(indexer.Host);
if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId))
{
//Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance
mappings.Add(new AppIndexerMap { RemoteIndexerName = $"{indexer.Type},{indexer.Name}", IndexerId = indexerId });
}
}
}
return mappings;
}
public override void AddIndexer(IndexerDefinition indexer)
{
if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any())
{
var mylarIndexer = BuildMylarIndexer(indexer, indexer.Protocol);
var remoteIndexer = _mylarV3Proxy.AddIndexer(mylarIndexer, Settings);
_appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerName = $"{remoteIndexer.Type},{remoteIndexer.Name}" });
}
}
public override void RemoveIndexer(int indexerId)
{
var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id);
var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexerId);
if (indexerMapping != null)
{
//Remove Indexer remotely and then remove the mapping
var indexerProps = indexerMapping.RemoteIndexerName.Split(",");
_mylarV3Proxy.RemoveIndexer(indexerProps[1], (MylarProviderType)Enum.Parse(typeof(MylarProviderType), indexerProps[0]), Settings);
_appIndexerMapService.Delete(indexerMapping.Id);
}
}
public override void UpdateIndexer(IndexerDefinition indexer)
{
_logger.Debug("Updating indexer {0} [{1}]", indexer.Name, indexer.Id);
var appMappings = _appIndexerMapService.GetMappingsForApp(Definition.Id);
var indexerMapping = appMappings.FirstOrDefault(m => m.IndexerId == indexer.Id);
var indexerProps = indexerMapping.RemoteIndexerName.Split(",");
var mylarIndexer = BuildMylarIndexer(indexer, indexer.Protocol, indexerProps[1]);
//Use the old remote id to find the indexer on Mylar incase the update was from a name change in Prowlarr
var remoteIndexer = _mylarV3Proxy.GetIndexer(indexerProps[1], mylarIndexer.Type, Settings);
if (remoteIndexer != null)
{
_logger.Debug("Remote indexer found, syncing with current settings");
if (!mylarIndexer.Equals(remoteIndexer))
{
_mylarV3Proxy.UpdateIndexer(mylarIndexer, Settings);
indexerMapping.RemoteIndexerName = $"{mylarIndexer.Type},{mylarIndexer.Altername}";
_appIndexerMapService.Update(indexerMapping);
}
}
else
{
_appIndexerMapService.Delete(indexerMapping.Id);
if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any())
{
_logger.Debug("Remote indexer not found, re-adding {0} to Mylar", indexer.Name);
var newRemoteIndexer = _mylarV3Proxy.AddIndexer(mylarIndexer, Settings);
_appIndexerMapService.Insert(new AppIndexerMap { AppId = Definition.Id, IndexerId = indexer.Id, RemoteIndexerName = $"{newRemoteIndexer.Type},{newRemoteIndexer.Name}" });
}
else
{
_logger.Debug("Remote indexer not found for {0}, skipping re-add to Mylar due to indexer capabilities", indexer.Name);
}
}
}
private MylarIndexer BuildMylarIndexer(IndexerDefinition indexer, DownloadProtocol protocol, string originalName = null)
{
var schema = protocol == DownloadProtocol.Usenet ? MylarProviderType.Newznab : MylarProviderType.Torznab;
var mylarIndexer = new MylarIndexer
{
Name = originalName ?? $"{indexer.Name} (Prowlarr)",
Altername = $"{indexer.Name} (Prowlarr)",
Host = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api",
Apikey = _configFileProvider.ApiKey,
Categories = string.Join(",", indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())),
Enabled = indexer.Enable,
Type = schema,
};
return mylarIndexer;
}
}
}

View File

@@ -0,0 +1,8 @@
namespace NzbDrone.Core.Applications.Mylar
{
public class MylarError
{
public int Code { get; set; }
public string Message { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
using System;
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.Applications.Mylar
{
public class MylarException : NzbDroneException
{
public MylarException(string message)
: base(message)
{
}
public MylarException(string message, params object[] args)
: base(message, args)
{
}
public MylarException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace NzbDrone.Core.Applications.Mylar
{
public class MylarIndexerResponse
{
public bool Success { get; set; }
public MylarIndexerData Data { get; set; }
public MylarError Error { get; set; }
}
public class MylarIndexerData
{
public List<MylarIndexer> Torznabs { get; set; }
public List<MylarIndexer> Newznabs { get; set; }
}
public enum MylarProviderType
{
Newznab,
Torznab
}
public class MylarIndexer
{
public string Name { get; set; }
public string Host { get; set; }
public string Apikey { get; set; }
public string Categories { get; set; }
public bool Enabled { get; set; }
public string Altername { get; set; }
public MylarProviderType Type { get; set; }
public bool Equals(MylarIndexer other)
{
if (ReferenceEquals(null, other))
{
return false;
}
return other.Host == Host &&
other.Apikey == Apikey &&
other.Name == Name &&
other.Categories == Categories &&
other.Enabled == Enabled &&
other.Altername == Altername;
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Collections.Generic;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Applications.Mylar
{
public class MylarSettingsValidator : AbstractValidator<MylarSettings>
{
public MylarSettingsValidator()
{
RuleFor(c => c.BaseUrl).IsValidUrl();
RuleFor(c => c.ProwlarrUrl).IsValidUrl();
RuleFor(c => c.ApiKey).NotEmpty();
}
}
public class MylarSettings : IApplicationSettings
{
private static readonly MylarSettingsValidator Validator = new MylarSettingsValidator();
public MylarSettings()
{
ProwlarrUrl = "http://localhost:9696";
BaseUrl = "http://localhost:8090";
SyncCategories = new[] { NewznabStandardCategory.BooksComics.Id };
}
public IEnumerable<int> SyncCategories { get; set; }
[FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Mylar sees it, including http(s)://, port, and urlbase if needed")]
public string ProwlarrUrl { get; set; }
[FieldDefinition(1, Label = "Mylar Server", HelpText = "Mylar server URL, including http(s):// and port if needed")]
public string BaseUrl { get; set; }
[FieldDefinition(2, Label = "ApiKey", Privacy = PrivacyLevel.ApiKey, HelpText = "The ApiKey generated by Mylar in Settings/Web Interface")]
public string ApiKey { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -0,0 +1,8 @@
namespace NzbDrone.Core.Applications.Mylar
{
public class MylarStatus
{
public bool Success { get; set; }
public MylarError Error { get; set; }
}
}

View File

@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation.Results;
using Newtonsoft.Json;
using NLog;
using NzbDrone.Common.Http;
namespace NzbDrone.Core.Applications.Mylar
{
public interface IMylarV3Proxy
{
MylarIndexer AddIndexer(MylarIndexer indexer, MylarSettings settings);
List<MylarIndexer> GetIndexers(MylarSettings settings);
MylarIndexer GetIndexer(string indexerName, MylarProviderType indexerType, MylarSettings settings);
void RemoveIndexer(string indexerName, MylarProviderType indexerType, MylarSettings settings);
MylarIndexer UpdateIndexer(MylarIndexer indexer, MylarSettings settings);
ValidationFailure TestConnection(MylarSettings settings);
}
public class MylarV1Proxy : IMylarV3Proxy
{
private readonly IHttpClient _httpClient;
private readonly Logger _logger;
public MylarV1Proxy(IHttpClient httpClient, Logger logger)
{
_httpClient = httpClient;
_logger = logger;
}
public MylarStatus GetStatus(MylarSettings settings)
{
var request = BuildRequest(settings, "/api", "getVersion", HttpMethod.GET);
return Execute<MylarStatus>(request);
}
public List<MylarIndexer> GetIndexers(MylarSettings settings)
{
var request = BuildRequest(settings, "/api", "listProviders", HttpMethod.GET);
var response = Execute<MylarIndexerResponse>(request);
if (!response.Success)
{
throw new MylarException(string.Format("Mylar Error - Code {0}: {1}", response.Error.Code, response.Error.Message));
}
var indexers = new List<MylarIndexer>();
var torIndexers = response.Data.Torznabs;
torIndexers.ForEach(i => i.Type = MylarProviderType.Torznab);
var nzbIndexers = response.Data.Newznabs;
nzbIndexers.ForEach(i => i.Type = MylarProviderType.Newznab);
indexers.AddRange(torIndexers);
indexers.AddRange(nzbIndexers);
indexers.ForEach(i => i.Altername = i.Name);
return indexers;
}
public MylarIndexer GetIndexer(string indexerName, MylarProviderType indexerType, MylarSettings settings)
{
var indexers = GetIndexers(settings);
return indexers.SingleOrDefault(i => i.Name == indexerName && i.Type == indexerType);
}
public void RemoveIndexer(string indexerName, MylarProviderType indexerType, MylarSettings settings)
{
var parameters = new Dictionary<string, string>
{
{ "name", indexerName },
{ "providertype", indexerType.ToString().ToLower() }
};
var request = BuildRequest(settings, "/api", "delProvider", HttpMethod.GET, parameters);
CheckForError(Execute<MylarStatus>(request));
}
public MylarIndexer AddIndexer(MylarIndexer indexer, MylarSettings settings)
{
var parameters = new Dictionary<string, string>
{
{ "name", indexer.Name },
{ "providertype", indexer.Type.ToString().ToLower() },
{ "host", indexer.Host },
{ "prov_apikey", indexer.Apikey },
{ "enabled", indexer.Enabled.ToString().ToLower() },
{ "categories", indexer.Categories }
};
var request = BuildRequest(settings, "/api", "addProvider", HttpMethod.GET, parameters);
CheckForError(Execute<MylarStatus>(request));
return indexer;
}
public MylarIndexer UpdateIndexer(MylarIndexer indexer, MylarSettings settings)
{
var parameters = new Dictionary<string, string>
{
{ "name", indexer.Name },
{ "providertype", indexer.Type.ToString().ToLower() },
{ "host", indexer.Host },
{ "prov_apikey", indexer.Apikey },
{ "enabled", indexer.Enabled.ToString().ToLower() },
{ "categories", indexer.Categories },
{ "altername", indexer.Altername }
};
var request = BuildRequest(settings, "/api", "changeProvider", HttpMethod.GET, parameters);
CheckForError(Execute<MylarStatus>(request));
return indexer;
}
private void CheckForError(MylarStatus response)
{
if (!response.Success)
{
throw new MylarException(string.Format("Mylar Error - Code {0}: {1}", response.Error.Code, response.Error.Message));
}
}
public ValidationFailure TestConnection(MylarSettings settings)
{
try
{
var status = GetStatus(settings);
if (!status.Success)
{
return new ValidationFailure("ApiKey", status.Error.Message);
}
}
catch (HttpException ex)
{
_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;
}
private HttpRequest BuildRequest(MylarSettings settings, string resource, string command, HttpMethod method, Dictionary<string, string> parameters = null)
{
var baseUrl = settings.BaseUrl.TrimEnd('/');
var requestBuilder = new HttpRequestBuilder(baseUrl).Resource(resource)
.AddQueryParam("cmd", command)
.AddQueryParam("apikey", settings.ApiKey);
if (parameters != null)
{
foreach (var param in parameters)
{
requestBuilder.AddQueryParam(param.Key, param.Value);
}
}
var request = requestBuilder.Build();
request.Headers.ContentType = "application/json";
request.Method = method;
request.AllowAutoRedirect = true;
return request;
}
private TResource Execute<TResource>(HttpRequest request)
where TResource : new()
{
var response = _httpClient.Execute(request);
var results = JsonConvert.DeserializeObject<TResource>(response.Content);
return results;
}
}
}

View File

@@ -55,12 +55,12 @@ namespace NzbDrone.Core.Applications.Radarr
return new ValidationResult(failures);
}
public override Dictionary<int, int> GetIndexerMappings()
public override List<AppIndexerMap> GetIndexerMappings()
{
var indexers = _radarrV3Proxy.GetIndexers(Settings)
.Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab");
var mappings = new Dictionary<int, int>();
var mappings = new List<AppIndexerMap>();
foreach (var indexer in indexers)
{
@@ -71,7 +71,7 @@ namespace NzbDrone.Core.Applications.Radarr
if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId))
{
//Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance
mappings.Add(indexer.Id, indexerId);
mappings.Add(new AppIndexerMap { RemoteIndexerId = indexer.Id, IndexerId = indexerId });
}
}
}

View File

@@ -55,12 +55,12 @@ namespace NzbDrone.Core.Applications.Readarr
return new ValidationResult(failures);
}
public override Dictionary<int, int> GetIndexerMappings()
public override List<AppIndexerMap> GetIndexerMappings()
{
var indexers = _readarrV1Proxy.GetIndexers(Settings)
.Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab");
.Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab");
var mappings = new Dictionary<int, int>();
var mappings = new List<AppIndexerMap>();
foreach (var indexer in indexers)
{
@@ -71,7 +71,7 @@ namespace NzbDrone.Core.Applications.Readarr
if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId))
{
//Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance
mappings.Add(indexer.Id, indexerId);
mappings.Add(new AppIndexerMap { RemoteIndexerId = indexer.Id, IndexerId = indexerId });
}
}
}

View File

@@ -55,12 +55,12 @@ namespace NzbDrone.Core.Applications.Sonarr
return new ValidationResult(failures);
}
public override Dictionary<int, int> GetIndexerMappings()
public override List<AppIndexerMap> GetIndexerMappings()
{
var indexers = _sonarrV3Proxy.GetIndexers(Settings)
.Where(i => i.Implementation == "Newznab" || i.Implementation == "Torznab");
var mappings = new Dictionary<int, int>();
var mappings = new List<AppIndexerMap>();
foreach (var indexer in indexers)
{
@@ -71,7 +71,7 @@ namespace NzbDrone.Core.Applications.Sonarr
if (match.Groups["indexer"].Success && int.TryParse(match.Groups["indexer"].Value, out var indexerId))
{
//Add parsed mapping if it's mapped to a Indexer in this Prowlarr instance
mappings.Add(indexer.Id, indexerId);
mappings.Add(new AppIndexerMap { RemoteIndexerId = indexer.Id, IndexerId = indexerId });
}
}
}
@@ -81,7 +81,7 @@ namespace NzbDrone.Core.Applications.Sonarr
public override void AddIndexer(IndexerDefinition indexer)
{
if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any())
if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any() || indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Any())
{
var sonarrIndexer = BuildSonarrIndexer(indexer, indexer.Protocol);
@@ -128,7 +128,7 @@ namespace NzbDrone.Core.Applications.Sonarr
{
_appIndexerMapService.Delete(indexerMapping.Id);
if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any())
if (indexer.Capabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray()).Any() || indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()).Any())
{
_logger.Debug("Remote indexer not found, re-adding {0} to Sonarr", indexer.Name);
sonarrIndexer.Id = 0;
@@ -169,7 +169,7 @@ namespace NzbDrone.Core.Applications.Sonarr
sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiPath").Value = "/api";
sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "apiKey").Value = _configFileProvider.ApiKey;
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.SyncCategories.ToArray()));
sonarrIndexer.Fields.FirstOrDefault(x => x.Name == "animeCategories").Value = JArray.FromObject(indexer.Capabilities.Categories.SupportedCategories(Settings.AnimeSyncCategories.ToArray()));
return sonarrIndexer;
}

View File

@@ -23,10 +23,12 @@ namespace NzbDrone.Core.Applications.Sonarr
{
ProwlarrUrl = "http://localhost:9696";
BaseUrl = "http://localhost:8989";
SyncCategories = new[] { 5000, 5010, 5020, 5030, 5040, 5045, 5050, 5070 };
SyncCategories = new[] { 5000, 5010, 5020, 5030, 5040, 5045, 5050 };
AnimeSyncCategories = new[] { 5070 };
}
public IEnumerable<int> SyncCategories { get; set; }
public IEnumerable<int> AnimeSyncCategories { get; set; }
[FieldDefinition(0, Label = "Prowlarr Server", HelpText = "Prowlarr server URL as Sonarr sees it, including http(s)://, port, and urlbase if needed")]
public string ProwlarrUrl { get; set; }

View File

@@ -0,0 +1,21 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(10)]
public class IndexerProxies : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Create.TableForModel("IndexerProxies")
.WithColumn("Name").AsString()
.WithColumn("Settings").AsString()
.WithColumn("Implementation").AsString()
.WithColumn("ConfigContract").AsString().Nullable()
.WithColumn("Tags").AsString().Nullable();
Alter.Table("Indexers").AddColumn("Tags").AsString().Nullable();
}
}
}

View File

@@ -0,0 +1,14 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(11)]
public class app_indexer_remote_name : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("ApplicationIndexerMapping").AddColumn("RemoteIndexerName").AsString().Nullable();
}
}
}

View File

@@ -9,6 +9,7 @@ using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFilters;
using NzbDrone.Core.Datastore.Converters;
using NzbDrone.Core.Download;
using NzbDrone.Core.IndexerProxies;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Instrumentation;
using NzbDrone.Core.Jobs;
@@ -53,7 +54,6 @@ namespace NzbDrone.Core.Datastore
.Ignore(i => i.SupportsSearch)
.Ignore(i => i.SupportsRedirect)
.Ignore(i => i.Capabilities)
.Ignore(d => d.Tags)
.HasOne(a => a.AppProfile, a => a.AppProfileId);
Mapper.Entity<DownloadClientDefinition>("DownloadClients").RegisterModel()
@@ -65,6 +65,9 @@ namespace NzbDrone.Core.Datastore
.Ignore(x => x.ImplementationName)
.Ignore(i => i.SupportsOnHealthIssue);
Mapper.Entity<IndexerProxyDefinition>("IndexerProxies").RegisterModel()
.Ignore(x => x.ImplementationName);
Mapper.Entity<ApplicationDefinition>("Applications").RegisterModel()
.Ignore(x => x.ImplementationName);

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Threading;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Download.Clients.Aria2
{
public class Aria2 : TorrentClientBase<Aria2Settings>
{
private readonly IAria2Proxy _proxy;
public override string Name => "Aria2";
public Aria2(IAria2Proxy proxy,
ITorrentFileInfoReader torrentFileInfoReader,
IHttpClient httpClient,
IConfigService configService,
IDiskProvider diskProvider,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, logger)
{
_proxy = proxy;
}
protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink)
{
var gid = _proxy.AddUri(Settings, magnetLink);
var tries = 10;
var retryDelay = 500;
// Wait a bit for the magnet to be resolved.
if (!WaitForTorrent(gid, hash, tries, retryDelay))
{
_logger.Warn($"Aria2 could not add magnent within {tries * retryDelay / 1000} seconds, download may remain stuck: {magnetLink}.");
return hash;
}
_logger.Debug($"Äria2 AddFromMagnetLink '{hash}' -> '{gid}'");
return hash;
}
protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent)
{
var gid = _proxy.AddTorrent(Settings, fileContent);
var tries = 10;
var retryDelay = 500;
// Wait a bit for the magnet to be resolved.
if (!WaitForTorrent(gid, hash, tries, retryDelay))
{
_logger.Warn($"Aria2 could not add torrent within {tries * retryDelay / 1000} seconds, download may remain stuck: {filename}.");
return hash;
}
return hash;
}
private bool WaitForTorrent(string gid, string hash, int tries, int retryDelay)
{
for (var i = 0; i < tries; i++)
{
var found = _proxy.GetFromGID(Settings, gid);
if (found?.InfoHash?.ToLower() == hash?.ToLower())
{
return true;
}
Thread.Sleep(retryDelay);
}
_logger.Debug("Could not find hash {0} in {1} tries at {2} ms intervals.", hash, tries, retryDelay);
return false;
}
protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnection());
if (failures.HasErrors())
{
return;
}
}
private ValidationFailure TestConnection()
{
try
{
var version = _proxy.GetVersion(Settings);
if (new Version(version) < new Version("1.34.0"))
{
return new ValidationFailure(string.Empty, "Aria2 version should be at least 1.34.0. Version reported is {0}", version);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to test Aria2");
return new NzbDroneValidationFailure("Host", "Unable to connect to Aria2")
{
DetailedDescription = ex.Message
};
}
return null;
}
protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink)
{
var gid = _proxy.AddUri(Settings, torrentLink);
var tries = 10;
var retryDelay = 500;
// Wait a bit for the magnet to be resolved.
if (!WaitForTorrent(gid, hash, tries, retryDelay))
{
_logger.Warn($"Aria2 could not add torrent within {tries * retryDelay / 1000} seconds, download may remain stuck: {torrentLink}.");
return hash;
}
_logger.Debug($"Aria2 AddFromTorrentLink '{hash}' -> '{gid}'");
return hash;
}
}
}

View File

@@ -0,0 +1,111 @@
using CookComputing.XmlRpc;
namespace NzbDrone.Core.Download.Clients.Aria2
{
public class Aria2Version
{
[XmlRpcMember("version")]
public string Version;
[XmlRpcMember("enabledFeatures")]
public string[] EnabledFeatures;
}
public class Aria2Uri
{
[XmlRpcMember("status")]
public string Status;
[XmlRpcMember("uri")]
public string Uri;
}
public class Aria2File
{
[XmlRpcMember("index")]
public string Index;
[XmlRpcMember("length")]
public string Length;
[XmlRpcMember("completedLength")]
public string CompletedLength;
[XmlRpcMember("path")]
public string Path;
[XmlRpcMember("selected")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string Selected;
[XmlRpcMember("uris")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public Aria2Uri[] Uris;
}
public class Aria2Status
{
[XmlRpcMember("bittorrent")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public XmlRpcStruct Bittorrent;
[XmlRpcMember("bitfield")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string Bitfield;
[XmlRpcMember("infoHash")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string InfoHash;
[XmlRpcMember("completedLength")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string CompletedLength;
[XmlRpcMember("connections")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string Connections;
[XmlRpcMember("dir")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string Dir;
[XmlRpcMember("downloadSpeed")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string DownloadSpeed;
[XmlRpcMember("files")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public Aria2File[] Files;
[XmlRpcMember("gid")]
public string Gid;
[XmlRpcMember("numPieces")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string NumPieces;
[XmlRpcMember("pieceLength")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string PieceLength;
[XmlRpcMember("status")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string Status;
[XmlRpcMember("totalLength")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string TotalLength;
[XmlRpcMember("uploadLength")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string UploadLength;
[XmlRpcMember("uploadSpeed")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string UploadSpeed;
[XmlRpcMember("errorMessage")]
[XmlRpcMissingMapping(MappingAction.Ignore)]
public string ErrorMessage;
}
}

View File

@@ -0,0 +1,144 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using CookComputing.XmlRpc;
using NLog;
namespace NzbDrone.Core.Download.Clients.Aria2
{
public interface IAria2Proxy
{
string GetVersion(Aria2Settings settings);
string AddUri(Aria2Settings settings, string magnet);
string AddTorrent(Aria2Settings settings, byte[] torrent);
Aria2Status GetFromGID(Aria2Settings settings, string gid);
}
public interface IAria2 : IXmlRpcProxy
{
[XmlRpcMethod("aria2.getVersion")]
Aria2Version GetVersion(string token);
[XmlRpcMethod("aria2.addUri")]
string AddUri(string token, string[] uri);
[XmlRpcMethod("aria2.addTorrent")]
string AddTorrent(string token, byte[] torrent);
[XmlRpcMethod("aria2.forceRemove")]
string Remove(string token, string gid);
[XmlRpcMethod("aria2.tellStatus")]
Aria2Status GetFromGid(string token, string gid);
[XmlRpcMethod("aria2.getGlobalOption")]
XmlRpcStruct GetGlobalOption(string token);
[XmlRpcMethod("aria2.tellActive")]
Aria2Status[] GetActives(string token);
[XmlRpcMethod("aria2.tellWaiting")]
Aria2Status[] GetWaitings(string token, int offset, int num);
[XmlRpcMethod("aria2.tellStopped")]
Aria2Status[] GetStoppeds(string token, int offset, int num);
}
public class Aria2Proxy : IAria2Proxy
{
private readonly Logger _logger;
public Aria2Proxy(Logger logger)
{
_logger = logger;
}
private string GetToken(Aria2Settings settings)
{
return $"token:{settings?.SecretToken}";
}
private string GetURL(Aria2Settings settings)
{
return $"http{(settings.UseSsl ? "s" : "")}://{settings.Host}:{settings.Port}{settings.RpcPath}";
}
public string GetVersion(Aria2Settings settings)
{
_logger.Debug("> aria2.getVersion");
var client = BuildClient(settings);
var version = ExecuteRequest(() => client.GetVersion(GetToken(settings)));
_logger.Debug("< aria2.getVersion");
return version.Version;
}
public Aria2Status GetFromGID(Aria2Settings settings, string gid)
{
_logger.Debug("> aria2.tellStatus");
var client = BuildClient(settings);
var found = ExecuteRequest(() => client.GetFromGid(GetToken(settings), gid));
_logger.Debug("< aria2.tellStatus");
return found;
}
public string AddUri(Aria2Settings settings, string magnet)
{
_logger.Debug("> aria2.addUri");
var client = BuildClient(settings);
var gid = ExecuteRequest(() => client.AddUri(GetToken(settings), new[] { magnet }));
_logger.Debug("< aria2.addUri");
return gid;
}
public string AddTorrent(Aria2Settings settings, byte[] torrent)
{
_logger.Debug("> aria2.addTorrent");
var client = BuildClient(settings);
var gid = ExecuteRequest(() => client.AddTorrent(GetToken(settings), torrent));
_logger.Debug("< aria2.addTorrent");
return gid;
}
private IAria2 BuildClient(Aria2Settings settings)
{
var client = XmlRpcProxyGen.Create<IAria2>();
client.Url = GetURL(settings);
return client;
}
private T ExecuteRequest<T>(Func<T> task)
{
try
{
return task();
}
catch (XmlRpcServerException ex)
{
throw new DownloadClientException("Unable to connect to aria2, please check your settings", ex);
}
catch (WebException ex)
{
if (ex.Status == WebExceptionStatus.TrustFailure)
{
throw new DownloadClientUnavailableException("Unable to connect to aria2, certificate validation failed.", ex);
}
throw new DownloadClientUnavailableException("Unable to connect to aria2, please check your settings", ex);
}
}
}
}

View File

@@ -0,0 +1,49 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Download.Clients.Aria2
{
public class Aria2SettingsValidator : AbstractValidator<Aria2Settings>
{
public Aria2SettingsValidator()
{
RuleFor(c => c.Host).ValidHost();
}
}
public class Aria2Settings : IProviderConfig
{
private static readonly Aria2SettingsValidator Validator = new Aria2SettingsValidator();
public Aria2Settings()
{
Host = "localhost";
Port = 6800;
RpcPath = "/rpc";
UseSsl = false;
SecretToken = "MySecretToken";
}
[FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)]
public string Host { get; set; }
[FieldDefinition(1, Label = "Port", Type = FieldType.Number)]
public int Port { get; set; }
[FieldDefinition(2, Label = "RPC Path", Type = FieldType.Textbox)]
public string RpcPath { get; set; }
[FieldDefinition(3, Label = "Use SSL", Type = FieldType.Checkbox)]
public bool UseSsl { get; set; }
[FieldDefinition(4, Label = "Secret token", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string SecretToken { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -1,33 +0,0 @@
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.Exceptions
{
public class MovieNotFoundException : NzbDroneException
{
public int TmdbMovieId { get; set; }
public MovieNotFoundException(int tmdbMovieId)
: base(string.Format("Movie with tmdbId {0} was not found, it may have been removed from TMDb.", tmdbMovieId))
{
TmdbMovieId = tmdbMovieId;
}
public MovieNotFoundException(string imdbId)
: base(string.Format("Movie with IMDBId {0} was not found, it may have been removed from TMDb.", imdbId))
{
TmdbMovieId = 0;
}
public MovieNotFoundException(int tmdbMovieId, string message, params object[] args)
: base(message, args)
{
TmdbMovieId = tmdbMovieId;
}
public MovieNotFoundException(int tmdbMovieId, string message)
: base(message)
{
TmdbMovieId = tmdbMovieId;
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.IndexerProxies;
using NzbDrone.Core.Localization;
using NzbDrone.Core.ThingiProvider.Events;
namespace NzbDrone.Core.HealthCheck.Checks
{
[CheckOn(typeof(ProviderDeletedEvent<IIndexerProxy>))]
[CheckOn(typeof(ProviderAddedEvent<IIndexerProxy>))]
[CheckOn(typeof(ProviderUpdatedEvent<IIndexerProxy>))]
public class IndexerProxyCheck : HealthCheckBase
{
private readonly IIndexerProxyFactory _proxyFactory;
public IndexerProxyCheck(IIndexerProxyFactory proxyFactory,
ILocalizationService localizationService)
: base(localizationService)
{
_proxyFactory = proxyFactory;
}
public override HealthCheck Check()
{
var enabledProviders = _proxyFactory.GetAvailableProviders();
var badProxies = enabledProviders.Where(p => p.Test().IsValid == false).ToList();
if (enabledProviders.Empty() || badProxies.Count == 0)
{
return new HealthCheck(GetType());
}
if (badProxies.Count == enabledProviders.Count)
{
return new HealthCheck(GetType(),
HealthCheckResult.Error,
_localizationService.GetLocalizedString("IndexerProxyStatusCheckAllClientMessage"),
"#proxies-are-unavailable-due-to-failures");
}
return new HealthCheck(GetType(),
HealthCheckResult.Warning,
string.Format(_localizationService.GetLocalizedString("IndexerProxyStatusCheckSingleClientMessage"),
string.Join(", ", badProxies.Select(v => v.Definition.Name))),
"#proxies-are-unavailable-due-to-failures");
}
}
}

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.Newznab;
using NzbDrone.Core.Localization;
using NzbDrone.Core.ThingiProvider.Events;
@@ -12,11 +11,11 @@ namespace NzbDrone.Core.HealthCheck.Checks
[CheckOn(typeof(ProviderAddedEvent<IIndexer>))]
[CheckOn(typeof(ProviderUpdatedEvent<IIndexer>))]
[CheckOn(typeof(ProviderDeletedEvent<IIndexer>))]
public class NewznabVIPCheck : HealthCheckBase
public class IndexerVIPCheck : HealthCheckBase
{
private readonly IIndexerFactory _indexerFactory;
public NewznabVIPCheck(IIndexerFactory indexerFactory, ILocalizationService localizationService)
public IndexerVIPCheck(IIndexerFactory indexerFactory, ILocalizationService localizationService)
: base(localizationService)
{
_indexerFactory = indexerFactory;
@@ -25,13 +24,20 @@ namespace NzbDrone.Core.HealthCheck.Checks
public override HealthCheck Check()
{
var enabled = _indexerFactory.Enabled(false);
var newznabProviders = enabled.Where(i => i.Definition.Implementation == typeof(Newznab).Name);
var expiringProviders = new List<IIndexer>();
var expiredProviders = new List<IIndexer>();
foreach (var provider in newznabProviders)
foreach (var provider in enabled)
{
var expiration = ((NewznabSettings)provider.Definition.Settings).VipExpiration;
var settingsType = provider.Definition.Settings.GetType();
var vipProp = settingsType.GetProperty("VipExpiration");
if (vipProp == null)
{
continue;
}
var expiration = (string)vipProp.GetValue(provider.Definition.Settings);
if (expiration.IsNullOrWhiteSpace())
{
@@ -53,18 +59,18 @@ namespace NzbDrone.Core.HealthCheck.Checks
{
return new HealthCheck(GetType(),
HealthCheckResult.Warning,
string.Format(_localizationService.GetLocalizedString("NewznabVipCheckExpiringClientMessage"),
string.Format(_localizationService.GetLocalizedString("IndexerVipCheckExpiringClientMessage"),
string.Join(", ", expiringProviders.Select(v => v.Definition.Name))),
"#newznab-vip-expiring");
"#indexer-vip-expiring");
}
if (!expiredProviders.Empty())
{
return new HealthCheck(GetType(),
HealthCheckResult.Warning,
string.Format(_localizationService.GetLocalizedString("NewznabVipCheckExpiredClientMessage"),
string.Format(_localizationService.GetLocalizedString("IndexerVipCheckExpiredClientMessage"),
string.Join(", ", expiredProviders.Select(v => v.Definition.Name))),
"#newznab-vip-expired");
"#indexer-vip-expired");
}
return new HealthCheck(GetType());

View File

@@ -25,9 +25,9 @@ namespace NzbDrone.Core.HealthCheck.Checks
public override HealthCheck Check()
{
var blacklist = _indexerDefinitionUpdateService.GetBlacklist();
var blocklist = _indexerDefinitionUpdateService.GetBlocklist();
var oldIndexers = _indexerFactory.All().Where(i => i.Implementation == "Cardigann" && blacklist.Contains(((CardigannSettings)i.Settings).DefinitionFile)).ToList();
var oldIndexers = _indexerFactory.All().Where(i => i.Implementation == "Cardigann" && blocklist.Contains(((CardigannSettings)i.Settings).DefinitionFile)).ToList();
if (oldIndexers.Count == 0)
{

View File

@@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers
{
using (var mapper = _database.OpenConnection())
{
var usedTags = new[] { "Notifications" }
var usedTags = new[] { "Notifications", "IndexerProxies", "Indexers", "Applications" }
.SelectMany(v => GetUsedTags(v, mapper))
.Distinct()
.ToArray();

View File

@@ -0,0 +1,224 @@
using System;
using System.Collections.Generic;
using System.Net;
using FluentValidation.Results;
using Newtonsoft.Json;
using NLog;
using NzbDrone.Common.Cloud;
using NzbDrone.Common.Http;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.IndexerProxies.FlareSolverr
{
public class FlareSolverr : HttpIndexerProxyBase<FlareSolverrSettings>
{
public FlareSolverr(IProwlarrCloudRequestBuilder cloudRequestBuilder, IHttpClient httpClient, Logger logger)
: base(cloudRequestBuilder, httpClient, logger)
{
}
public override string Name => "FlareSolverr";
public override HttpRequest PreRequest(HttpRequest request)
{
return GenerateFlareSolverrRequest(request);
}
public override HttpResponse PostResponse(HttpResponse response)
{
FlareSolverrResponse result = null;
if (response.StatusCode != HttpStatusCode.OK && response.StatusCode != HttpStatusCode.InternalServerError)
{
throw new FlareSolverrException("HTTP StatusCode not 200 or 500. Status is :" + response.StatusCode);
}
result = JsonConvert.DeserializeObject<FlareSolverrResponse>(response.Content);
var cookieCollection = new CookieCollection();
var responseHeader = new HttpHeader();
foreach (var cookie in result.Solution.Cookies)
{
cookieCollection.Add(cookie.ToCookieObj());
}
//Build new response with FS Cookie and Site Response
var newResponse = new HttpResponse(response.Request, responseHeader, cookieCollection, result.Solution.Response);
return newResponse;
}
private HttpRequest GenerateFlareSolverrRequest(HttpRequest request)
{
FlareSolverrRequest req;
var url = request.Url.ToString();
var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36";
var maxTimeout = 60000;
if (request.Method == HttpMethod.GET)
{
req = new FlareSolverrRequestGet
{
Cmd = "request.get",
Url = url,
MaxTimeout = maxTimeout,
UserAgent = userAgent
};
}
else if (request.Method == HttpMethod.POST)
{
var contentTypeType = request.Headers.ContentType;
if (contentTypeType == "application/x-www-form-urlencoded")
{
var contentTypeValue = request.Headers.ContentType.ToString();
var postData = request.GetContent();
req = new FlareSolverrRequestPostUrlEncoded
{
Cmd = "request.post",
Url = url,
PostData = postData,
Headers = new HeadersPost
{
ContentType = contentTypeValue,
ContentLength = null
},
MaxTimeout = maxTimeout,
UserAgent = userAgent
};
}
else if (contentTypeType.Contains("multipart/form-data"))
{
//TODO Implement - check if we just need to pass the content-type with the relevant headers
throw new FlareSolverrException("Unimplemented POST Content-Type: " + request.Headers.ContentType);
}
else
{
throw new FlareSolverrException("Unsupported POST Content-Type: " + request.Headers.ContentType);
}
}
else
{
throw new FlareSolverrException("Unsupported HttpMethod: " + request.Method);
}
var apiUrl = string.Format("{0}/v1", Settings.Host.TrimEnd('/'));
var newRequest = new HttpRequest(apiUrl, HttpAccept.Json);
newRequest.SetContent(req.ToJson());
newRequest.Method = HttpMethod.POST;
_logger.Debug("Applying FlareSolverr Proxy {0} to request {1}", Name, request.Url);
return newRequest;
}
public override ValidationResult Test()
{
var failures = new List<ValidationFailure>();
var request = PreRequest(_cloudRequestBuilder.Create()
.Resource("/ping")
.Build());
try
{
var response = _httpClient.Execute(request);
// We only care about 400 responses, other error codes can be ignored
if (response.StatusCode == HttpStatusCode.BadRequest)
{
_logger.Error("Proxy Health Check failed: {0}", response.StatusCode);
failures.Add(new NzbDroneValidationFailure("Host", "ProxyCheckBadRequestMessage"));
}
}
catch (Exception ex)
{
_logger.Error(ex, "Proxy Health Check failed");
failures.Add(new NzbDroneValidationFailure("Host", "ProxyCheckFailedToTestMessage"));
}
return new ValidationResult(failures);
}
public class FlareSolverrRequest
{
public string Cmd { get; set; }
public string Url { get; set; }
public string UserAgent { get; set; }
}
public class FlareSolverrRequestGet : FlareSolverrRequest
{
public string Headers { get; set; }
public int MaxTimeout { get; set; }
}
public class FlareSolverrRequestPost : FlareSolverrRequest
{
public string PostData { get; set; }
public int MaxTimeout { get; set; }
}
public class FlareSolverrRequestPostUrlEncoded : FlareSolverrRequestPost
{
public HeadersPost Headers { get; set; }
}
public class HeadersPost
{
public string ContentType { get; set; }
public string ContentLength { get; set; }
}
public class FlareSolverrResponse
{
public string Status { get; set; }
public string Message { get; set; }
public long StartTimestamp { get; set; }
public long EndTimestamp { get; set; }
public string Version { get; set; }
public Solution Solution { get; set; }
}
public class Solution
{
public string Url { get; set; }
public string Status { get; set; }
public Headers Headers { get; set; }
public string Response { get; set; }
public Cookie[] Cookies { get; set; }
public string UserAgent { get; set; }
}
public class Cookie
{
public string Name { get; set; }
public string Value { get; set; }
public string Domain { get; set; }
public string Path { get; set; }
public double Expires { get; set; }
public int Size { get; set; }
public bool HttpOnly { get; set; }
public bool Secure { get; set; }
public bool Session { get; set; }
public string SameSite { get; set; }
public string ToHeaderValue() => $"{Name}={Value}";
public System.Net.Cookie ToCookieObj() => new System.Net.Cookie(Name, Value);
}
public class Headers
{
public string Status { get; set; }
public string Date { get; set; }
[JsonProperty(PropertyName = "content-type")]
public string ContentType { get; set; }
}
}
}

View File

@@ -0,0 +1,23 @@
using System;
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.IndexerProxies.FlareSolverr
{
public class FlareSolverrException : NzbDroneException
{
public FlareSolverrException(string message)
: base(message)
{
}
public FlareSolverrException(string message, params object[] args)
: base(message, args)
{
}
public FlareSolverrException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,32 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.IndexerProxies.FlareSolverr
{
public class FlareSolverrSettingsValidator : AbstractValidator<FlareSolverrSettings>
{
public FlareSolverrSettingsValidator()
{
RuleFor(c => c.Host).NotEmpty();
}
}
public class FlareSolverrSettings : IIndexerProxySettings
{
private static readonly FlareSolverrSettingsValidator Validator = new FlareSolverrSettingsValidator();
public FlareSolverrSettings()
{
Host = "http://localhost:8191/";
}
[FieldDefinition(0, Label = "Host")]
public string Host { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -0,0 +1,34 @@
using System.Net;
using NLog;
using NzbDrone.Common.Cloud;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
namespace NzbDrone.Core.IndexerProxies.Http
{
public class Http : HttpIndexerProxyBase<HttpSettings>
{
public Http(IProwlarrCloudRequestBuilder cloudRequestBuilder, IHttpClient httpClient, Logger logger)
: base(cloudRequestBuilder, httpClient, logger)
{
}
public override string Name => "Http";
public override HttpRequest PreRequest(HttpRequest request)
{
if (Settings.Username.IsNotNullOrWhiteSpace() && Settings.Password.IsNotNullOrWhiteSpace())
{
request.Proxy = new WebProxy(Settings.Host + ":" + Settings.Port, false, null, new NetworkCredential(Settings.Username, Settings.Password));
}
else
{
request.Proxy = new WebProxy(Settings.Host + ":" + Settings.Port, false, null);
}
_logger.Debug("Applying HTTP(S) Proxy {0} to request {1}", Name, request.Url);
return request;
}
}
}

View File

@@ -0,0 +1,43 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.IndexerProxies.Http
{
public class HttpSettingsValidator : AbstractValidator<HttpSettings>
{
public HttpSettingsValidator()
{
RuleFor(c => c.Host).NotEmpty();
RuleFor(c => c.Port).NotEmpty();
}
}
public class HttpSettings : IIndexerProxySettings
{
private static readonly HttpSettingsValidator Validator = new HttpSettingsValidator();
public HttpSettings()
{
Username = "";
Password = "";
}
[FieldDefinition(0, Label = "Host")]
public string Host { get; set; }
[FieldDefinition(1, Label = "Port")]
public int Port { get; set; }
[FieldDefinition(2, Label = "Username", Privacy = PrivacyLevel.UserName)]
public string Username { get; set; }
[FieldDefinition(3, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Cloud;
using NzbDrone.Common.Http;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.IndexerProxies
{
public abstract class HttpIndexerProxyBase<TSettings> : IndexerProxyBase<TSettings>
where TSettings : IIndexerProxySettings, new()
{
protected readonly IHttpClient _httpClient;
protected readonly IHttpRequestBuilderFactory _cloudRequestBuilder;
protected readonly Logger _logger;
public HttpIndexerProxyBase(IProwlarrCloudRequestBuilder cloudRequestBuilder, IHttpClient httpClient, Logger logger)
{
_httpClient = httpClient;
_logger = logger;
_cloudRequestBuilder = cloudRequestBuilder.Services;
}
public override ValidationResult Test()
{
var failures = new List<ValidationFailure>();
var addresses = Dns.GetHostAddresses(Settings.Host);
if (!addresses.Any())
{
failures.Add(new NzbDroneValidationFailure("Host", "ProxyCheckResolveIpMessage"));
}
var request = PreRequest(_cloudRequestBuilder.Create()
.Resource("/ping")
.Build());
try
{
var response = PostResponse(_httpClient.Execute(request));
// We only care about 400 responses, other error codes can be ignored
if (response.StatusCode == HttpStatusCode.BadRequest)
{
_logger.Error("Proxy Health Check failed: {0}", response.StatusCode);
failures.Add(new NzbDroneValidationFailure("Host", string.Format("Failed to test proxy. StatusCode: {0}", response.StatusCode)));
}
}
catch (Exception ex)
{
_logger.Error(ex, "Proxy Health Check failed");
failures.Add(new NzbDroneValidationFailure("Host", string.Format("Failed to test proxy: {0}", request.Url)));
}
return new ValidationResult(failures);
}
}
}

View File

@@ -0,0 +1,11 @@
using NzbDrone.Common.Http;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.IndexerProxies
{
public interface IIndexerProxy : IProvider
{
HttpRequest PreRequest(HttpRequest request);
HttpResponse PostResponse(HttpResponse response);
}
}

View File

@@ -0,0 +1,9 @@
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.IndexerProxies
{
public interface IIndexerProxySettings : IProviderConfig
{
string Host { get; set; }
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using FluentValidation.Results;
using NzbDrone.Common.Http;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.IndexerProxies
{
public abstract class IndexerProxyBase<TSettings> : IIndexerProxy
where TSettings : IIndexerProxySettings, new()
{
public abstract string Name { get; }
public virtual ProviderMessage Message => null;
public Type ConfigContract => typeof(TSettings);
public IEnumerable<ProviderDefinition> DefaultDefinitions => new List<ProviderDefinition>();
public ProviderDefinition Definition { get; set; }
public abstract ValidationResult Test();
public abstract HttpRequest PreRequest(HttpRequest request);
public virtual HttpResponse PostResponse(HttpResponse response)
{
return response;
}
protected TSettings Settings => (TSettings)Definition.Settings;
public override string ToString()
{
return GetType().Name;
}
public virtual object RequestAction(string action, IDictionary<string, string> query)
{
return null;
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Linq;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.IndexerProxies
{
public class IndexerProxyDefinition : ProviderDefinition
{
public override bool Enable => Tags.Any();
}
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.IndexerProxies
{
public interface IIndexerProxyFactory : IProviderFactory<IIndexerProxy, IndexerProxyDefinition>
{
}
public class IndexerProxyFactory : ProviderFactory<IIndexerProxy, IndexerProxyDefinition>, IIndexerProxyFactory
{
public IndexerProxyFactory(IIndexerProxyRepository providerRepository, IEnumerable<IIndexerProxy> providers, IServiceProvider container, IEventAggregator eventAggregator, Logger logger)
: base(providerRepository, providers, container, eventAggregator, logger)
{
}
}
}

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