Compare commits

...

68 Commits

Author SHA1 Message Date
Bogdan
9c599a6be4 New: (UI) Indexer privacy label
Fixes #2132
2024-06-28 05:43:09 +03:00
Bogdan
715ce1fc6c Refresh indexers list and status on page change 2024-06-28 04:57:24 +03:00
Bogdan
8c3a192dd0 Fixed: Ignore auth events from queries and grab stats 2024-06-28 04:56:18 +03:00
Bogdan
d22bf93dfd Fixed: Searches with season/episodes should not be treated as ID searches 2024-06-27 08:06:26 +03:00
Bogdan
886054fdf8 Bump indexers definition version to 11 2024-06-27 04:35:27 +03:00
Bogdan
4188510586 New: (Cardigann) Add info_category_8000 2024-06-27 04:35:27 +03:00
Bogdan
fedebca5e1 New: (Cardigann) Optional login selectorinputs and getselectorinputs 2024-06-27 04:35:27 +03:00
Bogdan
e2ce6437e9 Bump mac image to 12 2024-06-26 23:52:41 +03:00
Mark McDowall
bdae60bac9 Improvements to EnhancedSelectInput
(cherry picked from commit 4c622fd41289cd293a68a6a9f6b8da2a086edecb)
2024-06-26 04:47:25 +03:00
Bogdan
2d6c818aec Fixed: Exclude invalid releases from Newznab and Torznab parsers 2024-06-26 03:47:08 +03:00
Bogdan
a1d19852dc Switch TorrentsCSV to STJson 2024-06-21 02:25:21 +03:00
Bogdan
104c95f28f Bump version to 1.20.0 2024-06-20 19:03:57 +03:00
Bogdan
55fa1ec637 Small improvements to IndexerNoDefinitionCheck 2024-06-20 17:40:54 +03:00
Weblate
b27a3d8272 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <arnaudthommeray+github@ik.me>
Co-authored-by: Mariot Tsitoara <mariot@tsitoara.fr>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translation: Servarr/Prowlarr
2024-06-20 17:39:11 +03:00
Bogdan
089d450b46 Fixed: (M-Team-TP) New API path
Co-authored-by: Garfield69 <garfield69@outlook.com>
2024-06-20 16:57:54 +03:00
dependabot[bot]
358ac7434d Bump ws from 7.5.9 to 7.5.10
Bumps [ws](https://github.com/websockets/ws) from 7.5.9 to 7.5.10.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.5.9...7.5.10)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-19 20:47:47 +03:00
dependabot[bot]
9cd505fd8a Bump braces from 3.0.2 to 3.0.3
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 22:56:14 +03:00
Bogdan
20ac2687df New: Ignore inaccessible folders when getting folders
(cherry picked from commit a30e9da7672a202cb9e9188cf106afc34a5d0361)
2024-06-18 22:45:12 +03:00
Weblate
9f075c09a2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Felix <edivad94@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2024-06-16 16:24:03 +03:00
Bogdan
3793538ba4 Use tooltip component for detailed error message 2024-06-15 23:27:04 +03:00
Bogdan
4c4b16d234 Fixed: (FileList) Parsing poorly padded IMDb Ids 2024-06-14 22:10:09 +03:00
Bogdan
f5790bec2e HD-Space switched to Cardigann YML 2024-06-13 12:26:52 +03:00
Bogdan
6c0d08de56 Fixed: Ignore case for name validation in providers
(cherry picked from commit 0edc5ba99a15c5f80305b387a053f35fc3f6e51b)
2024-06-11 07:49:17 +03:00
Mark McDowall
ba344756b1 Fixed: Improve error messaging if config file isn't formatted correctly
(cherry picked from commit 52b72925f9d42c896144dde3099dc19c397327b0)
2024-06-11 07:48:15 +03:00
Servarr
dfda86aca3 Automated API Docs update 2024-06-10 17:54:04 +03:00
Bogdan
df6f83ed69 Increase timeout for docs 2024-06-10 17:40:27 +03:00
Bogdan
218d92a1ac docs: use application specific to platform 2024-06-10 17:21:57 +03:00
Bogdan
df2b529d01 Bump Swashbuckle to 6.6.2 2024-06-10 17:00:46 +03:00
Bogdan
0ef42dbb4d Display downtime message for Nebulance 2024-06-09 14:27:27 +03:00
Weblate
1a428197b2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Yi Cao <caoyi06@qq.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: r0bertreh <Robert.reh@live.de>
Co-authored-by: topnew <sznetim@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2024-06-03 09:14:00 +03:00
Bogdan
09d7983845 Bump version to 1.19.0 2024-06-02 05:33:28 +03:00
Bogdan
6e01f3187a New: (UI) Detailed error message for inner exception in indexers validation 2024-06-01 13:47:23 +03:00
Bogdan
468436b9f7 Fixed: Remove extraneous rate limiting for grabs
Co-authored-by: Michael Goodnow <mmgoodnow@gmail.com>

Closes #2140
2024-06-01 10:37:30 +03:00
Bogdan
76c288a6e4 Fixed: Authentication issues with Cardigann definitions having captcha
This mostly reverts 68b895d2ad where the cache key was changed to something more specific to avoid another issue with shared settings, but sadly this resulted in a new instance of CardigannRequestGenerator with null `landingResultDocument` failing the login.

Fixes #2139
2024-06-01 08:07:47 +03:00
Bogdan
f95f67a7ca New: (Cardigann) Bump minimum version to v10 2024-05-27 21:05:14 +03:00
Bogdan
11864247eb Bump Microsoft.NET.Test.Sdk and Polly 2024-05-25 02:31:44 +03:00
Bogdan
74509ea7c9 Fixed: (MyAnonamouse) Don't die when no results on paginated queries 2024-05-19 01:24:10 +03:00
Bogdan
948fe0a6dc Fixed: Trimming slashes from UrlBase when using environment variable 2024-05-18 19:12:37 +03:00
Bogdan
a4257cbcde Bump Npgsql to 7.0.7 2024-05-13 15:33:46 +03:00
Bogdan
2929c3c898 Bump version to 1.18.0 2024-05-12 16:23:24 +03:00
Weblate
2c5f2187c8 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michael5564445 <michaelvelosk@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/uk/
Translation: Servarr/Prowlarr
2024-05-10 14:05:29 +03:00
Bogdan
401ef88971 Refactor PasswordInput to use type password 2024-05-10 13:59:52 +03:00
Bogdan
4fb3754048 Fixed: Text color for inputs on login page 2024-05-09 23:40:50 +03:00
Mark McDowall
596efe8fb0 New: Dark theme for login screen
(cherry picked from commit cae134ec7b331d1c906343716472f3d043614b2c)
2024-05-09 23:40:49 +03:00
Bogdan
076a4f2574 Fix class name for AppIndexerMapRepository 2024-05-09 23:15:05 +03:00
Servarr
9561371a47 Automated API Docs update 2024-05-08 03:20:28 +03:00
Mark McDowall
16254cf5f9 New: Option to select download client when multiple of the same type are configured
(cherry picked from commit 07f0fbf9a51d54e44681fd0f74df4e048bff561a)
2024-05-08 03:14:17 +03:00
Jared
649a03e5a0 New: Config file setting to disable log database (#2123)
Co-authored-by: sillock1 <jprest97@gmail.com>
2024-05-07 19:52:02 +03:00
Bogdan
dd21d9b521 Fixed: Allow decimals for Seed Ratio 2024-05-07 00:11:20 +03:00
Bogdan
68b895d2ad Fixed: Don't share settings for same cached definition in CardigannRequestGenerator 2024-05-06 18:22:54 +03:00
Weblate
634016ae1b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michael5564445 <michaelvelosk@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/uk/
Translation: Servarr/Prowlarr
2024-05-05 13:12:04 +03:00
Mark McDowall
83c6751847 Forward X-Forwarded-Host header
(cherry picked from commit 3fbe4361386e9fb8dafdf82ad9f00f02bec746cc)
2024-05-05 12:29:22 +03:00
Jared
04bb0c51b1 New: Optionally use Environment Variables for settings in config.xml
(cherry picked from commit d051dac12c8b797761a0d1f3b4aa84cff47ed13d)
2024-05-05 11:39:55 +03:00
Bogdan
d2e9621de9 Bump version to 1.17.2 2024-05-05 11:38:40 +03:00
Bogdan
cb673ddc42 New: Host column in history and more info 2024-05-02 22:35:20 +03:00
Bogdan
440618f2b6 Fixed: Initialize databases after app folder migrations
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-05-02 00:21:01 +03:00
Bruno Garcia
ae79d45664 Update Sentry SDK add features
Co-authored-by: Stefan Jandl <reg@bitfox.at>
(cherry picked from commit 6377c688fc7b35749d608bf62796446bb5bcb11b)
2024-05-02 00:07:58 +03:00
Bogdan
1877ccb513 Update Pull Request Labeler config for v5 2024-04-29 14:35:43 +03:00
Bogdan
b3098f2e4c Use newer Node.js task for in pipelines 2024-04-29 14:32:46 +03:00
Bogdan
3e0af062c1 Parameter binding for API requests 2024-04-29 01:24:57 +03:00
Bogdan
858f85c50d Fix translations for proxy validation 2024-04-28 23:49:59 +03:00
Uruk
938848be65 (ci): update action version 2024-04-28 11:42:07 -05:00
Bogdan
615f5899cc Fixed: (TorrentDay) Update base urls and MST
Co-authored-by: garfield69 <garfield69@outlook.com>
2024-04-28 14:57:30 +03:00
Mark McDowall
5a6b1313e8 Validate that folders in paths don't start or end with a space
(cherry picked from commit 316b5cbf75b45ef9a25f96ce1f2fbed25ad94296)
2024-04-28 13:16:08 +03:00
Mark McDowall
ab7debb34b Improve paths longer than 256 on Windows failing to hardlink
(cherry picked from commit a97fbcc40a6247bf59678425cf460588fd4dbecd)
2024-04-28 13:07:52 +03:00
Bogdan
eee21de795 Fixed: Handle download redirects to magnet links 2024-04-28 13:05:13 +03:00
Bogdan
15fabbe7d0 Bump version to 1.17.1 2024-04-28 12:50:04 +03:00
Weblate
6aef48c6e7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: maodun96 <435795439@qq.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2024-04-27 21:15:01 +03:00
168 changed files with 3167 additions and 1567 deletions

28
.github/labeler.yml vendored
View File

@@ -1,19 +1,31 @@
'Area: API':
- src/Prowlarr.Api.V1/**/*
- changed-files:
- any-glob-to-any-file:
- src/Prowlarr.Api.V1/**/*
'Area: Db-migration':
- src/NzbDrone.Core/Datastore/Migration/*
- changed-files:
- any-glob-to-any-file:
- src/NzbDrone.Core/Datastore/Migration/*
'Area: Download Clients':
- src/NzbDrone.Core/Download/Clients/**/*
- changed-files:
- any-glob-to-any-file:
- src/NzbDrone.Core/Download/Clients/**/*
'Area: Indexer':
- src/NzbDrone.Core/Indexers/**/*
- changed-files:
- any-glob-to-any-file:
- src/NzbDrone.Core/Indexers/**/*
'Area: Notifications':
- src/NzbDrone.Core/Notifications/**/*
- changed-files:
- any-glob-to-any-file:
- src/NzbDrone.Core/Notifications/**/*
'Area: UI':
- frontend/**/*
- package.json
- yarn.lock
- changed-files:
- any-glob-to-any-file:
- frontend/**/*
- package.json
- yarn.lock

View File

@@ -9,4 +9,4 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
- uses: actions/labeler@v5

View File

@@ -9,7 +9,7 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v4
- uses: dessant/lock-threads@v5
with:
github-token: ${{ github.token }}
issue-inactive-days: '90'

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '1.17.0'
majorVersion: '1.20.0'
minorVersion: $[counter('minorVersion', 1)]
prowlarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
@@ -20,7 +20,7 @@ variables:
innoVersion: '6.2.2'
windowsImage: 'windows-2022'
linuxImage: 'ubuntu-20.04'
macImage: 'macOS-11'
macImage: 'macOS-12'
trigger:
branches:
@@ -166,10 +166,10 @@ stages:
pool:
vmImage: $(imageName)
steps:
- task: NodeTool@0
- task: UseNode@1
displayName: Set Node.js version
inputs:
versionSpec: $(nodeVersion)
version: $(nodeVersion)
- checkout: self
submodules: true
fetchDepth: 1
@@ -1075,10 +1075,10 @@ stages:
pool:
vmImage: $(imageName)
steps:
- task: NodeTool@0
- task: UseNode@1
displayName: Set Node.js version
inputs:
versionSpec: $(nodeVersion)
version: $(nodeVersion)
- checkout: self
submodules: true
fetchDepth: 1

16
docs.sh Normal file → Executable file
View File

@@ -1,3 +1,7 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1
if [ "$PLATFORM" = "Windows" ]; then
@@ -21,17 +25,23 @@ slnFile=src/Prowlarr.sln
platform=Posix
if [ "$PLATFORM" = "Windows" ]; then
application=Prowlarr.Console.dll
else
application=Prowlarr.dll
fi
dotnet clean $slnFile -c Debug
dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest
dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Prowlarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/prowlarr.console.dll" v1 &
dotnet tool run swagger tofile --output ./src/Prowlarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 &
sleep 30
sleep 45
kill %1

View File

@@ -12,11 +12,10 @@ function App({ store, history }) {
<DocumentTitle title={window.Prowlarr.instanceName}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme>
<PageConnector>
<AppRoutes app={App} />
</PageConnector>
</ApplyTheme>
<ApplyTheme />
<PageConnector>
<AppRoutes app={App} />
</PageConnector>
</ConnectedRouter>
</Provider>
</DocumentTitle>

View File

@@ -1,50 +0,0 @@
import PropTypes from 'prop-types';
import React, { Fragment, useCallback, useEffect } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.ui.item.theme || window.Prowlarr.theme,
(
theme
) => {
return {
theme
};
}
);
}
function ApplyTheme({ theme, children }) {
// Update the CSS Variables
const updateCSSVariables = useCallback(() => {
const arrayOfVariableKeys = Object.keys(themes[theme]);
const arrayOfVariableValues = Object.values(themes[theme]);
// Loop through each array key and set the CSS Variables
arrayOfVariableKeys.forEach((cssVariableKey, index) => {
// Based on our snippet from MDN
document.documentElement.style.setProperty(
`--${cssVariableKey}`,
arrayOfVariableValues[index]
);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables(theme);
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
ApplyTheme.propTypes = {
theme: PropTypes.string.isRequired,
children: PropTypes.object.isRequired
};
export default connect(createMapStateToProps)(ApplyTheme);

View File

@@ -0,0 +1,37 @@
import React, { Fragment, ReactNode, useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import themes from 'Styles/Themes';
import AppState from './State/AppState';
interface ApplyThemeProps {
children: ReactNode;
}
function createThemeSelector() {
return createSelector(
(state: AppState) => state.settings.ui.item.theme || window.Prowlarr.theme,
(theme) => {
return theme;
}
);
}
function ApplyTheme({ children }: ApplyThemeProps) {
const theme = useSelector(createThemeSelector());
const updateCSSVariables = useCallback(() => {
Object.entries(themes[theme]).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value);
});
}, [theme]);
// On Component Mount and Component Update
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables, theme]);
return <Fragment>{children}</Fragment>;
}
export default ApplyTheme;

View File

@@ -10,6 +10,7 @@ class DescriptionListItem extends Component {
render() {
const {
className,
titleClassName,
descriptionClassName,
title,
@@ -17,7 +18,7 @@ class DescriptionListItem extends Component {
} = this.props;
return (
<div>
<div className={className}>
<DescriptionListItemTitle
className={titleClassName}
>
@@ -35,6 +36,7 @@ class DescriptionListItem extends Component {
}
DescriptionListItem.propTypes = {
className: PropTypes.string,
titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string,
title: PropTypes.string,

View File

@@ -271,26 +271,29 @@ class EnhancedSelectInput extends Component {
this.setState({ isOpen: !this.state.isOpen });
};
onSelect = (value) => {
if (Array.isArray(this.props.value)) {
let newValue = null;
const index = this.props.value.indexOf(value);
onSelect = (newValue) => {
const { name, value, values, onChange } = this.props;
if (Array.isArray(value)) {
let arrayValue = null;
const index = value.indexOf(newValue);
if (index === -1) {
newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v));
arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v));
} else {
newValue = [...this.props.value];
newValue.splice(index, 1);
arrayValue = [...value];
arrayValue.splice(index, 1);
}
this.props.onChange({
name: this.props.name,
value: newValue
onChange({
name,
value: arrayValue
});
} else {
this.setState({ isOpen: false });
this.props.onChange({
name: this.props.name,
value
onChange({
name,
value: newValue
});
}
};
@@ -485,7 +488,7 @@ class EnhancedSelectInput extends Component {
values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected = hasParent && value.includes(v.parentKey);
const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey);
return (
<OptionComponent
key={v.key}

View File

@@ -34,7 +34,8 @@ function getSelectOptions(items) {
key: option.value,
value: option.name,
hint: option.hint,
parentKey: option.parentValue
parentKey: option.parentValue,
isDisabled: option.isDisabled
};
});
}

View File

@@ -1,3 +1,7 @@
.validationFailures {
margin-bottom: 20px;
}
.details {
margin-left: 5px;
}

View File

@@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'details': string;
'validationFailures': string;
}
export const cssExports: CssExports;

View File

@@ -1,7 +1,9 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import { kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import styles from './Form.css';
function Form(props) {
@@ -26,6 +28,18 @@ function Form(props) {
kind={kinds.DANGER}
>
{error.errorMessage}
{
error.detailedDescription ?
<Tooltip
className={styles.details}
anchor={<Icon name={icons.INFO} />}
tooltip={error.detailedDescription}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/> :
null
}
</Alert>
);
})
@@ -39,6 +53,18 @@ function Form(props) {
kind={kinds.WARNING}
>
{warning.errorMessage}
{
warning.detailedDescription ?
<Tooltip
className={styles.details}
anchor={<Icon name={icons.INFO} />}
tooltip={warning.detailedDescription}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/> :
null
}
</Alert>
);
})

View File

@@ -256,6 +256,7 @@ FormInputGroup.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
isFloat: PropTypes.bool,
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,

View File

@@ -1,5 +0,0 @@
.input {
composes: input from '~Components/Form/TextInput.css';
font-family: $passwordFamily;
}

View File

@@ -1,7 +1,5 @@
import PropTypes from 'prop-types';
import React from 'react';
import TextInput from './TextInput';
import styles from './PasswordInput.css';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e) {
@@ -13,17 +11,14 @@ function PasswordInput(props) {
return (
<TextInput
{...props}
type="password"
onCopy={onCopy}
/>
);
}
PasswordInput.propTypes = {
className: PropTypes.string.isRequired
};
PasswordInput.defaultProps = {
className: styles.input
...TextInput.props
};
export default PasswordInput;

View File

@@ -25,14 +25,3 @@
font-family: 'Ubuntu Mono';
src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype');
}
/*
* text-security-disc
*/
@font-face {
font-weight: normal;
font-style: normal;
font-family: 'text-security-disc';
src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype');
}

View File

@@ -0,0 +1,7 @@
enum DownloadProtocol {
Unknown = 'unknown',
Usenet = 'usenet',
Torrent = 'torrent',
}
export default DownloadProtocol;

View File

@@ -43,6 +43,7 @@ import {
faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle,
faCircleDown as fasCircleDown,
faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog,
@@ -141,6 +142,7 @@ export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasSquareCheck;
export const CIRCLE = fasCircle;
export const CIRCLE_DOWN = fasCircleDown;
export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt;
export const CLIPBOARD = fasCopy;

View File

@@ -21,6 +21,7 @@ function HistoryDetails(props) {
limit,
offset,
source,
host,
url
} = data;
@@ -86,6 +87,15 @@ function HistoryDetails(props) {
null
}
{
data ?
<DescriptionListItem
title={translate('Host')}
data={host}
/> :
null
}
{
data ?
<DescriptionListItem

View File

@@ -331,6 +331,21 @@ class HistoryRow extends Component {
);
}
if (name === 'host') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{
data.host ?
data.host :
null
}
</TableRowCell>
);
}
if (name === 'elapsedTime') {
return (
<TableRowCell

View File

@@ -4,16 +4,16 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowButton from 'Components/Table/TableRowButton';
import { icons } from 'Helpers/Props';
import CapabilitiesLabel from 'Indexer/Index/Table/CapabilitiesLabel';
import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel';
import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
import { IndexerCapabilities } from 'Indexer/Indexer';
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
import { IndexerCapabilities, IndexerPrivacy } from 'Indexer/Indexer';
import translate from 'Utilities/String/translate';
import styles from './SelectIndexerRow.css';
interface SelectIndexerRowProps {
name: string;
protocol: string;
privacy: string;
privacy: IndexerPrivacy;
language: string;
description: string;
capabilities: IndexerCapabilities;
@@ -63,7 +63,9 @@ function SelectIndexerRow(props: SelectIndexerRowProps) {
<TableRowCell>{description}</TableRowCell>
<TableRowCell>{translate(firstCharToUpper(privacy))}</TableRowCell>
<TableRowCell>
<PrivacyLabel privacy={privacy} />
</TableRowCell>
<TableRowCell>
<CapabilitiesLabel capabilities={capabilities} />

View File

@@ -1,4 +1,10 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
@@ -22,12 +28,17 @@ import AddIndexerModal from 'Indexer/Add/AddIndexerModal';
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
import NoIndexer from 'Indexer/NoIndexer';
import { executeCommand } from 'Store/Actions/commandActions';
import { cloneIndexer, testAllIndexers } from 'Store/Actions/indexerActions';
import {
cloneIndexer,
fetchIndexers,
testAllIndexers,
} from 'Store/Actions/indexerActions';
import {
setIndexerFilter,
setIndexerSort,
setIndexerTableOption,
} from 'Store/Actions/indexerIndexActions';
import { fetchIndexerStatus } from 'Store/Actions/indexerStatusActions';
import scrollPositions from 'Store/scrollPositions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
@@ -82,6 +93,11 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
);
const [isSelectMode, setIsSelectMode] = useState(false);
useEffect(() => {
dispatch(fetchIndexers());
dispatch(fetchIndexerStatus());
}, [dispatch]);
const onAddIndexerPress = useCallback(() => {
setIsAddIndexerModalOpen(true);
}, [setIsAddIndexerModalOpen]);

View File

@@ -224,6 +224,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
name="seedRatio"
value={seedRatio}
helpText={translate('SeedRatioHelpText')}
isFloat={true}
onChange={onInputChange}
/>
</FormGroup>

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
@@ -15,10 +14,10 @@ import createIndexerIndexItemSelector from 'Indexer/Index/createIndexerIndexItem
import Indexer from 'Indexer/Indexer';
import IndexerTitleLink from 'Indexer/IndexerTitleLink';
import { SelectStateInputProps } from 'typings/props';
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
import translate from 'Utilities/String/translate';
import CapabilitiesLabel from './CapabilitiesLabel';
import IndexerStatusCell from './IndexerStatusCell';
import PrivacyLabel from './PrivacyLabel';
import ProtocolLabel from './ProtocolLabel';
import styles from './IndexerIndexRow.css';
@@ -175,7 +174,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
if (name === 'privacy') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<Label>{translate(firstCharToUpper(privacy))}</Label>
<PrivacyLabel privacy={privacy} />
</VirtualTableRowCell>
);
}

View File

@@ -0,0 +1,20 @@
.publicLabel {
composes: label from '~Components/Label.css';
border-color: var(--dangerColor);
background-color: var(--dangerColor);
}
.semiPrivateLabel {
composes: label from '~Components/Label.css';
border-color: var(--warningColor);
background-color: var(--warningColor);
}
.privateLabel {
composes: label from '~Components/Label.css';
border-color: var(--infoColor);
background-color: var(--infoColor);
}

View File

@@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'privateLabel': string;
'publicLabel': string;
'semiPrivateLabel': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Label from 'Components/Label';
import { IndexerPrivacy } from 'Indexer/Indexer';
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
import translate from 'Utilities/String/translate';
import styles from './PrivacyLabel.css';
interface PrivacyLabelProps {
privacy: IndexerPrivacy;
}
function PrivacyLabel({ privacy }: PrivacyLabelProps) {
return (
<Label className={styles[`${privacy}Label`]}>
{translate(firstCharToUpper(privacy))}
</Label>
);
}
export default PrivacyLabel;

View File

@@ -24,6 +24,8 @@ export interface IndexerCapabilities extends ModelBase {
categories: IndexerCategory[];
}
export type IndexerPrivacy = 'public' | 'semiPrivate' | 'private';
export interface IndexerField extends ModelBase {
order: number;
name: string;
@@ -47,7 +49,7 @@ interface Indexer extends ModelBase {
supportsRedirect: boolean;
supportsPagination: boolean;
protocol: string;
privacy: string;
privacy: IndexerPrivacy;
priority: number;
fields: IndexerField[];
tags: number[];

View File

@@ -24,6 +24,7 @@ import TagListConnector from 'Components/TagListConnector';
import { kinds } from 'Helpers/Props';
import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal';
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel';
import Indexer, { IndexerCapabilities } from 'Indexer/Indexer';
import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector';
import translate from 'Utilities/String/translate';
@@ -64,6 +65,7 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
fields,
tags,
protocol,
privacy,
capabilities = {} as IndexerCapabilities,
} = indexer as Indexer;
@@ -160,6 +162,11 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
title={translate('Language')}
data={language ?? '-'}
/>
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('Privacy')}
data={privacy ? <PrivacyLabel privacy={privacy} /> : '-'}
/>
{vipExpiration ? (
<DescriptionListItem
descriptionClassName={styles.description}

View File

@@ -47,3 +47,42 @@ $hoverScale: 1.05;
right: 0;
white-space: nowrap;
}
.downloadLink {
composes: link from '~Components/Link/Link.css';
margin: 0 2px;
width: 22px;
color: var(--textColor);
text-align: center;
}
.manualDownloadContent {
position: relative;
display: inline-block;
margin: 0 2px;
width: 22px;
height: 20.39px;
vertical-align: middle;
line-height: 20.39px;
&:hover {
color: var(--iconButtonHoverColor);
}
}
.interactiveIcon {
position: absolute;
top: 4px;
left: 0;
/* width: 100%; */
text-align: center;
}
.downloadIcon {
position: absolute;
top: 7px;
left: 8px;
/* width: 100%; */
text-align: center;
}

View File

@@ -4,9 +4,13 @@ interface CssExports {
'actions': string;
'container': string;
'content': string;
'downloadIcon': string;
'downloadLink': string;
'indexerRow': string;
'info': string;
'infoRow': string;
'interactiveIcon': string;
'manualDownloadContent': string;
'title': string;
'titleRow': string;
}

View File

@@ -1,234 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons, kinds } from 'Helpers/Props';
import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
import CategoryLabel from 'Search/Table/CategoryLabel';
import Peers from 'Search/Table/Peers';
import dimensions from 'Styles/Variables/dimensions';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './SearchIndexOverview.css';
const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
function getContentHeight(rowHeight, isSmallScreen) {
const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
return rowHeight - padding;
}
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return icons.SPINNER;
} else if (isGrabbed) {
return icons.DOWNLOADING;
} else if (grabError) {
return icons.DOWNLOADING;
}
return icons.DOWNLOAD;
}
function getDownloadKind(isGrabbed, grabError) {
if (isGrabbed) {
return kinds.SUCCESS;
}
if (grabError) {
return kinds.DANGER;
}
return kinds.DEFAULT;
}
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return translate('AddedToDownloadClient');
} else if (grabError) {
return grabError;
}
return translate('AddToDownloadClient');
}
class SearchIndexOverview extends Component {
//
// Listeners
onGrabPress = () => {
const {
guid,
indexerId,
onGrabPress
} = this.props;
onGrabPress({
guid,
indexerId
});
};
//
// Render
render() {
const {
title,
infoUrl,
protocol,
downloadUrl,
magnetUrl,
categories,
seeders,
leechers,
indexerFlags,
size,
age,
ageHours,
ageMinutes,
indexer,
rowHeight,
isSmallScreen,
isGrabbed,
isGrabbing,
grabError
} = this.props;
const contentHeight = getContentHeight(rowHeight, isSmallScreen);
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.info} style={{ height: contentHeight }}>
<div className={styles.titleRow}>
<div className={styles.title}>
<Link
to={infoUrl}
title={title}
>
<TextTruncate
line={2}
text={title}
/>
</Link>
</div>
<div className={styles.actions}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={this.onGrabPress}
/>
{
downloadUrl || magnetUrl ?
<IconButton
name={icons.SAVE}
title={translate('Save')}
to={downloadUrl ?? magnetUrl}
/> :
null
}
</div>
</div>
<div className={styles.indexerRow}>
{indexer}
</div>
<div className={styles.infoRow}>
<ProtocolLabel
protocol={protocol}
/>
{
protocol === 'torrent' &&
<Peers
seeders={seeders}
leechers={leechers}
/>
}
<Label>
{formatBytes(size)}
</Label>
<Label>
{formatAge(age, ageHours, ageMinutes)}
</Label>
<CategoryLabel
categories={categories}
/>
{
indexerFlags.length ?
indexerFlags
.sort((a, b) => a.localeCompare(b))
.map((flag, index) => {
return (
<Label key={index} kind={kinds.INFO}>
{titleCase(flag)}
</Label>
);
}) :
null
}
</div>
</div>
</div>
</div>
);
}
}
SearchIndexOverview.propTypes = {
guid: PropTypes.string.isRequired,
categories: PropTypes.arrayOf(PropTypes.object).isRequired,
protocol: PropTypes.string.isRequired,
age: PropTypes.number.isRequired,
ageHours: PropTypes.number.isRequired,
ageMinutes: PropTypes.number.isRequired,
publishDate: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
infoUrl: PropTypes.string.isRequired,
downloadUrl: PropTypes.string,
magnetUrl: PropTypes.string,
indexerId: PropTypes.number.isRequired,
indexer: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
files: PropTypes.number,
grabs: PropTypes.number,
seeders: PropTypes.number,
leechers: PropTypes.number,
indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired,
rowHeight: PropTypes.number.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onGrabPress: PropTypes.func.isRequired,
isGrabbing: PropTypes.bool.isRequired,
isGrabbed: PropTypes.bool.isRequired,
grabError: PropTypes.string
};
SearchIndexOverview.defaultProps = {
isGrabbing: false,
isGrabbed: false
};
export default SearchIndexOverview;

