mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2026-04-16 21:35:04 -04:00
Compare commits
97 Commits
v1.16.2.44
...
v1.21.0.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bada440d2 | ||
|
|
803c4752db | ||
|
|
c0777474c0 | ||
|
|
66dcea5604 | ||
|
|
a2a12d2450 | ||
|
|
39593bd5a8 | ||
|
|
45d8a8a4e6 | ||
|
|
a4546c77ce | ||
|
|
d69bf6360a | ||
|
|
da9ce5b5c3 | ||
|
|
e092098101 | ||
|
|
1a89a79b74 | ||
|
|
cb6bf49922 | ||
|
|
4bcaba0be0 | ||
|
|
220ef723c7 | ||
|
|
9c599a6be4 | ||
|
|
715ce1fc6c | ||
|
|
8c3a192dd0 | ||
|
|
d22bf93dfd | ||
|
|
886054fdf8 | ||
|
|
4188510586 | ||
|
|
fedebca5e1 | ||
|
|
e2ce6437e9 | ||
|
|
bdae60bac9 | ||
|
|
2d6c818aec | ||
|
|
a1d19852dc | ||
|
|
104c95f28f | ||
|
|
55fa1ec637 | ||
|
|
b27a3d8272 | ||
|
|
089d450b46 | ||
|
|
358ac7434d | ||
|
|
9cd505fd8a | ||
|
|
20ac2687df | ||
|
|
9f075c09a2 | ||
|
|
3793538ba4 | ||
|
|
4c4b16d234 | ||
|
|
f5790bec2e | ||
|
|
6c0d08de56 | ||
|
|
ba344756b1 | ||
|
|
dfda86aca3 | ||
|
|
df6f83ed69 | ||
|
|
218d92a1ac | ||
|
|
df2b529d01 | ||
|
|
0ef42dbb4d | ||
|
|
1a428197b2 | ||
|
|
09d7983845 | ||
|
|
6e01f3187a | ||
|
|
468436b9f7 | ||
|
|
76c288a6e4 | ||
|
|
f95f67a7ca | ||
|
|
11864247eb | ||
|
|
74509ea7c9 | ||
|
|
948fe0a6dc | ||
|
|
a4257cbcde | ||
|
|
2929c3c898 | ||
|
|
2c5f2187c8 | ||
|
|
401ef88971 | ||
|
|
4fb3754048 | ||
|
|
596efe8fb0 | ||
|
|
076a4f2574 | ||
|
|
9561371a47 | ||
|
|
16254cf5f9 | ||
|
|
649a03e5a0 | ||
|
|
dd21d9b521 | ||
|
|
68b895d2ad | ||
|
|
634016ae1b | ||
|
|
83c6751847 | ||
|
|
04bb0c51b1 | ||
|
|
d2e9621de9 | ||
|
|
cb673ddc42 | ||
|
|
440618f2b6 | ||
|
|
ae79d45664 | ||
|
|
1877ccb513 | ||
|
|
b3098f2e4c | ||
|
|
3e0af062c1 | ||
|
|
858f85c50d | ||
|
|
938848be65 | ||
|
|
615f5899cc | ||
|
|
5a6b1313e8 | ||
|
|
ab7debb34b | ||
|
|
eee21de795 | ||
|
|
15fabbe7d0 | ||
|
|
6aef48c6e7 | ||
|
|
b29bc923fc | ||
|
|
b223e9b0cc | ||
|
|
77a982a7da | ||
|
|
ab3dc765b4 | ||
|
|
0261201360 | ||
|
|
1da3954879 | ||
|
|
742dd5ff54 | ||
|
|
a85406e3b7 | ||
|
|
73cdaf3d44 | ||
|
|
e26fa2dbf4 | ||
|
|
64be68a22d | ||
|
|
478a185968 | ||
|
|
4ff5d11a03 | ||
|
|
6000952b76 |
28
.github/labeler.yml
vendored
28
.github/labeler.yml
vendored
@@ -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
|
||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -9,4 +9,4 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
- uses: actions/labeler@v5
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -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'
|
||||
|
||||
@@ -9,18 +9,18 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '1.16.2'
|
||||
majorVersion: '1.21.0'
|
||||
minorVersion: $[counter('minorVersion', 1)]
|
||||
prowlarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
|
||||
sentryOrg: 'servarr'
|
||||
sentryUrl: 'https://sentry.servarr.com'
|
||||
dotnetVersion: '6.0.417'
|
||||
dotnetVersion: '6.0.421'
|
||||
nodeVersion: '20.X'
|
||||
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
|
||||
@@ -1169,7 +1169,7 @@ stages:
|
||||
submodules: true
|
||||
- powershell: Set-Service SCardSvr -StartupType Manual
|
||||
displayName: Enable Windows Test Service
|
||||
- task: SonarCloudPrepare@1
|
||||
- task: SonarCloudPrepare@2
|
||||
condition: eq(variables['System.PullRequest.IsFork'], 'False')
|
||||
inputs:
|
||||
SonarCloud: 'SonarCloud'
|
||||
@@ -1187,21 +1187,16 @@ stages:
|
||||
./build.sh --backend -f net6.0 -r win-x64
|
||||
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
|
||||
displayName: Coverage Unit Tests
|
||||
- task: SonarCloudAnalyze@1
|
||||
- task: SonarCloudAnalyze@2
|
||||
condition: eq(variables['System.PullRequest.IsFork'], 'False')
|
||||
displayName: Publish SonarCloud Results
|
||||
- task: reportgenerator@4
|
||||
- task: reportgenerator@5
|
||||
displayName: Generate Coverage Report
|
||||
inputs:
|
||||
reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml'
|
||||
targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined'
|
||||
reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges'
|
||||
- task: PublishCodeCoverageResults@1
|
||||
displayName: Publish Coverage Report
|
||||
inputs:
|
||||
codeCoverageTool: 'cobertura'
|
||||
summaryFileLocation: './CoverageResults/combined/Cobertura.xml'
|
||||
reportDirectory: './CoverageResults/combined/'
|
||||
publishCodeCoverageResults: true
|
||||
|
||||
- stage: Report_Out
|
||||
dependsOn:
|
||||
|
||||
16
docs.sh
Normal file → Executable file
16
docs.sh
Normal file → Executable 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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
37
frontend/src/App/ApplyTheme.tsx
Normal file
37
frontend/src/App/ApplyTheme.tsx
Normal 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;
|
||||
@@ -42,7 +42,16 @@ export interface CustomFilter {
|
||||
filers: PropertyFilter[];
|
||||
}
|
||||
|
||||
export interface AppSectionState {
|
||||
dimensions: {
|
||||
isSmallScreen: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
app: AppSectionState;
|
||||
commands: CommandAppState;
|
||||
history: HistoryAppState;
|
||||
indexerHistory: IndexerHistoryAppState;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.validationFailures {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
1
frontend/src/Components/Form/Form.css.d.ts
vendored
1
frontend/src/Components/Form/Form.css.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.input {
|
||||
composes: input from '~Components/Form/TextInput.css';
|
||||
|
||||
font-family: $passwordFamily;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
7
frontend/src/DownloadClient/DownloadProtocol.ts
Normal file
7
frontend/src/DownloadClient/DownloadProtocol.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
enum DownloadProtocol {
|
||||
Unknown = 'unknown',
|
||||
Usenet = 'usenet',
|
||||
Torrent = 'torrent',
|
||||
}
|
||||
|
||||
export default DownloadProtocol;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -224,6 +224,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
name="seedRatio"
|
||||
value={seedRatio}
|
||||
helpText={translate('SeedRatioHelpText')}
|
||||
isFloat={true}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
20
frontend/src/Indexer/Index/Table/PrivacyLabel.css
Normal file
20
frontend/src/Indexer/Index/Table/PrivacyLabel.css
Normal 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);
|
||||
}
|
||||
9
frontend/src/Indexer/Index/Table/PrivacyLabel.css.d.ts
vendored
Normal file
9
frontend/src/Indexer/Index/Table/PrivacyLabel.css.d.ts
vendored
Normal 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;
|
||||
20
frontend/src/Indexer/Index/Table/PrivacyLabel.tsx
Normal file
20
frontend/src/Indexer/Index/Table/PrivacyLabel.tsx
Normal 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;
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
262
frontend/src/Search/Mobile/SearchIndexOverview.tsx
Normal file
262
frontend/src/Search/Mobile/SearchIndexOverview.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
.downloadClient {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--borderColor);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
17
frontend/src/Search/OverrideMatch/OverrideMatchData.css
Normal file
17
frontend/src/Search/OverrideMatch/OverrideMatchData.css
Normal 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);
|
||||
}
|
||||
9
frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts
vendored
Normal file
9
frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts
vendored
Normal 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;
|
||||
35
frontend/src/Search/OverrideMatch/OverrideMatchData.tsx
Normal file
35
frontend/src/Search/OverrideMatch/OverrideMatchData.tsx
Normal 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
|
||||
)}
|
||||
>
|
||||
|
||||
</span>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverrideMatchData;
|
||||
45
frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx
Normal file
45
frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
11
frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts
vendored
Normal file
11
frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts
vendored
Normal 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;
|
||||
150
frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx
Normal file
150
frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx
Normal 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;
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
395
frontend/src/Search/Table/SearchIndexRow.tsx
Normal file
395
frontend/src/Search/Table/SearchIndexRow.tsx
Normal 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;
|
||||
@@ -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'),
|
||||
|
||||
@@ -401,7 +401,16 @@ export const actionHandlers = handleThunks({
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[CLEAR_RELEASES]: (state) => {
|
||||
return Object.assign({}, state, defaultState);
|
||||
const {
|
||||
sortKey,
|
||||
sortDirection,
|
||||
customFilters,
|
||||
selectedFilterKey,
|
||||
columns,
|
||||
...otherDefaultState
|
||||
} = defaultState;
|
||||
|
||||
return Object.assign({}, state, otherDefaultState);
|
||||
},
|
||||
|
||||
[UPDATE_RELEASE]: (state, { payload }) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createDimensionsSelector() {
|
||||
return createSelector(
|
||||
(state) => state.app.dimensions,
|
||||
(state: AppState) => state.app.dimensions,
|
||||
(dimensions) => {
|
||||
return dimensions;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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(',')
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(',')
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7,10 +7,10 @@ function formatBytes(input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return filesize(size, {
|
||||
return `${filesize(size, {
|
||||
base: 2,
|
||||
round: 1
|
||||
});
|
||||
})}`;
|
||||
}
|
||||
|
||||
export default formatBytes;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface UiSettings {
|
||||
theme: 'auto' | 'dark' | 'light';
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
longDateFormat: string;
|
||||
|
||||
44
package.json
44
package.json
@@ -30,12 +30,12 @@
|
||||
"@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",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"chart.js": "4.3.0",
|
||||
"@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",
|
||||
"chart.js": "4.4.2",
|
||||
"classnames": "2.3.2",
|
||||
"clipboard": "2.0.11",
|
||||
"connected-react-router": "6.9.3",
|
||||
@@ -83,46 +83,46 @@
|
||||
"redux-thunk": "2.4.2",
|
||||
"reselect": "4.1.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"typescript": "5.0.4"
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.22.11",
|
||||
"@babel/eslint-parser": "7.22.11",
|
||||
"@babel/plugin-proposal-export-default-from": "7.22.5",
|
||||
"@babel/core": "7.24.4",
|
||||
"@babel/eslint-parser": "7.24.1",
|
||||
"@babel/plugin-proposal-export-default-from": "7.24.1",
|
||||
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||
"@babel/preset-env": "7.22.14",
|
||||
"@babel/preset-react": "7.22.5",
|
||||
"@babel/preset-typescript": "7.22.11",
|
||||
"@babel/preset-env": "7.24.4",
|
||||
"@babel/preset-react": "7.24.1",
|
||||
"@babel/preset-typescript": "7.24.1",
|
||||
"@types/lodash": "4.14.194",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/react-text-truncate": "0.14.1",
|
||||
"@types/react-window": "1.8.5",
|
||||
"@types/webpack-livereload-plugin": "2.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.5",
|
||||
"@typescript-eslint/parser": "5.59.5",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"are-you-es5": "2.1.2",
|
||||
"autoprefixer": "10.4.14",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-inline-classnames": "2.0.1",
|
||||
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
||||
"core-js": "3.33.0",
|
||||
"core-js": "3.37.0",
|
||||
"css-loader": "6.7.3",
|
||||
"css-modules-typescript-loader": "4.0.1",
|
||||
"eslint": "8.45.0",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "8.10.0",
|
||||
"eslint-plugin-filenames": "1.3.2",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react": "7.34.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "10.0.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.0",
|
||||
"file-loader": "6.2.0",
|
||||
"filemanager-webpack-plugin": "8.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "8.0.0",
|
||||
"html-webpack-plugin": "5.5.1",
|
||||
"loader-utils": "^3.2.1",
|
||||
"mini-css-extract-plugin": "2.7.5",
|
||||
"postcss": "8.4.31",
|
||||
"postcss": "8.4.38",
|
||||
"postcss-color-function": "4.1.0",
|
||||
"postcss-loader": "7.3.0",
|
||||
"postcss-mixins": "9.0.4",
|
||||
|
||||
@@ -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.4.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<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 -->
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
8
src/NzbDrone.Common/Options/AppOptions.cs
Normal file
8
src/NzbDrone.Common/Options/AppOptions.cs
Normal 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; }
|
||||
}
|
||||
9
src/NzbDrone.Common/Options/AuthOptions.cs
Normal file
9
src/NzbDrone.Common/Options/AuthOptions.cs
Normal 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; }
|
||||
}
|
||||
15
src/NzbDrone.Common/Options/LogOptions.cs
Normal file
15
src/NzbDrone.Common/Options/LogOptions.cs
Normal 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; }
|
||||
}
|
||||
12
src/NzbDrone.Common/Options/ServerOptions.cs
Normal file
12
src/NzbDrone.Common/Options/ServerOptions.cs
Normal 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; }
|
||||
}
|
||||
9
src/NzbDrone.Common/Options/UpdateOptions.cs
Normal file
9
src/NzbDrone.Common/Options/UpdateOptions.cs
Normal 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; }
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -114,7 +114,7 @@ namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests
|
||||
query.Tvrage.Should().BeNull();
|
||||
query.Search.Should().BeNull();
|
||||
query.Category.Should().Be("Episode");
|
||||
query.Name.Should().Be("S01E03");
|
||||
query.Name.Should().Be("S01E03%");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -249,7 +249,7 @@ namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests
|
||||
query.Tvrage.Should().BeNull();
|
||||
query.Search.Should().Be("Malcolm%in%the%Middle");
|
||||
query.Category.Should().Be("Episode");
|
||||
query.Name.Should().Be("S02E03");
|
||||
query.Name.Should().Be("S02E03%");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Dapper" Version="2.0.151" />
|
||||
<PackageReference Include="NBuilder" Version="6.1.0" />
|
||||
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.1.1" />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace NzbDrone.Core.Applications.Sonarr
|
||||
public IEnumerable<int> AnimeSyncCategories { get; set; }
|
||||
|
||||
[FieldDefinition(5, Label = "Sync Anime Standard Format Search", Type = FieldType.Checkbox, HelpText = "Sync also searching for anime using the standard numbering", Advanced = true)]
|
||||
public bool SyncAnimeStandardFormatSearch { get; set; }
|
||||
public bool SyncAnimeStandardFormatSearch { get; set; } = true;
|
||||
|
||||
[FieldDefinition(6, Type = FieldType.Checkbox, Label = "ApplicationSettingsSyncRejectBlocklistedTorrentHashes", HelpText = "ApplicationSettingsSyncRejectBlocklistedTorrentHashesHelpText", Advanced = true)]
|
||||
public bool SyncRejectBlocklistedTorrentHashesWhileGrabbing { get; set; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,12 +16,12 @@ namespace NzbDrone.Core.Datastore
|
||||
}
|
||||
|
||||
public CorruptDatabaseException(string message, Exception innerException, params object[] args)
|
||||
: base(message, innerException, args)
|
||||
: base(innerException, message, args)
|
||||
{
|
||||
}
|
||||
|
||||
public CorruptDatabaseException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
: base(innerException, message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
using NzbDrone.Core.Instrumentation;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Instrumentation;
|
||||
|
||||
namespace NzbDrone.Core.Housekeeping.Housekeepers
|
||||
{
|
||||
public class TrimLogDatabase : IHousekeepingTask
|
||||
{
|
||||
private readonly ILogRepository _logRepo;
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
|
||||
public TrimLogDatabase(ILogRepository logRepo)
|
||||
public TrimLogDatabase(ILogRepository logRepo, IConfigFileProvider configFileProvider)
|
||||
{
|
||||
_logRepo = logRepo;
|
||||
_configFileProvider = configFileProvider;
|
||||
}
|
||||
|
||||
public void Clean()
|
||||
{
|
||||
if (!_configFileProvider.LogDbEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logRepo.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ||
|
||||
@@ -116,7 +114,7 @@ namespace NzbDrone.Core.IndexerSearch.Definitions
|
||||
string episodeString;
|
||||
if (DateTime.TryParseExact($"{Season} {Episode}", "yyyy MM/dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var showDate))
|
||||
{
|
||||
episodeString = showDate.ToString("yyyy.MM.dd");
|
||||
episodeString = showDate.ToString("yyyy.MM.dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
else if (Episode.IsNullOrWhiteSpace())
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -55,7 +55,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
{
|
||||
TvSearchParams = new List<TvSearchParam>
|
||||
{
|
||||
TvSearchParam.Q
|
||||
TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep
|
||||
},
|
||||
MusicSearchParams = new List<MusicSearchParam>
|
||||
{
|
||||
@@ -117,7 +117,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
{
|
||||
var pageableRequests = new IndexerPageableRequestChain();
|
||||
|
||||
pageableRequests.Add(GetPagedRequests($"{searchCriteria.SanitizedSearchTerm}", searchCriteria.Categories));
|
||||
pageableRequests.Add(GetPagedRequests($"{searchCriteria.SanitizedTvSearchString}", searchCriteria.Categories));
|
||||
|
||||
return pageableRequests;
|
||||
}
|
||||
|
||||
@@ -644,16 +644,16 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
|
||||
private static int? ParseSeasonFromTitles(IReadOnlyCollection<string> titles)
|
||||
{
|
||||
var advancedSeasonRegex = new Regex(@"(\d+)(st|nd|rd|th) Season", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
var advancedSeasonRegex = new Regex(@"\b(?:(?<season>\d+)(?:st|nd|rd|th) Season|Season (?<season>\d+))\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
var seasonCharactersRegex = new Regex(@"(I{2,})$", RegexOptions.Compiled);
|
||||
var seasonNumberRegex = new Regex(@"\b(?:S)?([2-9])$", RegexOptions.Compiled);
|
||||
var seasonNumberRegex = new Regex(@"\b(?<!Part[- ._])(?:S)?(?<season>[2-9])$", RegexOptions.Compiled);
|
||||
|
||||
foreach (var title in titles)
|
||||
{
|
||||
var advancedSeasonRegexMatch = advancedSeasonRegex.Match(title);
|
||||
if (advancedSeasonRegexMatch.Success)
|
||||
{
|
||||
return ParseUtil.CoerceInt(advancedSeasonRegexMatch.Groups[1].Value);
|
||||
return ParseUtil.CoerceInt(advancedSeasonRegexMatch.Groups["season"].Value);
|
||||
}
|
||||
|
||||
var seasonCharactersRegexMatch = seasonCharactersRegex.Match(title);
|
||||
@@ -665,7 +665,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
var seasonNumberRegexMatch = seasonNumberRegex.Match(title);
|
||||
if (seasonNumberRegexMatch.Success)
|
||||
{
|
||||
return ParseUtil.CoerceInt(seasonNumberRegexMatch.Groups[1].Value);
|
||||
return ParseUtil.CoerceInt(seasonNumberRegexMatch.Groups["season"].Value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user