View File

@@ -0,0 +1,262 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import TextTruncate from 'react-text-truncate';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { icons, kinds } from 'Helpers/Props';
import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
import { IndexerCategory } from 'Indexer/Indexer';
import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal';
import CategoryLabel from 'Search/Table/CategoryLabel';
import Peers from 'Search/Table/Peers';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import dimensions from 'Styles/Variables/dimensions';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './SearchIndexOverview.css';
const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(
dimensions.movieIndexColumnPaddingSmallScreen
);
function getDownloadIcon(
isGrabbing: boolean,
isGrabbed: boolean,
grabError?: string
) {
if (isGrabbing) {
return icons.SPINNER;
} else if (isGrabbed) {
return icons.DOWNLOADING;
} else if (grabError) {
return icons.DOWNLOADING;
}
return icons.DOWNLOAD;
}
function getDownloadKind(isGrabbed: boolean, grabError?: string) {
if (isGrabbed) {
return kinds.SUCCESS;
}
if (grabError) {
return kinds.DANGER;
}
return kinds.DEFAULT;
}
function getDownloadTooltip(
isGrabbing: boolean,
isGrabbed: boolean,
grabError?: string
) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return translate('AddedToDownloadClient');
} else if (grabError) {
return grabError;
}
return translate('AddToDownloadClient');
}
interface SearchIndexOverviewProps {
guid: string;
protocol: DownloadProtocol;
age: number;
ageHours: number;
ageMinutes: number;
publishDate: string;
title: string;
infoUrl: string;
downloadUrl?: string;
magnetUrl?: string;
indexerId: number;
indexer: string;
categories: IndexerCategory[];
size: number;
seeders?: number;
leechers?: number;
indexerFlags: string[];
isGrabbing: boolean;
isGrabbed: boolean;
grabError?: string;
longDateFormat: string;
timeFormat: string;
rowHeight: number;
isSmallScreen: boolean;
onGrabPress(...args: unknown[]): void;
}
function SearchIndexOverview(props: SearchIndexOverviewProps) {
const {
guid,
indexerId,
protocol,
categories,
age,
ageHours,
ageMinutes,
publishDate,
title,
infoUrl,
downloadUrl,
magnetUrl,
indexer,
size,
seeders,
leechers,
indexerFlags = [],
isGrabbing = false,
isGrabbed = false,
grabError,
longDateFormat,
timeFormat,
rowHeight,
isSmallScreen,
onGrabPress,
} = props;
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
const { items: downloadClients } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);
const onGrabPressWrapper = useCallback(() => {
onGrabPress({
guid,
indexerId,
});
}, [guid, indexerId, onGrabPress]);
const onOverridePress = useCallback(() => {
setIsOverrideModalOpen(true);
}, [setIsOverrideModalOpen]);
const onOverrideModalClose = useCallback(() => {
setIsOverrideModalOpen(false);
}, [setIsOverrideModalOpen]);
const contentHeight = useMemo(() => {
const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
return rowHeight - padding;
}, [rowHeight, isSmallScreen]);
return (
<>
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.info} style={{ height: contentHeight }}>
<div className={styles.titleRow}>
<div className={styles.title}>
<Link to={infoUrl} title={title}>
<TextTruncate line={2} text={title} />
</Link>
</div>
<div className={styles.actions}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={onGrabPressWrapper}
/>
{downloadClients.length > 1 ? (
<Link
className={styles.manualDownloadContent}
title={translate('OverrideAndAddToDownloadClient')}
onPress={onOverridePress}
>
<div className={styles.manualDownloadContent}>
<Icon
className={styles.interactiveIcon}
name={icons.INTERACTIVE}
size={11}
/>
<Icon
className={styles.downloadIcon}
name={icons.CIRCLE_DOWN}
size={9}
/>
</div>
</Link>
) : null}
{downloadUrl || magnetUrl ? (
<IconButton
className={styles.downloadLink}
name={icons.SAVE}
title={translate('Save')}
to={downloadUrl ?? magnetUrl}
/>
) : null}
</div>
</div>
<div className={styles.indexerRow}>{indexer}</div>
<div className={styles.infoRow}>
<ProtocolLabel protocol={protocol} />
{protocol === 'torrent' && (
<Peers seeders={seeders} leechers={leechers} />
)}
<Label>{formatBytes(size)}</Label>
<Label
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
includeSeconds: true,
})}
>
{formatAge(age, ageHours, ageMinutes)}
</Label>
<CategoryLabel categories={categories} />
{indexerFlags.length
? indexerFlags
.sort((a, b) => a.localeCompare(b))
.map((flag, index) => {
return (
<Label key={index} kind={kinds.INFO}>
{titleCase(flag)}
</Label>
);
})
: null}
</div>
</div>
</div>
</div>
<OverrideMatchModal
isOpen={isOverrideModalOpen}
title={title}
indexerId={indexerId}
guid={guid}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onOverrideModalClose}
/>
</>
);
}
export default SearchIndexOverview;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { sizes } from 'Helpers/Props';
import SelectDownloadClientModalContent from './SelectDownloadClientModalContent';
interface SelectDownloadClientModalProps {
isOpen: boolean;
protocol: DownloadProtocol;
modalTitle: string;
onDownloadClientSelect(downloadClientId: number): void;
onModalClose(): void;
}
function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
const { isOpen, protocol, modalTitle, onDownloadClientSelect, onModalClose } =
props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
<SelectDownloadClientModalContent
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default SelectDownloadClientModal;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
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 DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { kinds } from 'Helpers/Props';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import translate from 'Utilities/String/translate';
import SelectDownloadClientRow from './SelectDownloadClientRow';
interface SelectDownloadClientModalContentProps {
protocol: DownloadProtocol;
modalTitle: string;
onDownloadClientSelect(downloadClientId: number): void;
onModalClose(): void;
}
function SelectDownloadClientModalContent(
props: SelectDownloadClientModalContentProps
) {
const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props;
const { isFetching, isPopulated, error, items } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('SelectDownloadClientModalTitle', { modalTitle })}
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('DownloadClientsLoadError')}
</Alert>
) : null}
{isPopulated && !error ? (
<Form>
{items.map((downloadClient) => {
const { id, name, priority } = downloadClient;
return (
<SelectDownloadClientRow
key={id}
id={id}
name={name}
priority={priority}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})}
</Form>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default SelectDownloadClientModalContent;

View File

@@ -0,0 +1,6 @@
.downloadClient {
display: flex;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--borderColor);
}

View File

@@ -1,7 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'input': string;
'downloadClient': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,32 @@
import React, { useCallback } from 'react';
import Link from 'Components/Link/Link';
import translate from 'Utilities/String/translate';
import styles from './SelectDownloadClientRow.css';
interface SelectSeasonRowProps {
id: number;
name: string;
priority: number;
onDownloadClientSelect(downloadClientId: number): unknown;
}
function SelectDownloadClientRow(props: SelectSeasonRowProps) {
const { id, name, priority, onDownloadClientSelect } = props;
const onSeasonSelectWrapper = useCallback(() => {
onDownloadClientSelect(id);
}, [id, onDownloadClientSelect]);
return (
<Link
className={styles.downloadClient}
component="div"
onPress={onSeasonSelectWrapper}
>
<div>{name}</div>
<div>{translate('PrioritySettings', { priority })}</div>
</Link>
);
}
export default SelectDownloadClientRow;

View File

@@ -0,0 +1,17 @@
.link {
composes: link from '~Components/Link/Link.css';
width: 100%;
}
.placeholder {
display: inline-block;
margin: -2px 0;
width: 100%;
outline: 2px dashed var(--dangerColor);
outline-offset: -2px;
}
.optional {
outline: 2px dashed var(--gray);
}

View File

@@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'link': string;
'optional': string;
'placeholder': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import React from 'react';
import Link from 'Components/Link/Link';
import styles from './OverrideMatchData.css';
interface OverrideMatchDataProps {
value?: string | number | JSX.Element | JSX.Element[];
isDisabled?: boolean;
isOptional?: boolean;
onPress: () => void;
}
function OverrideMatchData(props: OverrideMatchDataProps) {
const { value, isDisabled = false, isOptional, onPress } = props;
return (
<Link className={styles.link} isDisabled={isDisabled} onPress={onPress}>
{(value == null || (Array.isArray(value) && value.length === 0)) &&
!isDisabled ? (
<span
className={classNames(
styles.placeholder,
isOptional && styles.optional
)}
>
&nbsp;
</span>
) : (
value
)}
</Link>
);
}
export default OverrideMatchData;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { sizes } from 'Helpers/Props';
import OverrideMatchModalContent from './OverrideMatchModalContent';
interface OverrideMatchModalProps {
isOpen: boolean;
title: string;
indexerId: number;
guid: string;
protocol: DownloadProtocol;
isGrabbing: boolean;
grabError?: string;
onModalClose(): void;
}
function OverrideMatchModal(props: OverrideMatchModalProps) {
const {
isOpen,
title,
indexerId,
guid,
protocol,
isGrabbing,
grabError,
onModalClose,
} = props;
return (
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
<OverrideMatchModalContent
title={title}
indexerId={indexerId}
guid={guid}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default OverrideMatchModal;

View File

@@ -0,0 +1,49 @@
.label {
composes: label from '~Components/Label.css';
cursor: pointer;
}
.item {
display: block;
margin-bottom: 5px;
margin-left: 50px;
}
.footer {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
display: flex;
justify-content: space-between;
overflow: hidden;
}
.error {
margin-right: 20px;
color: var(--dangerColor);
word-break: break-word;
}
.buttons {
display: flex;
}
@media only screen and (max-width: $breakpointSmall) {
.item {
margin-left: 0;
}
.footer {
display: block;
}
.error {
margin-right: 0;
margin-bottom: 10px;
}
.buttons {
justify-content: space-between;
flex-grow: 1;
}
}

View File

@@ -0,0 +1,11 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'buttons': string;
'error': string;
'footer': string;
'item': string;
'label': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,150 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
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 DownloadProtocol from 'DownloadClient/DownloadProtocol';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { grabRelease } from 'Store/Actions/releaseActions';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import translate from 'Utilities/String/translate';
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
import OverrideMatchData from './OverrideMatchData';
import styles from './OverrideMatchModalContent.css';
type SelectType = 'select' | 'downloadClient';
interface OverrideMatchModalContentProps {
indexerId: number;
title: string;
guid: string;
protocol: DownloadProtocol;
isGrabbing: boolean;
grabError?: string;
onModalClose(): void;
}
function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
const modalTitle = translate('ManualGrab');
const {
indexerId,
title,
guid,
protocol,
isGrabbing,
grabError,
onModalClose,
} = props;
const [downloadClientId, setDownloadClientId] = useState<number | null>(null);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
const previousIsGrabbing = usePrevious(isGrabbing);
const dispatch = useDispatch();
const { items: downloadClients } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSelectDownloadClientPress = useCallback(() => {
setSelectModalOpen('downloadClient');
}, [setSelectModalOpen]);
const onDownloadClientSelect = useCallback(
(downloadClientId: number) => {
setDownloadClientId(downloadClientId);
setSelectModalOpen(null);
},
[setDownloadClientId, setSelectModalOpen]
);
const onGrabPress = useCallback(() => {
dispatch(
grabRelease({
indexerId,
guid,
downloadClientId,
})
);
}, [indexerId, guid, downloadClientId, dispatch]);
useEffect(() => {
if (!isGrabbing && previousIsGrabbing) {
onModalClose();
}
}, [isGrabbing, previousIsGrabbing, onModalClose]);
useEffect(
() => {
dispatch(fetchDownloadClients());
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('OverrideGrabModalTitle', { title })}
</ModalHeader>
<ModalBody>
<DescriptionList>
{downloadClients.length > 1 ? (
<DescriptionListItem
className={styles.item}
title={translate('DownloadClient')}
data={
<OverrideMatchData
value={
downloadClients.find(
(downloadClient) => downloadClient.id === downloadClientId
)?.name ?? translate('Default')
}
onPress={onSelectDownloadClientPress}
/>
}
/>
) : null}
</DescriptionList>
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.error}>{grabError}</div>
<div className={styles.buttons}>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isGrabbing}
error={grabError}
onPress={onGrabPress}
>
{translate('GrabRelease')}
</SpinnerErrorButton>
</div>
</ModalFooter>
<SelectDownloadClientModal
isOpen={selectModalOpen === 'downloadClient'}
protocol={protocol}
modalTitle={modalTitle}
onDownloadClientSelect={onDownloadClientSelect}
onModalClose={onSelectModalClose}
/>
</ModalContent>
);
}
export default OverrideMatchModalContent;

View File

@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import withScrollPosition from 'Components/withScrollPosition';
import { bulkGrabReleases, cancelFetchReleases, clearReleases, fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions';
import { fetchDownloadClients } from 'Store/Actions/Settings/downloadClients';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createReleaseClientSideCollectionItemsSelector from 'Store/Selectors/createReleaseClientSideCollectionItemsSelector';
import SearchIndex from './SearchIndex';
@@ -55,12 +56,20 @@ function createMapDispatchToProps(dispatch, props) {
dispatchClearReleases() {
dispatch(clearReleases());
},
dispatchFetchDownloadClients() {
dispatch(fetchDownloadClients());
}
};
}
class SearchIndexConnector extends Component {
componentDidMount() {
this.props.dispatchFetchDownloadClients();
}
componentWillUnmount() {
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
@@ -85,6 +94,7 @@ SearchIndexConnector.propTypes = {
onBulkGrabPress: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired,
items: PropTypes.arrayOf(PropTypes.object)
};

View File

@@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { executeCommand } from 'Store/Actions/commandActions';
function createReleaseSelector() {
return createSelector(
@@ -37,10 +36,6 @@ function createMapStateToProps() {
);
}
const mapDispatchToProps = {
dispatchExecuteCommand: executeCommand
};
class SearchIndexItemConnector extends Component {
//
@@ -71,4 +66,4 @@ SearchIndexItemConnector.propTypes = {
component: PropTypes.elementType.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SearchIndexItemConnector);
export default connect(createMapStateToProps, null)(SearchIndexItemConnector);

View File

@@ -67,3 +67,33 @@
color: var(--textColor);
}
.manualDownloadContent {
position: relative;
display: inline-block;
margin: 0 2px;
width: 22px;
height: 20.39px;
vertical-align: middle;
line-height: 20.39px;
&:hover {
color: var(--iconButtonHoverColor);
}
}
.interactiveIcon {
position: absolute;
top: 4px;
left: 0;
/* width: 100%; */
text-align: center;
}
.downloadIcon {
position: absolute;
top: 7px;
left: 8px;
/* width: 100%; */
text-align: center;
}

View File

@@ -6,12 +6,15 @@ interface CssExports {
'category': string;
'cell': string;
'checkInput': string;
'downloadIcon': string;
'downloadLink': string;
'externalLinks': string;
'files': string;
'grabs': string;
'indexer': string;
'indexerFlags': string;
'interactiveIcon': string;
'manualDownloadContent': string;
'peers': string;
'protocol': string;
'size': string;

View File

@@ -1,431 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import CategoryLabel from './CategoryLabel';
import Peers from './Peers';
import ReleaseLinks from './ReleaseLinks';
import styles from './SearchIndexRow.css';
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return icons.SPINNER;
} else if (isGrabbed) {
return icons.DOWNLOADING;
} else if (grabError) {
return icons.DOWNLOADING;
}
return icons.DOWNLOAD;
}
function getDownloadKind(isGrabbed, grabError) {
if (isGrabbed) {
return kinds.SUCCESS;
}
if (grabError) {
return kinds.DANGER;
}
return kinds.DEFAULT;
}
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return translate('AddedToDownloadClient');
} else if (grabError) {
return grabError;
}
return translate('AddToDownloadClient');
}
class SearchIndexRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isConfirmGrabModalOpen: false
};
}
//
// Listeners
onGrabPress = () => {
const {
guid,
indexerId,
onGrabPress
} = this.props;
onGrabPress({
guid,
indexerId
});
};
onSavePress = () => {
const {
downloadUrl,
fileName,
onSavePress
} = this.props;
onSavePress({
downloadUrl,
fileName
});
};
//
// Render
render() {
const {
guid,
protocol,
downloadUrl,
magnetUrl,
categories,
age,
ageHours,
ageMinutes,
publishDate,
title,
infoUrl,
indexer,
size,
files,
grabs,
seeders,
leechers,
imdbId,
tmdbId,
tvdbId,
tvMazeId,
indexerFlags,
columns,
isGrabbing,
isGrabbed,
grabError,
longDateFormat,
timeFormat,
isSelected,
onSelectedChange
} = this.props;
return (
<>
{
columns.map((column) => {
const {
isVisible
} = column;
if (!isVisible) {
return null;
}
if (column.name === 'select') {
return (
<VirtualTableSelectCell
inputClassName={styles.checkInput}
id={guid}
key={column.name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (column.name === 'protocol') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<ProtocolLabel
protocol={protocol}
/>
</VirtualTableRowCell>
);
}
if (column.name === 'age') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
title={formatDateTime(publishDate, longDateFormat, timeFormat, { includeSeconds: true })}
>
{formatAge(age, ageHours, ageMinutes)}
</VirtualTableRowCell>
);
}
if (column.name === 'sortTitle') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<Link
to={infoUrl}
title={title}
>
<div>
{title}
</div>
</Link>
</VirtualTableRowCell>
);
}
if (column.name === 'indexer') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{indexer}
</VirtualTableRowCell>
);
}
if (column.name === 'size') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{formatBytes(size)}
</VirtualTableRowCell>
);
}
if (column.name === 'files') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{files}
</VirtualTableRowCell>
);
}
if (column.name === 'grabs') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{grabs}
</VirtualTableRowCell>
);
}
if (column.name === 'peers') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{
protocol === 'torrent' &&
<Peers
seeders={seeders}
leechers={leechers}
/>
}
</VirtualTableRowCell>
);
}
if (column.name === 'category') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<CategoryLabel
categories={categories}
/>
</VirtualTableRowCell>
);
}
if (column.name === 'indexerFlags') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
{
!!indexerFlags.length &&
<Popover
anchor={
<Icon
name={icons.FLAG}
kind={kinds.PRIMARY}
/>
}
title={translate('IndexerFlags')}
body={
<ul>
{
indexerFlags.map((flag, index) => {
return (
<li key={index}>
{titleCase(flag)}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
/>
}
</VirtualTableRowCell>
);
}
if (column.name === 'actions') {
return (
<VirtualTableRowCell
key={column.name}
className={styles[column.name]}
>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={this.onGrabPress}
/>
{
downloadUrl ?
<IconButton
className={styles.downloadLink}
name={icons.SAVE}
title={translate('Save')}
onPress={this.onSavePress}
/> :
null
}
{
magnetUrl ?
<IconButton
className={styles.downloadLink}
name={icons.MAGNET}
title={translate('Open')}
to={magnetUrl}
/> :
null
}
{
imdbId || tmdbId || tvdbId || tvMazeId ? (
<Popover
anchor={
<Icon
className={styles.externalLinks}
name={icons.EXTERNAL_LINK}
size={12}
/>
}
title={translate('Links')}
body={
<ReleaseLinks
categories={categories}
imdbId={imdbId}
tmdbId={tmdbId}
tvdbId={tvdbId}
tvMazeId={tvMazeId}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
) : null
}
</VirtualTableRowCell>
);
}
return null;
})
}
</>
);
}
}
SearchIndexRow.propTypes = {
guid: PropTypes.string.isRequired,
categories: PropTypes.arrayOf(PropTypes.object).isRequired,
protocol: PropTypes.string.isRequired,
age: PropTypes.number.isRequired,
ageHours: PropTypes.number.isRequired,
ageMinutes: PropTypes.number.isRequired,
publishDate: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
fileName: PropTypes.string.isRequired,
infoUrl: PropTypes.string.isRequired,
downloadUrl: PropTypes.string,
magnetUrl: PropTypes.string,
indexerId: PropTypes.number.isRequired,
indexer: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
files: PropTypes.number,
grabs: PropTypes.number,
seeders: PropTypes.number,
leechers: PropTypes.number,
imdbId: PropTypes.number,
tmdbId: PropTypes.number,
tvdbId: PropTypes.number,
tvMazeId: PropTypes.number,
indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onGrabPress: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
isGrabbing: PropTypes.bool.isRequired,
isGrabbed: PropTypes.bool.isRequired,
grabError: PropTypes.string,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired
};
SearchIndexRow.defaultProps = {
isGrabbing: false,
isGrabbed: false
};
export default SearchIndexRow;

View File

@@ -0,0 +1,395 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import Column from 'Components/Table/Column';
import Popover from 'Components/Tooltip/Popover';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
import { IndexerCategory } from 'Indexer/Indexer';
import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import { SelectStateInputProps } from 'typings/props';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import CategoryLabel from './CategoryLabel';
import Peers from './Peers';
import ReleaseLinks from './ReleaseLinks';
import styles from './SearchIndexRow.css';
function getDownloadIcon(
isGrabbing: boolean,
isGrabbed: boolean,
grabError?: string
) {
if (isGrabbing) {
return icons.SPINNER;
} else if (isGrabbed) {
return icons.DOWNLOADING;
} else if (grabError) {
return icons.DOWNLOADING;
}
return icons.DOWNLOAD;
}
function getDownloadKind(isGrabbed: boolean, grabError?: string) {
if (isGrabbed) {
return kinds.SUCCESS;
}
if (grabError) {
return kinds.DANGER;
}
return kinds.DEFAULT;
}
function getDownloadTooltip(
isGrabbing: boolean,
isGrabbed: boolean,
grabError?: string
) {
if (isGrabbing) {
return '';
} else if (isGrabbed) {
return translate('AddedToDownloadClient');
} else if (grabError) {
return grabError;
}
return translate('AddToDownloadClient');
}
interface SearchIndexRowProps {
guid: string;
protocol: DownloadProtocol;
age: number;
ageHours: number;
ageMinutes: number;
publishDate: string;
title: string;
fileName: string;
infoUrl: string;
downloadUrl?: string;
magnetUrl?: string;
indexerId: number;
indexer: string;
categories: IndexerCategory[];
size: number;
files?: number;
grabs?: number;
seeders?: number;
leechers?: number;
imdbId?: string;
tmdbId?: number;
tvdbId?: number;
tvMazeId?: number;
indexerFlags: string[];
isGrabbing: boolean;
isGrabbed: boolean;
grabError?: string;
longDateFormat: string;
timeFormat: string;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
onGrabPress(...args: unknown[]): void;
onSavePress(...args: unknown[]): void;
}
function SearchIndexRow(props: SearchIndexRowProps) {
const {
guid,
indexerId,
protocol,
categories,
age,
ageHours,
ageMinutes,
publishDate,
title,
fileName,
infoUrl,
downloadUrl,
magnetUrl,
indexer,
size,
files,
grabs,
seeders,
leechers,
imdbId,
tmdbId,
tvdbId,
tvMazeId,
indexerFlags = [],
isGrabbing = false,
isGrabbed = false,
grabError,
longDateFormat,
timeFormat,
columns,
isSelected,
onSelectedChange,
onGrabPress,
onSavePress,
} = props;
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
const { items: downloadClients } = useSelector(
createEnabledDownloadClientsSelector(protocol)
);
const onGrabPressWrapper = useCallback(() => {
onGrabPress({
guid,
indexerId,
});
}, [guid, indexerId, onGrabPress]);
const onSavePressWrapper = useCallback(() => {
onSavePress({
downloadUrl,
fileName,
});
}, [downloadUrl, fileName, onSavePress]);
const onOverridePress = useCallback(() => {
setIsOverrideModalOpen(true);
}, [setIsOverrideModalOpen]);
const onOverrideModalClose = useCallback(() => {
setIsOverrideModalOpen(false);
}, [setIsOverrideModalOpen]);
return (
<>
{columns.map((column) => {
const { name, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'select') {
return (
<VirtualTableSelectCell
inputClassName={styles.checkInput}
id={guid}
key={name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (name === 'protocol') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<ProtocolLabel protocol={protocol} />
</VirtualTableRowCell>
);
}
if (name === 'age') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
title={formatDateTime(publishDate, longDateFormat, timeFormat, {
includeSeconds: true,
})}
>
{formatAge(age, ageHours, ageMinutes)}
</VirtualTableRowCell>
);
}
if (name === 'sortTitle') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<Link to={infoUrl} title={title}>
<div>{title}</div>
</Link>
</VirtualTableRowCell>
);
}
if (name === 'indexer') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{indexer}
</VirtualTableRowCell>
);
}
if (name === 'size') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{formatBytes(size)}
</VirtualTableRowCell>
);
}
if (name === 'files') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{files}
</VirtualTableRowCell>
);
}
if (name === 'grabs') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{grabs}
</VirtualTableRowCell>
);
}
if (name === 'peers') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{protocol === 'torrent' && (
<Peers seeders={seeders} leechers={leechers} />
)}
</VirtualTableRowCell>
);
}
if (name === 'category') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<CategoryLabel categories={categories} />
</VirtualTableRowCell>
);
}
if (name === 'indexerFlags') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{!!indexerFlags.length && (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
title={translate('IndexerFlags')}
body={
<ul>
{indexerFlags.map((flag, index) => {
return <li key={index}>{titleCase(flag)}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
/>
)}
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={onGrabPressWrapper}
/>
{downloadClients.length > 1 ? (
<Link
className={styles.manualDownloadContent}
title={translate('OverrideAndAddToDownloadClient')}
onPress={onOverridePress}
>
<div className={styles.manualDownloadContent}>
<Icon
className={styles.interactiveIcon}
name={icons.INTERACTIVE}
size={12}
/>
<Icon
className={styles.downloadIcon}
name={icons.CIRCLE_DOWN}
size={10}
/>
</div>
</Link>
) : null}
{downloadUrl ? (
<IconButton
className={styles.downloadLink}
name={icons.SAVE}
title={translate('Save')}
onPress={onSavePressWrapper}
/>
) : null}
{magnetUrl ? (
<IconButton
className={styles.downloadLink}
name={icons.MAGNET}
title={translate('Open')}
to={magnetUrl}
/>
) : null}
{imdbId || tmdbId || tvdbId || tvMazeId ? (
<Popover
anchor={
<Icon
className={styles.externalLinks}
name={icons.EXTERNAL_LINK}
size={12}
/>
}
title={translate('Links')}
body={
<ReleaseLinks
categories={categories}
imdbId={imdbId}
tmdbId={tmdbId}
tvdbId={tvdbId}
tvMazeId={tvMazeId}
/>
}
position={tooltipPositions.TOP}
/>
) : null}
</VirtualTableRowCell>
);
}
return null;
})}
<OverrideMatchModal
isOpen={isOverrideModalOpen}
title={title}
indexerId={indexerId}
guid={guid}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onOverrideModalClose}
/>
</>
);
}
export default SearchIndexRow;

View File

@@ -82,6 +82,12 @@ export const defaultState = {
isSortable: false,
isVisible: false
},
{
name: 'host',
label: () => translate('Host'),
isSortable: false,
isVisible: false
},
{
name: 'elapsedTime',
label: () => translate('ElapsedTime'),

View File

@@ -0,0 +1,22 @@
import { createSelector } from 'reselect';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName';
export default function createEnabledDownloadClientsSelector(
protocol: DownloadProtocol
) {
return createSelector(
createSortedSectionSelector('settings.downloadClients', sortByName),
(downloadClients: DownloadClientAppState) => {
const { isFetching, isPopulated, error, items } = downloadClients;
const clients = items.filter(
(item) => item.protocol === protocol && item.enable
);
return { isFetching, isPopulated, error, items: clients };
}
);
}

View File

@@ -188,7 +188,7 @@ module.exports = {
// Charts
chartBackgroundColor: '#262626',
failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'],
chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'],
chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26']
failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','),
chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','),
chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',')
};

View File

@@ -2,7 +2,7 @@ import * as dark from './dark';
import * as light from './light';
const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const auto = defaultDark ? { ...dark } : { ...light };
const auto = defaultDark ? dark : light;
export default {
auto,

View File

@@ -188,7 +188,7 @@ module.exports = {
// Charts
chartBackgroundColor: '#fff',
failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'],
chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'],
chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26']
failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','),
chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','),
chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',')
};

View File

@@ -2,7 +2,6 @@ module.exports = {
// Families
defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
passwordFamily: 'text-security-disc',
// Sizes
extraSmallFontSize: '11px',

View File

@@ -7,10 +7,10 @@ function formatBytes(input) {
return '';
}
return filesize(size, {
return `${filesize(size, {
base: 2,
round: 1
});
})}`;
}
export default formatBytes;

View File

@@ -57,8 +57,8 @@
<style>
body {
background-color: #f5f7fa;
color: #656565;
background-color: var(--pageBackground);
color: var(--textColor);
font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial,
sans-serif;
}
@@ -88,14 +88,14 @@
padding: 10px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
background-color: #464b51;
background-color: var(--themeDarkColor);
}
.panel-body {
padding: 20px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fff;
background-color: var(--panelBackground);
}
.sign-in {
@@ -112,16 +112,18 @@
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid #dde6e9;
background-color: var(--inputBackgroundColor);
border: 1px solid var(--inputBorderColor);
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor);
color: var(--textColor);
}
.form-input:focus {
outline: 0;
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px rgba(102, 175, 233, 0.6);
border-color: var(--inputFocusBorderColor);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor),
0 0 8px var(--inputFocusBoxShadowColor);
}
.button {
@@ -130,10 +132,10 @@
padding: 10px 0;
width: 100%;
border: 1px solid;
border-color: #5899eb;
border-color: var(--primaryBorderColor);
border-radius: 4px;
background-color: #5d9cec;
color: #fff;
background-color: var(--primaryBackgroundColor);
color: var(--white);
vertical-align: middle;
text-align: center;
white-space: nowrap;
@@ -141,9 +143,9 @@
}
.button:hover {
border-color: #3483e7;
background-color: #4b91ea;
color: #fff;
border-color: var(--primaryHoverBorderColor);
background-color: var(--primaryHoverBackgroundColor);
color: var(--white);
text-decoration: none;
}
@@ -165,24 +167,24 @@
.forgot-password {
margin-left: auto;
color: #909fa7;
color: var(--forgotPasswordColor);
text-decoration: none;
font-size: 13px;
}
.forgot-password:focus,
.forgot-password:hover {
color: #748690;
color: var(--forgotPasswordAltColor);
text-decoration: underline;
}
.forgot-password:visited {
color: #748690;
color: var(--forgotPasswordAltColor);
}
.login-failed {
margin-top: 20px;
color: #f05050;
color: var(--failedColor);
font-size: 14px;
}
@@ -291,5 +293,59 @@
loginFailedDiv.classList.remove("hidden");
}
var light = {
white: '#fff',
pageBackground: '#f5f7fa',
textColor: '#515253',
themeDarkColor: '#464b51',
panelBackground: '#fff',
inputBackgroundColor: '#fff',
inputBorderColor: '#dde6e9',
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
inputFocusBorderColor: '#66afe9',
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
primaryBackgroundColor: '#5d9cec',
primaryBorderColor: '#5899eb',
primaryHoverBackgroundColor: '#4b91ea',
primaryHoverBorderColor: '#3483e7',
failedColor: '#f05050',
forgotPasswordColor: '#909fa7',
forgotPasswordAltColor: '#748690'
};
var dark = {
white: '#fff',
pageBackground: '#202020',
textColor: '#ccc',
themeDarkColor: '#494949',
panelBackground: '#111',
inputBackgroundColor: '#333',
inputBorderColor: '#dde6e9',
inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
inputFocusBorderColor: '#66afe9',
inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
primaryBackgroundColor: '#5d9cec',
primaryBorderColor: '#5899eb',
primaryHoverBackgroundColor: '#4b91ea',
primaryHoverBorderColor: '#3483e7',
failedColor: '#f05050',
forgotPasswordColor: '#737d83',
forgotPasswordAltColor: '#546067'
};
var theme = "_THEME_";
var defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var finalTheme = theme === 'dark' || (theme === 'auto' && defaultDark) ?
dark :
light;
Object.entries(finalTheme).forEach(([key, value]) => {
document.documentElement.style.setProperty(
`--${key}`,
value
);
});
</script>
</html>

View File

@@ -1,4 +1,5 @@
export interface UiSettings {
theme: 'auto' | 'dark' | 'light';
showRelativeDates: boolean;
shortDateFormat: string;
longDateFormat: string;

View File

@@ -30,8 +30,8 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@juggle/resize-observer": "3.4.0",
"@microsoft/signalr": "6.0.25",
"@sentry/browser": "7.51.2",
"@sentry/integrations": "7.51.2",
"@sentry/browser": "7.100.0",
"@sentry/integrations": "7.100.0",
"@types/node": "18.19.31",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",

View File

@@ -99,20 +99,52 @@
<RootNamespace Condition="'$(ProwlarrProject)'=='true'">$(MSBuildProjectName.Replace('Prowlarr','NzbDrone'))</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(ProwlarrProject)'=='true' and '$(EnableAnalyzers)'=='false'">
<!-- FXCop Built into Net5 SDK now as NETAnalyzers, Enabled by default on net5 projects -->
<EnableNETAnalyzers>false</EnableNETAnalyzers>
<ItemGroup Condition="'$(TestProject)'!='true'">
<!-- Annotates .NET assemblies with repository information including SHA -->
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<!-- Sentry specific configuration: Only in Release mode -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
<!-- OrgSlug, ProjectSlug and AuthToken are required.
They can be set below, via argument to 'msbuild -p:' or environment variable -->
<SentryOrg></SentryOrg>
<SentryProject></SentryProject>
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
without the need to deploy the application with PDBs -->
<SentryUploadSymbols>true</SentryUploadSymbols>
<!-- Source Link settings -->
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
<EmbedAllSources>true</EmbedAllSources>
</PropertyGroup>
<!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
</ItemGroup>
<ItemGroup Condition="'$(TestProject)'=='true' and '$(TargetFramework)'=='net6.0'">
<PackageReference Include="coverlet.collector" Version="3.0.4-preview.27.ge7cb7c3b40" />
</ItemGroup>
<PropertyGroup Condition="'$(ProwlarrProject)'=='true' and '$(EnableAnalyzers)'=='false'">
<!-- FXCop Built into Net5 SDK now as NETAnalyzers, Enabled by default on net5 projects -->
<EnableNETAnalyzers>false</EnableNETAnalyzers>
</PropertyGroup>
<!-- Set up stylecop -->
<ItemGroup Condition="'$(ProwlarrProject)'=='true' and '$(EnableAnalyzers)'!='false'">
<!-- StyleCop analysis -->

View File

@@ -1,10 +1,12 @@
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Options;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using NzbDrone.Test.Common;
@@ -43,6 +45,26 @@ namespace NzbDrone.Common.Test
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.WriteAllText(configFile, It.IsAny<string>()))
.Callback<string, string>((p, t) => _configFileContents = t);
Mocker.GetMock<IOptions<AuthOptions>>()
.Setup(v => v.Value)
.Returns(new AuthOptions());
Mocker.GetMock<IOptions<AppOptions>>()
.Setup(v => v.Value)
.Returns(new AppOptions());
Mocker.GetMock<IOptions<ServerOptions>>()
.Setup(v => v.Value)
.Returns(new ServerOptions());
Mocker.GetMock<IOptions<LogOptions>>()
.Setup(v => v.Value)
.Returns(new LogOptions());
Mocker.GetMock<IOptions<UpdateOptions>>()
.Setup(v => v.Value)
.Returns(new UpdateOptions());
}
[Test]

View File

@@ -4,6 +4,7 @@ using System.Linq;
using FluentAssertions;
using NLog;
using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Sentry;
using NzbDrone.Test.Common;
@@ -26,7 +27,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
[SetUp]
public void Setup()
{
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111");
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object);
}
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)

View File

@@ -3,6 +3,7 @@ using System.IO;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Test.Common;
@@ -34,7 +35,7 @@ namespace NzbDrone.Common.Test
[TestCase(@"\\Testserver\\Test\", @"\\Testserver\Test")]
[TestCase(@"\\Testserver\Test\file.ext", @"\\Testserver\Test\file.ext")]
[TestCase(@"\\Testserver\Test\file.ext\\", @"\\Testserver\Test\file.ext")]
[TestCase(@"\\Testserver\Test\file.ext \\", @"\\Testserver\Test\file.ext")]
[TestCase(@"\\Testserver\Test\file.ext ", @"\\Testserver\Test\file.ext")]
[TestCase(@"//CAPITAL//lower// ", @"\\CAPITAL\lower")]
public void Clean_Path_Windows(string dirty, string clean)
{
@@ -315,5 +316,30 @@ namespace NzbDrone.Common.Test
result[2].Should().Be(@"TV");
result[3].Should().Be(@"Series Title");
}
[TestCase(@"C:\Test\")]
[TestCase(@"C:\Test")]
[TestCase(@"C:\Test\TV\")]
[TestCase(@"C:\Test\TV")]
public void IsPathValid_should_be_true(string path)
{
path.AsOsAgnostic().IsPathValid(PathValidationType.CurrentOs).Should().BeTrue();
}
[TestCase(@"C:\Test \")]
[TestCase(@"C:\Test ")]
[TestCase(@"C:\ Test\")]
[TestCase(@"C:\ Test")]
[TestCase(@"C:\Test \TV")]
[TestCase(@"C:\ Test\TV")]
[TestCase(@"C:\Test \TV\")]
[TestCase(@"C:\ Test\TV\")]
[TestCase(@" C:\Test\TV\")]
[TestCase(@" C:\Test\TV")]
public void IsPathValid_should_be_false(string path)
{
path.AsOsAgnostic().IsPathValid(PathValidationType.CurrentOs).Should().BeFalse();
}
}
}

View File

@@ -10,6 +10,7 @@ using NUnit.Framework;
using NzbDrone.Common.Composition.Extensions;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Common.Options;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore.Extensions;
using NzbDrone.Core.Lifecycle;
@@ -29,10 +30,16 @@ namespace NzbDrone.Common.Test
.AddNzbDroneLogger()
.AutoAddServices(Bootstrap.ASSEMBLIES)
.AddDummyDatabase()
.AddDummyLogDatabase()
.AddStartupContext(new StartupContext("first", "second"));
container.RegisterInstance(new Mock<IHostLifetime>().Object);
container.RegisterInstance(new Mock<IOptions<PostgresOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<AppOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<AuthOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<ServerOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<LogOptions>>().Object);
container.RegisterInstance(new Mock<IOptions<UpdateOptions>>().Object);
var serviceProvider = container.GetServiceProvider();

View File

@@ -153,7 +153,11 @@ namespace NzbDrone.Common.Disk
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
return Directory.EnumerateDirectories(path);
return Directory.EnumerateDirectories(path, "*", new EnumerationOptions
{
AttributesToSkip = FileAttributes.System,
IgnoreInaccessible = true
});
}
public IEnumerable<string> GetFiles(string path, bool recursive)

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnsureThat;
@@ -28,6 +29,12 @@ namespace NzbDrone.Common.Extensions
public static string CleanFilePath(this string path)
{
if (path.IsNotNullOrWhiteSpace())
{
// Trim trailing spaces before checking if the path is valid so validation doesn't fail for something we can fix.
path = path.TrimEnd(' ');
}
Ensure.That(path, () => path).IsNotNullOrWhiteSpace();
Ensure.That(path, () => path).IsValidPath(PathValidationType.AnyOs);
@@ -36,10 +43,10 @@ namespace NzbDrone.Common.Extensions
// UNC
if (!info.FullName.Contains('/') && info.FullName.StartsWith(@"\\"))
{
return info.FullName.TrimEnd('/', '\\', ' ');
return info.FullName.TrimEnd('/', '\\');
}
return info.FullName.TrimEnd('/').Trim('\\', ' ');
return info.FullName.TrimEnd('/').Trim('\\');
}
public static bool PathNotEquals(this string firstPath, string secondPath, StringComparison? comparison = null)
@@ -143,6 +150,23 @@ namespace NzbDrone.Common.Extensions
return false;
}
if (path.Trim() != path)
{
return false;
}
var directoryInfo = new DirectoryInfo(path);
while (directoryInfo != null)
{
if (directoryInfo.Name.Trim() != directoryInfo.Name)
{
return false;
}
directoryInfo = directoryInfo.Parent;
}
if (validationType == PathValidationType.AnyOs)
{
return IsPathValidForWindows(path) || IsPathValidForNonWindows(path);
@@ -253,6 +277,11 @@ namespace NzbDrone.Common.Extensions
return processName;
}
public static string CleanPath(this string path)
{
return Path.Join(path.Split(Path.DirectorySeparatorChar).Select(s => s.Trim()).ToArray());
}
public static string GetAppDataPath(this IAppFolderInfo appFolderInfo)
{
return appFolderInfo.AppDataFolder;

View File

@@ -41,7 +41,7 @@ namespace NzbDrone.Common.Instrumentation
RegisterDebugger();
}
RegisterSentry(updateApp);
RegisterSentry(updateApp, appFolderInfo);
if (updateApp)
{
@@ -62,7 +62,7 @@ namespace NzbDrone.Common.Instrumentation
LogManager.ReconfigExistingLoggers();
}
private static void RegisterSentry(bool updateClient)
private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo)
{
string dsn;
@@ -77,7 +77,7 @@ namespace NzbDrone.Common.Instrumentation
: "https://e38306161ff945999adf774a16e933c3@sentry.servarr.com/30";
}
var target = new SentryTarget(dsn)
var target = new SentryTarget(dsn, appFolderInfo)
{
Name = "sentryTarget",
Layout = "${message}"

View File

@@ -106,7 +106,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
public bool FilterEvents { get; set; }
public bool SentryEnabled { get; set; }
public SentryTarget(string dsn)
public SentryTarget(string dsn, IAppFolderInfo appFolderInfo)
{
_sdk = SentrySdk.Init(o =>
{
@@ -114,9 +114,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry
o.AttachStacktrace = true;
o.MaxBreadcrumbs = 200;
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x));
o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x));
o.Environment = BuildInfo.Branch;
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
o.AutoSessionTracking = true;
// Caches files in the event device is offline
// Sentry creates a 'sentry' sub directory, no need to concat here
o.CacheDirectoryPath = appFolderInfo.GetAppDataPath();
// default environment is production
if (!RuntimeInfo.IsProduction)
{
if (RuntimeInfo.IsDevelopment)
{
o.Environment = "development";
}
else if (RuntimeInfo.IsTesting)
{
o.Environment = "testing";
}
else
{
o.Environment = "other";
}
}
});
InitializeScope();
@@ -134,7 +158,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
{
SentrySdk.ConfigureScope(scope =>
{
scope.User = new User
scope.User = new SentryUser
{
Id = HashUtil.AnonymousToken()
};
@@ -317,13 +341,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry
}
}
var level = LoggingLevelMap[logEvent.Level];
var sentryEvent = new SentryEvent(logEvent.Exception)
{
Level = LoggingLevelMap[logEvent.Level],
Level = level,
Logger = logEvent.LoggerName,
Message = logEvent.FormattedMessage
};
if (level is SentryLevel.Fatal && logEvent.Exception is not null)
{
// Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with
// the 'unhandled' exception flag
logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false);
}
sentryEvent.SetExtras(extras);
sentryEvent.SetFingerprint(fingerPrint);

View File

@@ -0,0 +1,8 @@
namespace NzbDrone.Common.Options;
public class AppOptions
{
public string InstanceName { get; set; }
public string Theme { get; set; }
public bool? LaunchBrowser { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace NzbDrone.Common.Options;
public class AuthOptions
{
public string ApiKey { get; set; }
public bool? Enabled { get; set; }
public string Method { get; set; }
public string Required { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace NzbDrone.Common.Options;
public class LogOptions
{
public string Level { get; set; }
public bool? FilterSentryEvents { get; set; }
public int? Rotate { get; set; }
public bool? Sql { get; set; }
public string ConsoleLevel { get; set; }
public bool? AnalyticsEnabled { get; set; }
public string SyslogServer { get; set; }
public int? SyslogPort { get; set; }
public string SyslogLevel { get; set; }
public bool? DbEnabled { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace NzbDrone.Common.Options;
public class ServerOptions
{
public string UrlBase { get; set; }
public string BindAddress { get; set; }
public int? Port { get; set; }
public bool? EnableSsl { get; set; }
public int? SslPort { get; set; }
public string SslCertPath { get; set; }
public string SslCertPassword { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace NzbDrone.Common.Options;
public class UpdateOptions
{
public string Mechanism { get; set; }
public bool? Automatically { get; set; }
public string ScriptPath { get; set; }
public string Branch { get; set; }
}

View File

@@ -10,8 +10,8 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.2.0" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.0" />
<PackageReference Include="Npgsql" Version="7.0.6" />
<PackageReference Include="Sentry" Version="3.29.1" />
<PackageReference Include="Npgsql" Version="7.0.7" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

View File

@@ -10,9 +10,9 @@ namespace NzbDrone.Core.Applications
void DeleteAllForApp(int appId);
}
public class TagRepository : BasicRepository<AppIndexerMap>, IAppIndexerMapRepository
public class AppIndexerMapRepository : BasicRepository<AppIndexerMap>, IAppIndexerMapRepository
{
public TagRepository(IMainDatabase database, IEventAggregator eventAggregator)
public AppIndexerMapRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}

View File

@@ -10,6 +10,7 @@ using NzbDrone.Common.Cache;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Options;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Datastore;
@@ -53,13 +54,14 @@ namespace NzbDrone.Core.Configuration
string SyslogServer { get; }
int SyslogPort { get; }
string SyslogLevel { get; }
bool LogDbEnabled { get; }
string Theme { get; }
string PostgresHost { get; }
int PostgresPort { get; }
string PostgresUser { get; }
string PostgresPassword { get; }
string PostgresMainDb { get; }
string PostgresLogDb { get; }
string Theme { get; }
}
public class ConfigFileProvider : IConfigFileProvider
@@ -72,6 +74,11 @@ namespace NzbDrone.Core.Configuration
private readonly IDiskProvider _diskProvider;
private readonly ICached<string> _cache;
private readonly PostgresOptions _postgresOptions;
private readonly AuthOptions _authOptions;
private readonly AppOptions _appOptions;
private readonly ServerOptions _serverOptions;
private readonly UpdateOptions _updateOptions;
private readonly LogOptions _logOptions;
private readonly string _configFile;
private static readonly Regex HiddenCharacterRegex = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
@@ -82,13 +89,23 @@ namespace NzbDrone.Core.Configuration
ICacheManager cacheManager,
IEventAggregator eventAggregator,
IDiskProvider diskProvider,
IOptions<PostgresOptions> postgresOptions)
IOptions<PostgresOptions> postgresOptions,
IOptions<AuthOptions> authOptions,
IOptions<AppOptions> appOptions,
IOptions<ServerOptions> serverOptions,
IOptions<UpdateOptions> updateOptions,
IOptions<LogOptions> logOptions)
{
_cache = cacheManager.GetCache<string>(GetType());
_eventAggregator = eventAggregator;
_diskProvider = diskProvider;
_configFile = appFolderInfo.GetConfigPath();
_postgresOptions = postgresOptions.Value;
_authOptions = authOptions.Value;
_appOptions = appOptions.Value;
_serverOptions = serverOptions.Value;
_updateOptions = updateOptions.Value;
_logOptions = logOptions.Value;
}
public Dictionary<string, object> GetConfigDictionary()
@@ -144,7 +161,7 @@ namespace NzbDrone.Core.Configuration
{
const string defaultValue = "*";
var bindAddress = GetValue("BindAddress", defaultValue);
var bindAddress = _serverOptions.BindAddress ?? GetValue("BindAddress", defaultValue);
if (string.IsNullOrWhiteSpace(bindAddress))
{
return defaultValue;
@@ -154,19 +171,19 @@ namespace NzbDrone.Core.Configuration
}
}
public int Port => GetValueInt("Port", DEFAULT_PORT);
public int Port => _serverOptions.Port ?? GetValueInt("Port", DEFAULT_PORT);
public int SslPort => GetValueInt("SslPort", DEFAULT_SSL_PORT);
public int SslPort => _serverOptions.SslPort ?? GetValueInt("SslPort", DEFAULT_SSL_PORT);
public bool EnableSsl => GetValueBoolean("EnableSsl", false);
public bool EnableSsl => _serverOptions.EnableSsl ?? GetValueBoolean("EnableSsl", false);
public bool LaunchBrowser => GetValueBoolean("LaunchBrowser", true);
public bool LaunchBrowser => _appOptions.LaunchBrowser ?? GetValueBoolean("LaunchBrowser", true);
public string ApiKey
{
get
{
var apiKey = GetValue("ApiKey", GenerateApiKey());
var apiKey = _authOptions.ApiKey ?? GetValue("ApiKey", GenerateApiKey());
if (apiKey.IsNullOrWhiteSpace())
{
@@ -182,7 +199,7 @@ namespace NzbDrone.Core.Configuration
{
get
{
var enabled = GetValueBoolean("AuthenticationEnabled", false, false);
var enabled = _authOptions.Enabled ?? GetValueBoolean("AuthenticationEnabled", false, false);
if (enabled)
{
@@ -190,37 +207,45 @@ namespace NzbDrone.Core.Configuration
return AuthenticationType.Basic;
}
return GetValueEnum("AuthenticationMethod", AuthenticationType.None);
return Enum.TryParse<AuthenticationType>(_authOptions.Method, out var enumValue)
? enumValue
: GetValueEnum("AuthenticationMethod", AuthenticationType.None);
}
}
public AuthenticationRequiredType AuthenticationRequired => GetValueEnum("AuthenticationRequired", AuthenticationRequiredType.Enabled);
public AuthenticationRequiredType AuthenticationRequired =>
Enum.TryParse<AuthenticationRequiredType>(_authOptions.Required, out var enumValue)
? enumValue
: GetValueEnum("AuthenticationRequired", AuthenticationRequiredType.Enabled);
public bool AnalyticsEnabled => GetValueBoolean("AnalyticsEnabled", true, persist: false);
public bool AnalyticsEnabled => _logOptions.AnalyticsEnabled ?? GetValueBoolean("AnalyticsEnabled", true, persist: false);
// TODO: Change back to "master" for the first stable release.
public string Branch => GetValue("Branch", "master").ToLowerInvariant();
public string Branch => _updateOptions.Branch ?? GetValue("Branch", "master").ToLowerInvariant();
public string LogLevel => _logOptions.Level ?? GetValue("LogLevel", "info").ToLowerInvariant();
public string ConsoleLogLevel => _logOptions.ConsoleLevel ?? GetValue("ConsoleLogLevel", string.Empty, persist: false);
public string Theme => _appOptions.Theme ?? GetValue("Theme", "auto", persist: false);
public string LogLevel => GetValue("LogLevel", "info").ToLowerInvariant();
public string ConsoleLogLevel => GetValue("ConsoleLogLevel", string.Empty, persist: false);
public string PostgresHost => _postgresOptions?.Host ?? GetValue("PostgresHost", string.Empty, persist: false);
public string PostgresUser => _postgresOptions?.User ?? GetValue("PostgresUser", string.Empty, persist: false);
public string PostgresPassword => _postgresOptions?.Password ?? GetValue("PostgresPassword", string.Empty, persist: false);
public string PostgresMainDb => _postgresOptions?.MainDb ?? GetValue("PostgresMainDb", "prowlarr-main", persist: false);
public string PostgresLogDb => _postgresOptions?.LogDb ?? GetValue("PostgresLogDb", "prowlarr-log", persist: false);
public int PostgresPort => (_postgresOptions?.Port ?? 0) != 0 ? _postgresOptions.Port : GetValueInt("PostgresPort", 5432, persist: false);
public string Theme => GetValue("Theme", "auto", persist: false);
public bool LogSql => GetValueBoolean("LogSql", false, persist: false);
public int LogRotate => GetValueInt("LogRotate", 50, persist: false);
public bool FilterSentryEvents => GetValueBoolean("FilterSentryEvents", true, persist: false);
public string SslCertPath => GetValue("SslCertPath", "");
public string SslCertPassword => GetValue("SslCertPassword", "");
public bool LogDbEnabled => _logOptions.DbEnabled ?? GetValueBoolean("LogDbEnabled", true, persist: false);
public bool LogSql => _logOptions.Sql ?? GetValueBoolean("LogSql", false, persist: false);
public int LogRotate => _logOptions.Rotate ?? GetValueInt("LogRotate", 50, persist: false);
public bool FilterSentryEvents => _logOptions.FilterSentryEvents ?? GetValueBoolean("FilterSentryEvents", true, persist: false);
public string SslCertPath => _serverOptions.SslCertPath ?? GetValue("SslCertPath", "");
public string SslCertPassword => _serverOptions.SslCertPassword ?? GetValue("SslCertPassword", "");
public string UrlBase
{
get
{
var urlBase = GetValue("UrlBase", "").Trim('/');
var urlBase = (_serverOptions.UrlBase ?? GetValue("UrlBase", "")).Trim('/');
if (urlBase.IsNullOrWhiteSpace())
{
@@ -232,19 +257,36 @@ namespace NzbDrone.Core.Configuration
}
public string UiFolder => BuildInfo.IsDebug ? Path.Combine("..", "UI") : "UI";
public string InstanceName => GetValue("InstanceName", BuildInfo.AppName);
public bool UpdateAutomatically => GetValueBoolean("UpdateAutomatically", false, false);
public string InstanceName
{
get
{
var instanceName = _appOptions.InstanceName ?? GetValue("InstanceName", BuildInfo.AppName);
public UpdateMechanism UpdateMechanism => GetValueEnum("UpdateMechanism", UpdateMechanism.BuiltIn, false);
if (instanceName.ContainsIgnoreCase(BuildInfo.AppName))
{
return instanceName;
}
public string UpdateScriptPath => GetValue("UpdateScriptPath", "", false);
return BuildInfo.AppName;
}
}
public string SyslogServer => GetValue("SyslogServer", "", persist: false);
public bool UpdateAutomatically => _updateOptions.Automatically ?? GetValueBoolean("UpdateAutomatically", false, false);
public int SyslogPort => GetValueInt("SyslogPort", 514, persist: false);
public UpdateMechanism UpdateMechanism =>
Enum.TryParse<UpdateMechanism>(_updateOptions.Mechanism, out var enumValue)
? enumValue
: GetValueEnum("UpdateMechanism", UpdateMechanism.BuiltIn, false);
public string SyslogLevel => GetValue("SyslogLevel", LogLevel, false).ToLowerInvariant();
public string UpdateScriptPath => _updateOptions.ScriptPath ?? GetValue("UpdateScriptPath", "", false);
public string SyslogServer => _logOptions.SyslogServer ?? GetValue("SyslogServer", "", persist: false);
public int SyslogPort => _logOptions.SyslogPort ?? GetValueInt("SyslogPort", 514, persist: false);
public string SyslogLevel => _logOptions.SyslogLevel ?? GetValue("SyslogLevel", LogLevel, persist: false).ToLowerInvariant();
public int GetValueInt(string key, int defaultValue, bool persist = true)
{
@@ -277,13 +319,13 @@ namespace NzbDrone.Core.Configuration
return valueHolder.First().Value.Trim();
}
//Save the value
// Save the value
if (persist)
{
SetValue(key, defaultValue);
}
//return the default value
// return the default value
return defaultValue.ToString();
});
}
@@ -380,13 +422,21 @@ namespace NzbDrone.Core.Configuration
throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Prowlarr will recreate it.");
}
return XDocument.Parse(_diskProvider.ReadAllText(_configFile));
var xDoc = XDocument.Parse(_diskProvider.ReadAllText(_configFile));
var config = xDoc.Descendants(CONFIG_ELEMENT_NAME).ToList();
if (config.Count != 1)
{
throw new InvalidConfigFileException($"{_configFile} is invalid. Please delete the config file and Prowlarr will recreate it.");
}
return xDoc;
}
var xDoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
xDoc.Add(new XElement(CONFIG_ELEMENT_NAME));
var newXDoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
newXDoc.Add(new XElement(CONFIG_ELEMENT_NAME));
return xDoc;
return newXDoc;
}
}
catch (XmlException ex)

View File

@@ -8,6 +8,12 @@ namespace NzbDrone.Core.Datastore.Extensions
public static IContainer AddDatabase(this IContainer container)
{
container.RegisterDelegate<IDbFactory, IMainDatabase>(f => new MainDatabase(f.Create()), Reuse.Singleton);
return container;
}
public static IContainer AddLogDatabase(this IContainer container)
{
container.RegisterDelegate<IDbFactory, ILogDatabase>(f => new LogDatabase(f.Create(MigrationType.Log)), Reuse.Singleton);
return container;
@@ -16,6 +22,12 @@ namespace NzbDrone.Core.Datastore.Extensions
public static IContainer AddDummyDatabase(this IContainer container)
{
container.RegisterInstance<IMainDatabase>(new MainDatabase(null));
return container;
}
public static IContainer AddDummyLogDatabase(this IContainer container)
{
container.RegisterInstance<ILogDatabase>(new LogDatabase(null));
return container;

View File

@@ -1,10 +1,8 @@
using System;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Common.TPL;
using NzbDrone.Core.Download.Clients;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Indexers;
@@ -27,7 +25,6 @@ namespace NzbDrone.Core.Download
private readonly IDownloadClientStatusService _downloadClientStatusService;
private readonly IIndexerFactory _indexerFactory;
private readonly IIndexerStatusService _indexerStatusService;
private readonly IRateLimitService _rateLimitService;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
@@ -35,7 +32,6 @@ namespace NzbDrone.Core.Download
IDownloadClientStatusService downloadClientStatusService,
IIndexerFactory indexerFactory,
IIndexerStatusService indexerStatusService,
IRateLimitService rateLimitService,
IEventAggregator eventAggregator,
Logger logger)
{
@@ -43,7 +39,6 @@ namespace NzbDrone.Core.Download
_downloadClientStatusService = downloadClientStatusService;
_indexerFactory = indexerFactory;
_indexerStatusService = indexerStatusService;
_rateLimitService = rateLimitService;
_eventAggregator = eventAggregator;
_logger = logger;
}
@@ -132,15 +127,7 @@ namespace NzbDrone.Core.Download
_logger.Trace("Attempting download of {0}", link);
var url = new Uri(link);
// Limit grabs to 2 per second.
if (link.IsNotNullOrWhiteSpace() && !link.StartsWith("magnet:"))
{
await _rateLimitService.WaitAndPulseAsync(url.Host, TimeSpan.FromSeconds(2));
}
var indexer = _indexerFactory.GetInstance(_indexerFactory.Get(indexerId));
var success = false;
var downloadedBytes = Array.Empty<byte>();
var release = new ReleaseInfo
{
@@ -151,12 +138,14 @@ namespace NzbDrone.Core.Download
DownloadProtocol = indexer.Protocol
};
var grabEvent = new IndexerDownloadEvent(release, success, source, host, release.Title, release.DownloadUrl)
var grabEvent = new IndexerDownloadEvent(release, false, source, host, release.Title, release.DownloadUrl)
{
Indexer = indexer,
GrabTrigger = source == "Prowlarr" ? GrabTrigger.Manual : GrabTrigger.Api
};
byte[] downloadedBytes;
try
{
downloadedBytes = await indexer.Download(url);

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.Definitions.Cardigann;
@@ -9,12 +10,12 @@ namespace NzbDrone.Core.HealthCheck.Checks
{
[CheckOn(typeof(ProviderDeletedEvent<IIndexer>))]
[CheckOn(typeof(ProviderBulkDeletedEvent<IIndexer>))]
public class NoDefinitionCheck : HealthCheckBase
public class IndexerNoDefinitionCheck : HealthCheckBase
{
private readonly IIndexerDefinitionUpdateService _indexerDefinitionUpdateService;
private readonly IIndexerFactory _indexerFactory;
public NoDefinitionCheck(IIndexerDefinitionUpdateService indexerDefinitionUpdateService, IIndexerFactory indexerFactory, ILocalizationService localizationService)
public IndexerNoDefinitionCheck(IIndexerDefinitionUpdateService indexerDefinitionUpdateService, IIndexerFactory indexerFactory, ILocalizationService localizationService)
: base(localizationService)
{
_indexerDefinitionUpdateService = indexerDefinitionUpdateService;
@@ -23,23 +24,22 @@ namespace NzbDrone.Core.HealthCheck.Checks
public override HealthCheck Check()
{
var currentDefs = _indexerDefinitionUpdateService.All();
var currentDefinitions = _indexerDefinitionUpdateService.All();
var noDefinitionIndexers = _indexerFactory.AllProviders(false)
.Where(i => i.Definition.Implementation == "Cardigann" && currentDefinitions.All(d => d.File != ((CardigannSettings)i.Definition.Settings).DefinitionFile))
.ToList();
var noDefIndexers = _indexerFactory.AllProviders(false)
.Where(i => i.Definition.Implementation == "Cardigann" && currentDefs.All(d => d.File != ((CardigannSettings)i.Definition.Settings).DefinitionFile)).ToList();
if (noDefIndexers.Count == 0)
if (noDefinitionIndexers.Count == 0)
{
return new HealthCheck(GetType());
}
var healthType = HealthCheckResult.Error;
var healthMessage = string.Format(_localizationService.GetLocalizedString("IndexerNoDefCheckMessage"),
string.Join(", ", noDefIndexers.Select(v => v.Definition.Name)));
return new HealthCheck(GetType(),
healthType,
healthMessage,
HealthCheckResult.Error,
_localizationService.GetLocalizedString("IndexerNoDefinitionCheckHealthCheckMessage", new Dictionary<string, object>
{
{ "indexerNames", string.Join(", ", noDefinitionIndexers.Select(v => v.Definition.Name).ToArray()) }
}),
"#indexers-have-no-definition");
}

View File

@@ -190,16 +190,16 @@ namespace NzbDrone.Core.IndexerProxies.FlareSolverr
if (response.StatusCode != HttpStatusCode.OK)
{
_logger.Error("Proxy Health Check failed: {0}", response.StatusCode);
failures.Add(new NzbDroneValidationFailure("Host", string.Format(_localizationService.GetLocalizedString("ProxyCheckBadRequestMessage"), response.StatusCode)));
_logger.Error("Proxy validation failed: {0}", response.StatusCode);
failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationBadRequest", new Dictionary<string, object> { { "statusCode", response.StatusCode } })));
}
var result = JsonConvert.DeserializeObject<FlareSolverrResponse>(response.Content);
}
catch (Exception ex)
{
_logger.Error(ex, "Proxy Health Check failed");
failures.Add(new NzbDroneValidationFailure("Host", string.Format(_localizationService.GetLocalizedString("ProxyCheckFailedToTestMessage"), request.Url.Host)));
_logger.Error(ex, "Proxy validation failed");
failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationUnableToConnect", new Dictionary<string, object> { { "exceptionMessage", ex.Message } })));
}
return new ValidationResult(failures);

View File

@@ -41,14 +41,14 @@ namespace NzbDrone.Core.IndexerProxies
// 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)));
_logger.Error("Proxy validation failed: {0}", response.StatusCode);
failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationBadRequest", new Dictionary<string, object> { { "statusCode", response.StatusCode } })));
}
}
catch (Exception ex)
{
_logger.Error(ex, "Proxy Health Check failed");
failures.Add(new NzbDroneValidationFailure("Host", string.Format("Failed to test proxy: {0}", ex.Message)));
_logger.Error(ex, "Proxy validation failed");
failures.Add(new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("ProxyValidationUnableToConnect", new Dictionary<string, object> { { "exceptionMessage", ex.Message } })));
}
return new ValidationResult(failures);

View File

@@ -31,9 +31,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions
!IsIdSearch;
public override bool IsIdSearch =>
Episode.IsNotNullOrWhiteSpace() ||
ImdbId.IsNotNullOrWhiteSpace() ||
Season.HasValue ||
TvdbId.HasValue ||
RId.HasValue ||
TraktId.HasValue ||

View File

@@ -26,11 +26,15 @@ namespace NzbDrone.Core.IndexerStats
{
var history = _historyService.Between(start, end);
var filteredHistory = history.Where(h => indexerIds.Contains(h.IndexerId));
var filteredHistory = history.Where(h => indexerIds.Contains(h.IndexerId)).ToArray();
var groupedByIndexer = filteredHistory.GroupBy(h => h.IndexerId);
var groupedByUserAgent = filteredHistory.GroupBy(h => h.Data.GetValueOrDefault("source") ?? "");
var groupedByHost = filteredHistory.GroupBy(h => h.Data.GetValueOrDefault("host") ?? "");
var groupedByIndexer = filteredHistory.GroupBy(h => h.IndexerId).ToArray();
var groupedByUserAgent = filteredHistory
.Where(h => h.EventType != HistoryEventType.IndexerAuth)
.GroupBy(h => h.Data.GetValueOrDefault("source") ?? "").ToArray();
var groupedByHost = filteredHistory
.Where(h => h.EventType != HistoryEventType.IndexerAuth)
.GroupBy(h => h.Data.GetValueOrDefault("host") ?? "").ToArray();
var indexerStatsList = new List<IndexerStatistics>();
var userAgentStatsList = new List<UserAgentStatistics>();
@@ -60,7 +64,7 @@ namespace NzbDrone.Core.IndexerStats
var temp = 0;
var elapsedTimeEvents = sortedEvents
.Where(h => int.TryParse(h.Data.GetValueOrDefault("elapsedTime"), out temp) && h.Data.GetValueOrDefault("cached") != "1")
.Select(h => temp)
.Select(_ => temp)
.ToArray();
indexerStats.AverageResponseTime = elapsedTimeEvents.Any() ? (int)elapsedTimeEvents.Average() : 0;
@@ -68,6 +72,7 @@ namespace NzbDrone.Core.IndexerStats
foreach (var historyEvent in sortedEvents)
{
var failed = !historyEvent.Successful;
switch (historyEvent.EventType)
{
case HistoryEventType.IndexerQuery:
@@ -101,8 +106,6 @@ namespace NzbDrone.Core.IndexerStats
indexerStats.NumberOfFailedRssQueries++;
}
break;
default:
break;
}
}
@@ -118,8 +121,8 @@ namespace NzbDrone.Core.IndexerStats
};
var sortedEvents = indexer.OrderBy(v => v.Date)
.ThenBy(v => v.Id)
.ToArray();
.ThenBy(v => v.Id)
.ToArray();
foreach (var historyEvent in sortedEvents)
{
@@ -128,13 +131,10 @@ namespace NzbDrone.Core.IndexerStats
case HistoryEventType.IndexerRss:
case HistoryEventType.IndexerQuery:
indexerStats.NumberOfQueries++;
break;
case HistoryEventType.ReleaseGrabbed:
indexerStats.NumberOfGrabs++;
break;
default:
break;
}
}
@@ -149,8 +149,8 @@ namespace NzbDrone.Core.IndexerStats
};
var sortedEvents = indexer.OrderBy(v => v.Date)
.ThenBy(v => v.Id)
.ToArray();
.ThenBy(v => v.Id)
.ToArray();
foreach (var historyEvent in sortedEvents)
{
@@ -163,8 +163,6 @@ namespace NzbDrone.Core.IndexerStats
case HistoryEventType.ReleaseGrabbed:
indexerStats.NumberOfGrabs++;
break;
default:
break;
}
}

View File

@@ -29,7 +29,7 @@ namespace NzbDrone.Core.IndexerVersions
/* Update Service will fall back if version # does not exist for an indexer per Ta */
private const string DEFINITION_BRANCH = "master";
private const int DEFINITION_VERSION = 9;
private const int DEFINITION_VERSION = 11;
// Used when moving yml to C#
private readonly List<string> _definitionBlocklist = new ()

View File

@@ -22,7 +22,7 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
private readonly ICached<CardigannRequestGenerator> _generatorCache;
public override string Name => "Cardigann";
public override string[] IndexerUrls => new string[] { "" };
public override string[] IndexerUrls => new[] { "" };
public override string Description => "";
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
@@ -58,10 +58,11 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
Settings = Settings
});
generator = (CardigannRequestGenerator)SetCookieFunctions(generator);
generator.Definition = Definition;
generator.Settings = Settings;
generator = (CardigannRequestGenerator)SetCookieFunctions(generator);
_generatorCache.ClearExpired();
return generator;

View File

@@ -139,20 +139,13 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
{
var selectorSelector = ApplyGoTemplateText(selector.Selector, variables);
if (dom.Matches(selectorSelector))
{
selection = dom;
}
else
{
selection = QuerySelector(dom, selectorSelector);
}
selection = dom.Matches(selectorSelector) ? dom : QuerySelector(dom, selectorSelector);
if (selection == null)
{
if (required)
{
throw new Exception(string.Format("Selector \"{0}\" didn't match {1}", selectorSelector, dom.ToHtmlPretty()));
throw new Exception($"Selector \"{selectorSelector}\" didn't match {dom.ToHtmlPretty()}");
}
return null;
@@ -195,7 +188,7 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
{
if (required)
{
throw new Exception(string.Format("Attribute \"{0}\" is not set for element {1}", selector.Attribute, selection.ToHtmlPretty()));
throw new Exception($"Attribute \"{selector.Attribute}\" is not set for element {selection.ToHtmlPretty()}");
}
return null;
@@ -337,9 +330,12 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
variables[name] = selected.Key;
break;
case "info":
variables[name] = value;
break;
case "info_cookie":
case "info_flaresolverr":
case "info_useragent":
case "info_category_8000":
case "cardigannCaptcha":
// no-op
break;
default:
throw new NotSupportedException($"Type {setting.Type} is not supported.");

View File

@@ -332,37 +332,47 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
}
// selector inputs
if (login.Selectorinputs != null)
if (login.Selectorinputs != null && login.Selectorinputs.Any())
{
foreach (var selectorinput in login.Selectorinputs)
foreach (var selectorInput in login.Selectorinputs)
{
string value = null;
try
{
value = HandleSelector(selectorinput.Value, landingResultDocument.FirstElementChild);
pairs[selectorinput.Key] = value;
var value = HandleSelector(selectorInput.Value, landingResultDocument.FirstElementChild, required: !selectorInput.Value.Optional);
if (selectorInput.Value.Optional && value == null)
{
continue;
}
pairs[selectorInput.Key] = value;
}
catch (Exception ex)
{
throw new CardigannException(string.Format("Error while parsing selector input={0}, selector={1}, value={2}: {3}", selectorinput.Key, selectorinput.Value.Selector, value, ex.Message));
throw new CardigannException($"Error while parsing selector input={selectorInput.Key}, selector={selectorInput.Value.Selector}: {ex.Message}", ex);
}
}
}
// getselector inputs
if (login.Getselectorinputs != null)
if (login.Getselectorinputs != null && login.Getselectorinputs.Any())
{
foreach (var selectorinput in login.Getselectorinputs)
foreach (var selectorInput in login.Getselectorinputs)
{
string value = null;
try
{
value = HandleSelector(selectorinput.Value, landingResultDocument.FirstElementChild);
queryCollection[selectorinput.Key] = value;
var value = HandleSelector(selectorInput.Value, landingResultDocument.FirstElementChild, required: !selectorInput.Value.Optional);
if (selectorInput.Value.Optional && value == null)
{
continue;
}
queryCollection[selectorInput.Key] = value;
}
catch (Exception ex)
{
throw new CardigannException(string.Format("Error while parsing get selector input={0}, selector={1}, value={2}: {3}", selectorinput.Key, selectorinput.Value.Selector, value, ex.Message));
throw new CardigannException($"Error while parsing get selector input={selectorInput.Key}, selector={selectorInput.Value.Selector}: {ex.Message}", ex);
}
}
}
@@ -1178,14 +1188,14 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
if (method == HttpMethod.Get && searchUrls.Contains(searchUrl))
{
_logger.Trace("Skip duplicated request {0}", searchUrl);
_logger.Trace("Skip duplicated request for {0}: {1}", Definition.Name, searchUrl);
continue;
}
searchUrls.Add(searchUrl);
_logger.Debug($"Adding request: {searchUrl}");
_logger.Debug("Adding request for {0}: {1}", Definition.Name, searchUrl);
var requestBuilder = new HttpRequestBuilder(searchUrl)
{

View File

@@ -61,7 +61,7 @@ public class FileListParser : IParseIndexerResponse
var imdbId = 0;
if (row.ImdbId is { Length: > 2 })
{
imdbId = int.Parse(row.ImdbId.Substring(2));
int.TryParse(row.ImdbId.TrimStart('t'), out imdbId);
}
var downloadVolumeFactor = row.FreeLeech ? 0 : 1;

View File

@@ -20,6 +20,7 @@ using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.Indexers.Definitions
{
[Obsolete("Moved to YML for Cardigann")]
public class HDSpace : TorrentIndexerBase<HDSpaceSettings>
{
public override string Name => "HD-Space";

View File

@@ -70,16 +70,17 @@ namespace NzbDrone.Core.Indexers.Headphones
protected override bool PostProcess(IndexerResponse indexerResponse, List<XElement> items, List<ReleaseInfo> releases)
{
var enclosureTypes = items.SelectMany(GetEnclosures).Select(v => v.Type).Distinct().ToArray();
if (enclosureTypes.Any() && enclosureTypes.Intersect(PreferredEnclosureMimeTypes).Empty())
{
if (enclosureTypes.Intersect(TorrentEnclosureMimeTypes).Any())
{
_logger.Warn("Feed does not contain {0}, found {1}, did you intend to add a Torznab indexer?", NzbEnclosureMimeType, enclosureTypes[0]);
}
else
{
_logger.Warn("Feed does not contain {0}, found {1}.", NzbEnclosureMimeType, enclosureTypes[0]);
_logger.Warn("{0} does not contain {1}, found {2}, did you intend to add a Torznab indexer?", indexerResponse.Request.Url, NzbEnclosureMimeType, enclosureTypes[0]);
return false;
}
_logger.Warn("{0} does not contain {1}, found {2}.", indexerResponse.Request.Url, NzbEnclosureMimeType, enclosureTypes[0]);
}
return true;

View File

@@ -51,12 +51,12 @@ public class MTeamTp : TorrentIndexerBase<MTeamTpSettings>
public override IIndexerRequestGenerator GetRequestGenerator()
{
return new MTeamTpRequestGenerator(Settings, Capabilities);
return new MTeamTpRequestGenerator(Settings, Capabilities, BuildApiUrl(Settings));
}
public override IParseIndexerResponse GetParser()
{
return new MTeamTpParser(Settings, Capabilities.Categories);
return new MTeamTpParser(Settings, Capabilities.Categories, BuildApiUrl(Settings));
}
public override async Task<byte[]> Download(Uri link)
@@ -168,19 +168,26 @@ public class MTeamTp : TorrentIndexerBase<MTeamTpSettings>
return caps;
}
private static string BuildApiUrl(IIndexerSettings settings)
{
return $"https://api.{settings.BaseUrl.AsSpan(settings.BaseUrl.IndexOf('.') + 1)}";
}
}
public class MTeamTpRequestGenerator : IIndexerRequestGenerator
{
private readonly MTeamTpSettings _settings;
private readonly IndexerCapabilities _capabilities;
private readonly string _apiUrl;
private readonly int[] _trackerAdultCategories = { 410, 429, 424, 430, 426, 437, 431, 432, 436, 425, 433, 411, 412, 413, 440 };
public MTeamTpRequestGenerator(MTeamTpSettings settings, IndexerCapabilities capabilities)
public MTeamTpRequestGenerator(MTeamTpSettings settings, IndexerCapabilities capabilities, string apiUrl)
{
_settings = settings;
_capabilities = capabilities;
_apiUrl = apiUrl;
}
public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria)
@@ -253,7 +260,7 @@ public class MTeamTpRequestGenerator : IIndexerRequestGenerator
private IndexerRequest BuildSearchRequest(MTeamTpRequestType requestType, IEnumerable<int> categoryMapping, string searchTerm, string imdbId)
{
var request = new HttpRequestBuilder(_settings.BaseUrl)
var request = new HttpRequestBuilder(_apiUrl)
.Resource("/api/torrent/search")
.SetHeader("x-api-key", _settings.ApiKey)
.Accept(HttpAccept.Json)
@@ -299,11 +306,13 @@ public class MTeamTpParser : IParseIndexerResponse
{
private readonly MTeamTpSettings _settings;
private readonly IndexerCapabilitiesCategories _categories;
private readonly string _apiUrl;
public MTeamTpParser(MTeamTpSettings settings, IndexerCapabilitiesCategories categories)
public MTeamTpParser(MTeamTpSettings settings, IndexerCapabilitiesCategories categories, string apiUrl)
{
_settings = settings;
_categories = categories;
_apiUrl = apiUrl;
}
public IList<ReleaseInfo> ParseResponse(IndexerResponse indexerResponse)
@@ -390,7 +399,7 @@ public class MTeamTpParser : IParseIndexerResponse
private string GetDownloadUrl(int torrentId)
{
var url = new HttpUri(_settings.BaseUrl)
var url = new HttpUri(_apiUrl)
.CombinePath("/api/torrent/genDlToken")
.AddQueryParam("id", torrentId);

View File

@@ -387,14 +387,14 @@ namespace NzbDrone.Core.Indexers.Definitions
throw new IndexerException(indexerResponse, $"Unexpected response header {indexerResponse.HttpResponse.Headers.ContentType} from indexer request, expected {HttpAccept.Json.Value}");
}
var torrentInfos = new List<TorrentInfo>();
var releaseInfos = new List<ReleaseInfo>();
var jsonResponse = JsonConvert.DeserializeObject<MyAnonamouseResponse>(indexerResponse.Content);
var error = jsonResponse.Error;
if (error is "Nothing returned, out of 0" or "Nothing returned, out of 1")
if (error.IsNotNullOrWhiteSpace() && error.StartsWithIgnoreCase("Nothing returned, out of"))
{
return torrentInfos.ToArray();
return releaseInfos.ToArray();
}
var hasUserVip = HasUserVip();
@@ -462,10 +462,10 @@ namespace NzbDrone.Core.Indexers.Definitions
release.MinimumRatio = 1;
release.MinimumSeedTime = 259200; // 72 hours
torrentInfos.Add(release);
releaseInfos.Add(release);
}
return torrentInfos.ToArray();
return releaseInfos.ToArray();
}
private bool HasUserVip()

View File

@@ -228,10 +228,21 @@ namespace NzbDrone.Core.Indexers.Definitions
if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK)
{
throw new IndexerException(indexerResponse, $"Unexpected response status {indexerResponse.HttpResponse.StatusCode} code from indexer request");
throw new IndexerException(indexerResponse, "Unexpected response status '{0}' code from indexer request", indexerResponse.HttpResponse.StatusCode);
}
var jsonResponse = STJson.Deserialize<JsonRpcResponse<NebulanceTorrents>>(indexerResponse.HttpResponse.Content);
JsonRpcResponse<NebulanceTorrents> jsonResponse;
try
{
jsonResponse = STJson.Deserialize<JsonRpcResponse<NebulanceTorrents>>(indexerResponse.HttpResponse.Content);
}
catch (Exception ex)
{
STJson.TryDeserialize<JsonRpcResponse<string>>(indexerResponse.HttpResponse.Content, out var response);
throw new IndexerException(indexerResponse, "Unexpected response from indexer request: {0}", ex, response?.Result ?? ex.Message);
}
if (jsonResponse.Error != null || jsonResponse.Result == null)
{

View File

@@ -74,16 +74,17 @@ namespace NzbDrone.Core.Indexers.Newznab
protected override bool PostProcess(IndexerResponse indexerResponse, List<XElement> items, List<ReleaseInfo> releases)
{
var enclosureTypes = items.SelectMany(GetEnclosures).Select(v => v.Type).Distinct().ToArray();
if (enclosureTypes.Any() && enclosureTypes.Intersect(PreferredEnclosureMimeTypes).Empty())
{
if (enclosureTypes.Intersect(TorrentEnclosureMimeTypes).Any())
{
_logger.Warn("Feed does not contain {0}, found {1}, did you intend to add a Torznab indexer?", NzbEnclosureMimeType, enclosureTypes[0]);
}
else
{
_logger.Warn("Feed does not contain {0}, found {1}.", NzbEnclosureMimeType, enclosureTypes[0]);
_logger.Warn("{0} does not contain {1}, found {2}, did you intend to add a Torznab indexer?", indexerResponse.Request.Url, NzbEnclosureMimeType, enclosureTypes[0]);
return false;
}
_logger.Warn("{0} does not contain {1}, found {2}.", indexerResponse.Request.Url, NzbEnclosureMimeType, enclosureTypes[0]);
}
return true;

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