mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2026-04-17 21:44:48 -04:00
Compare commits
56 Commits
v1.17.0.44
...
v1.19.0.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '1.17.0'
|
||||
majorVersion: '1.19.0'
|
||||
minorVersion: $[counter('minorVersion', 1)]
|
||||
prowlarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
|
||||
@@ -166,10 +166,10 @@ stages:
|
||||
pool:
|
||||
vmImage: $(imageName)
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
- task: UseNode@1
|
||||
displayName: Set Node.js version
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
version: $(nodeVersion)
|
||||
- checkout: self
|
||||
submodules: true
|
||||
fetchDepth: 1
|
||||
@@ -1075,10 +1075,10 @@ stages:
|
||||
pool:
|
||||
vmImage: $(imageName)
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
- task: UseNode@1
|
||||
displayName: Set Node.js version
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
version: $(nodeVersion)
|
||||
- checkout: self
|
||||
submodules: true
|
||||
fetchDepth: 1
|
||||
|
||||
16
docs.sh
Normal file → Executable file
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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -224,6 +224,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
||||
name="seedRatio"
|
||||
value={seedRatio}
|
||||
helpText={translate('SeedRatioHelpText')}
|
||||
isFloat={true}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@juggle/resize-observer": "3.4.0",
|
||||
"@microsoft/signalr": "6.0.25",
|
||||
"@sentry/browser": "7.51.2",
|
||||
"@sentry/integrations": "7.51.2",
|
||||
"@sentry/browser": "7.100.0",
|
||||
"@sentry/integrations": "7.100.0",
|
||||
"@types/node": "18.19.31",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-dom": "18.2.25",
|
||||
|
||||
@@ -99,20 +99,52 @@
|
||||
<RootNamespace Condition="'$(ProwlarrProject)'=='true'">$(MSBuildProjectName.Replace('Prowlarr','NzbDrone'))</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(ProwlarrProject)'=='true' and '$(EnableAnalyzers)'=='false'">
|
||||
<!-- FXCop Built into Net5 SDK now as NETAnalyzers, Enabled by default on net5 projects -->
|
||||
<EnableNETAnalyzers>false</EnableNETAnalyzers>
|
||||
<ItemGroup Condition="'$(TestProject)'!='true'">
|
||||
<!-- Annotates .NET assemblies with repository information including SHA -->
|
||||
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
|
||||
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Sentry specific configuration: Only in Release mode -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
|
||||
<!-- OrgSlug, ProjectSlug and AuthToken are required.
|
||||
They can be set below, via argument to 'msbuild -p:' or environment variable -->
|
||||
<SentryOrg></SentryOrg>
|
||||
<SentryProject></SentryProject>
|
||||
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
|
||||
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
|
||||
|
||||
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
|
||||
without the need to deploy the application with PDBs -->
|
||||
<SentryUploadSymbols>true</SentryUploadSymbols>
|
||||
|
||||
<!-- Source Link settings -->
|
||||
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
|
||||
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
|
||||
<EmbedAllSources>true</EmbedAllSources>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Standard testing packages -->
|
||||
<ItemGroup Condition="'$(TestProject)'=='true'">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="NunitXml.TestLogger" Version="3.0.131" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TestProject)'=='true' and '$(TargetFramework)'=='net6.0'">
|
||||
<PackageReference Include="coverlet.collector" Version="3.0.4-preview.27.ge7cb7c3b40" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(ProwlarrProject)'=='true' and '$(EnableAnalyzers)'=='false'">
|
||||
<!-- FXCop Built into Net5 SDK now as NETAnalyzers, Enabled by default on net5 projects -->
|
||||
<EnableNETAnalyzers>false</EnableNETAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Set up stylecop -->
|
||||
<ItemGroup Condition="'$(ProwlarrProject)'=='true' and '$(EnableAnalyzers)'!='false'">
|
||||
<!-- StyleCop analysis -->
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = 10;
|
||||
|
||||
// Used when moving yml to C#
|
||||
private readonly List<string> _definitionBlocklist = new ()
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
|
||||
private readonly ICached<CardigannRequestGenerator> _generatorCache;
|
||||
|
||||
public override string Name => "Cardigann";
|
||||
public override string[] IndexerUrls => new string[] { "" };
|
||||
public override string[] IndexerUrls => new[] { "" };
|
||||
public override string Description => "";
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
|
||||
|
||||
@@ -58,10 +58,11 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
|
||||
Settings = Settings
|
||||
});
|
||||
|
||||
generator = (CardigannRequestGenerator)SetCookieFunctions(generator);
|
||||
|
||||
generator.Definition = Definition;
|
||||
generator.Settings = Settings;
|
||||
|
||||
generator = (CardigannRequestGenerator)SetCookieFunctions(generator);
|
||||
|
||||
_generatorCache.ClearExpired();
|
||||
|
||||
return generator;
|
||||
|
||||
@@ -337,9 +337,11 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
|
||||
variables[name] = selected.Key;
|
||||
break;
|
||||
case "info":
|
||||
variables[name] = value;
|
||||
break;
|
||||
case "info_cookie":
|
||||
case "info_flaresolverr":
|
||||
case "info_useragent":
|
||||
case "cardigannCaptcha":
|
||||
// no-op
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Type {setting.Type} is not supported.");
|
||||
|
||||
@@ -1178,14 +1178,14 @@ namespace NzbDrone.Core.Indexers.Definitions.Cardigann
|
||||
|
||||
if (method == HttpMethod.Get && searchUrls.Contains(searchUrl))
|
||||
{
|
||||
_logger.Trace("Skip duplicated request {0}", searchUrl);
|
||||
_logger.Trace("Skip duplicated request for {0}: {1}", Definition.Name, searchUrl);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
searchUrls.Add(searchUrl);
|
||||
|
||||
_logger.Debug($"Adding request: {searchUrl}");
|
||||
_logger.Debug("Adding request for {0}: {1}", Definition.Name, searchUrl);
|
||||
|
||||
var requestBuilder = new HttpRequestBuilder(searchUrl)
|
||||
{
|
||||
|
||||
@@ -61,7 +61,7 @@ public class FileListParser : IParseIndexerResponse
|
||||
var imdbId = 0;
|
||||
if (row.ImdbId is { Length: > 2 })
|
||||
{
|
||||
imdbId = int.Parse(row.ImdbId.Substring(2));
|
||||
int.TryParse(row.ImdbId.TrimStart('t'), out imdbId);
|
||||
}
|
||||
|
||||
var downloadVolumeFactor = row.FreeLeech ? 0 : 1;
|
||||
|
||||
@@ -20,6 +20,7 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.Indexers.Definitions
|
||||
{
|
||||
[Obsolete("Moved to YML for Cardigann")]
|
||||
public class HDSpace : TorrentIndexerBase<HDSpaceSettings>
|
||||
{
|
||||
public override string Name => "HD-Space";
|
||||
|
||||
@@ -51,12 +51,12 @@ public class MTeamTp : TorrentIndexerBase<MTeamTpSettings>
|
||||
|
||||
public override IIndexerRequestGenerator GetRequestGenerator()
|
||||
{
|
||||
return new MTeamTpRequestGenerator(Settings, Capabilities);
|
||||
return new MTeamTpRequestGenerator(Settings, Capabilities, BuildApiUrl(Settings));
|
||||
}
|
||||
|
||||
public override IParseIndexerResponse GetParser()
|
||||
{
|
||||
return new MTeamTpParser(Settings, Capabilities.Categories);
|
||||
return new MTeamTpParser(Settings, Capabilities.Categories, BuildApiUrl(Settings));
|
||||
}
|
||||
|
||||
public override async Task<byte[]> Download(Uri link)
|
||||
@@ -168,19 +168,26 @@ public class MTeamTp : TorrentIndexerBase<MTeamTpSettings>
|
||||
|
||||
return caps;
|
||||
}
|
||||
|
||||
private static string BuildApiUrl(IIndexerSettings settings)
|
||||
{
|
||||
return $"https://api.{settings.BaseUrl.AsSpan(settings.BaseUrl.IndexOf('.') + 1)}";
|
||||
}
|
||||
}
|
||||
|
||||
public class MTeamTpRequestGenerator : IIndexerRequestGenerator
|
||||
{
|
||||
private readonly MTeamTpSettings _settings;
|
||||
private readonly IndexerCapabilities _capabilities;
|
||||
private readonly string _apiUrl;
|
||||
|
||||
private readonly int[] _trackerAdultCategories = { 410, 429, 424, 430, 426, 437, 431, 432, 436, 425, 433, 411, 412, 413, 440 };
|
||||
|
||||
public MTeamTpRequestGenerator(MTeamTpSettings settings, IndexerCapabilities capabilities)
|
||||
public MTeamTpRequestGenerator(MTeamTpSettings settings, IndexerCapabilities capabilities, string apiUrl)
|
||||
{
|
||||
_settings = settings;
|
||||
_capabilities = capabilities;
|
||||
_apiUrl = apiUrl;
|
||||
}
|
||||
|
||||
public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria)
|
||||
@@ -253,7 +260,7 @@ public class MTeamTpRequestGenerator : IIndexerRequestGenerator
|
||||
|
||||
private IndexerRequest BuildSearchRequest(MTeamTpRequestType requestType, IEnumerable<int> categoryMapping, string searchTerm, string imdbId)
|
||||
{
|
||||
var request = new HttpRequestBuilder(_settings.BaseUrl)
|
||||
var request = new HttpRequestBuilder(_apiUrl)
|
||||
.Resource("/api/torrent/search")
|
||||
.SetHeader("x-api-key", _settings.ApiKey)
|
||||
.Accept(HttpAccept.Json)
|
||||
@@ -299,11 +306,13 @@ public class MTeamTpParser : IParseIndexerResponse
|
||||
{
|
||||
private readonly MTeamTpSettings _settings;
|
||||
private readonly IndexerCapabilitiesCategories _categories;
|
||||
private readonly string _apiUrl;
|
||||
|
||||
public MTeamTpParser(MTeamTpSettings settings, IndexerCapabilitiesCategories categories)
|
||||
public MTeamTpParser(MTeamTpSettings settings, IndexerCapabilitiesCategories categories, string apiUrl)
|
||||
{
|
||||
_settings = settings;
|
||||
_categories = categories;
|
||||
_apiUrl = apiUrl;
|
||||
}
|
||||
|
||||
public IList<ReleaseInfo> ParseResponse(IndexerResponse indexerResponse)
|
||||
@@ -390,7 +399,7 @@ public class MTeamTpParser : IParseIndexerResponse
|
||||
|
||||
private string GetDownloadUrl(int torrentId)
|
||||
{
|
||||
var url = new HttpUri(_settings.BaseUrl)
|
||||
var url = new HttpUri(_apiUrl)
|
||||
.CombinePath("/api/torrent/genDlToken")
|
||||
.AddQueryParam("id", torrentId);
|
||||
|
||||
|
||||
@@ -387,14 +387,14 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
throw new IndexerException(indexerResponse, $"Unexpected response header {indexerResponse.HttpResponse.Headers.ContentType} from indexer request, expected {HttpAccept.Json.Value}");
|
||||
}
|
||||
|
||||
var torrentInfos = new List<TorrentInfo>();
|
||||
var releaseInfos = new List<ReleaseInfo>();
|
||||
|
||||
var jsonResponse = JsonConvert.DeserializeObject<MyAnonamouseResponse>(indexerResponse.Content);
|
||||
|
||||
var error = jsonResponse.Error;
|
||||
if (error is "Nothing returned, out of 0" or "Nothing returned, out of 1")
|
||||
if (error.IsNotNullOrWhiteSpace() && error.StartsWithIgnoreCase("Nothing returned, out of"))
|
||||
{
|
||||
return torrentInfos.ToArray();
|
||||
return releaseInfos.ToArray();
|
||||
}
|
||||
|
||||
var hasUserVip = HasUserVip();
|
||||
@@ -462,10 +462,10 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
release.MinimumRatio = 1;
|
||||
release.MinimumSeedTime = 259200; // 72 hours
|
||||
|
||||
torrentInfos.Add(release);
|
||||
releaseInfos.Add(release);
|
||||
}
|
||||
|
||||
return torrentInfos.ToArray();
|
||||
return releaseInfos.ToArray();
|
||||
}
|
||||
|
||||
private bool HasUserVip()
|
||||
|
||||
@@ -228,10 +228,21 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
|
||||
if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
throw new IndexerException(indexerResponse, $"Unexpected response status {indexerResponse.HttpResponse.StatusCode} code from indexer request");
|
||||
throw new IndexerException(indexerResponse, "Unexpected response status '{0}' code from indexer request", indexerResponse.HttpResponse.StatusCode);
|
||||
}
|
||||
|
||||
var jsonResponse = STJson.Deserialize<JsonRpcResponse<NebulanceTorrents>>(indexerResponse.HttpResponse.Content);
|
||||
JsonRpcResponse<NebulanceTorrents> jsonResponse;
|
||||
|
||||
try
|
||||
{
|
||||
jsonResponse = STJson.Deserialize<JsonRpcResponse<NebulanceTorrents>>(indexerResponse.HttpResponse.Content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
STJson.TryDeserialize<JsonRpcResponse<string>>(indexerResponse.HttpResponse.Content, out var response);
|
||||
|
||||
throw new IndexerException(indexerResponse, "Unexpected response from indexer request: {0}", ex, response?.Result ?? ex.Message);
|
||||
}
|
||||
|
||||
if (jsonResponse.Error != null || jsonResponse.Result == null)
|
||||
{
|
||||
|
||||
@@ -20,16 +20,21 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
public override string Name => "TorrentDay";
|
||||
public override string[] IndexerUrls => new[]
|
||||
{
|
||||
"https://torrentday.cool/",
|
||||
"https://tday.love/",
|
||||
"https://torrentday.cool/",
|
||||
"https://secure.torrentday.com/",
|
||||
"https://classic.torrentday.com/",
|
||||
"https://www.torrentday.com/",
|
||||
"https://www.torrentday.me/",
|
||||
"https://torrentday.it/",
|
||||
"https://td.findnemo.net/",
|
||||
"https://td.getcrazy.me/",
|
||||
"https://td.venom.global/",
|
||||
"https://td.workisboring.net/"
|
||||
"https://td.workisboring.net/",
|
||||
"https://tday.findnemo.net/",
|
||||
"https://tday.getcrazy.me/",
|
||||
"https://tday.venom.global/",
|
||||
"https://tday.workisboring.net/"
|
||||
};
|
||||
public override string Description => "TorrentDay (TD) is a Private site for TV / MOVIES / GENERAL";
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
|
||||
@@ -266,7 +271,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
DownloadVolumeFactor = downloadMultiplier,
|
||||
UploadVolumeFactor = 1,
|
||||
MinimumRatio = 1,
|
||||
MinimumSeedTime = 172800 // 48 hours
|
||||
MinimumSeedTime = 259200 // 72 hours
|
||||
};
|
||||
|
||||
torrentInfos.Add(release);
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
using NzbDrone.Common.Exceptions;
|
||||
using System;
|
||||
using NzbDrone.Common.Exceptions;
|
||||
|
||||
namespace NzbDrone.Core.Indexers.Exceptions
|
||||
{
|
||||
public class IndexerException : NzbDroneException
|
||||
{
|
||||
private readonly IndexerResponse _indexerResponse;
|
||||
|
||||
public IndexerException(IndexerResponse response, string message, params object[] args)
|
||||
: base(message, args)
|
||||
{
|
||||
_indexerResponse = response;
|
||||
}
|
||||
public IndexerResponse Response { get; }
|
||||
|
||||
public IndexerException(IndexerResponse response, string message)
|
||||
: base(message)
|
||||
{
|
||||
_indexerResponse = response;
|
||||
Response = response;
|
||||
}
|
||||
|
||||
public IndexerResponse Response => _indexerResponse;
|
||||
public IndexerException(IndexerResponse response, string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Response = response;
|
||||
}
|
||||
|
||||
public IndexerException(IndexerResponse response, string message, params object[] args)
|
||||
: base(message, args)
|
||||
{
|
||||
Response = response;
|
||||
}
|
||||
|
||||
public IndexerException(IndexerResponse response, string message, Exception innerException, params object[] args)
|
||||
: base(message, innerException, args)
|
||||
{
|
||||
Response = response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using NzbDrone.Core.Indexers.Exceptions;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Validation;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
@@ -27,6 +28,7 @@ namespace NzbDrone.Core.Indexers
|
||||
where TSettings : IIndexerSettings, new()
|
||||
{
|
||||
protected const int MaxNumResultsPerQuery = 1000;
|
||||
private const int MaxRedirects = 5;
|
||||
|
||||
protected readonly IIndexerHttpClient _httpClient;
|
||||
protected readonly IEventAggregator _eventAggregator;
|
||||
@@ -239,11 +241,47 @@ namespace NzbDrone.Core.Indexers
|
||||
request.RateLimit = RateLimit;
|
||||
}
|
||||
|
||||
request.AllowAutoRedirect = false;
|
||||
|
||||
byte[] fileData;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.ExecuteProxiedAsync(request, Definition);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther)
|
||||
{
|
||||
var autoRedirectChain = new List<string> { request.Url.ToString() };
|
||||
|
||||
do
|
||||
{
|
||||
var redirectUrl = response.RedirectUrl;
|
||||
|
||||
_logger.Debug("Download request is being redirected to: {0}", redirectUrl);
|
||||
|
||||
if (redirectUrl.IsNullOrWhiteSpace())
|
||||
{
|
||||
throw new WebException("Remote website tried to redirect without providing a location.");
|
||||
}
|
||||
|
||||
if (redirectUrl.StartsWith("magnet:"))
|
||||
{
|
||||
return await Download(new Uri(redirectUrl));
|
||||
}
|
||||
|
||||
request.Url = new HttpUri(redirectUrl);
|
||||
autoRedirectChain.Add(request.Url.ToString());
|
||||
|
||||
if (autoRedirectChain.Count > MaxRedirects)
|
||||
{
|
||||
throw new WebException($"Too many download redirections were attempted for {autoRedirectChain.Join(" -> ")}", WebExceptionStatus.ProtocolError);
|
||||
}
|
||||
|
||||
response = await _httpClient.ExecuteProxiedAsync(request, Definition);
|
||||
}
|
||||
while (response.StatusCode is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther);
|
||||
}
|
||||
|
||||
fileData = response.ResponseData;
|
||||
|
||||
_logger.Debug("Downloaded for release finished ({0} bytes from {1})", fileData.Length, link.AbsoluteUri);
|
||||
@@ -287,10 +325,7 @@ namespace NzbDrone.Core.Indexers
|
||||
|
||||
protected virtual Task<HttpRequest> GetDownloadRequest(Uri link)
|
||||
{
|
||||
var requestBuilder = new HttpRequestBuilder(link.AbsoluteUri)
|
||||
{
|
||||
AllowAutoRedirect = FollowRedirect
|
||||
};
|
||||
var requestBuilder = new HttpRequestBuilder(link.AbsoluteUri);
|
||||
|
||||
if (Cookies != null)
|
||||
{
|
||||
@@ -780,7 +815,10 @@ namespace NzbDrone.Core.Indexers
|
||||
{
|
||||
_logger.Warn(ex, "Unable to connect to indexer");
|
||||
|
||||
return new ValidationFailure(string.Empty, "Unable to connect to indexer, please check your DNS settings and ensure IPv6 is working or disabled. " + ex.Message);
|
||||
return new NzbDroneValidationFailure(string.Empty, "Unable to connect to indexer, please check your DNS settings and ensure IPv6 is working or disabled. " + ex.Message)
|
||||
{
|
||||
DetailedDescription = ex.InnerException?.Message
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
@@ -807,7 +845,10 @@ namespace NzbDrone.Core.Indexers
|
||||
{
|
||||
_logger.Warn(ex, "Unable to connect to indexer");
|
||||
|
||||
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log above the ValidationFailure for more details. " + ex.Message);
|
||||
return new NzbDroneValidationFailure(string.Empty, "Unable to connect to indexer, check the log above the ValidationFailure for more details. " + ex.Message)
|
||||
{
|
||||
DetailedDescription = ex.InnerException?.Message
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -55,7 +55,7 @@ namespace NzbDrone.Core.Indexers
|
||||
[FieldDefinition(1, Type = FieldType.Number, Label = "IndexerSettingsAppsMinimumSeeders", HelpText = "IndexerSettingsAppsMinimumSeedersHelpText", Advanced = true)]
|
||||
public int? AppMinimumSeeders { get; set; }
|
||||
|
||||
[FieldDefinition(2, Type = FieldType.Textbox, Label = "IndexerSettingsSeedRatio", HelpText = "IndexerSettingsSeedRatioHelpText")]
|
||||
[FieldDefinition(2, Type = FieldType.Number, Label = "IndexerSettingsSeedRatio", HelpText = "IndexerSettingsSeedRatioHelpText")]
|
||||
public double? SeedRatio { get; set; }
|
||||
|
||||
[FieldDefinition(3, Type = FieldType.Number, Label = "IndexerSettingsSeedTime", Unit = "minutes", HelpText = "IndexerSettingsSeedTimeHelpText", Advanced = true)]
|
||||
|
||||
@@ -355,5 +355,7 @@
|
||||
"ResetAPIKeyMessageText": "هل أنت متأكد أنك تريد إعادة تعيين مفتاح API الخاص بك؟",
|
||||
"RestartProwlarr": "أعد تشغيل {appName}",
|
||||
"CustomFilter": "مرشحات مخصصة",
|
||||
"IndexerHDBitsSettingsMediums": "متوسط"
|
||||
"IndexerHDBitsSettingsMediums": "متوسط",
|
||||
"GrabRelease": "انتزاع الإصدار",
|
||||
"ProxyValidationBadRequest": "فشل اختبار الوكيل. رمز الحالة: {statusCode}"
|
||||
}
|
||||
|
||||
@@ -355,5 +355,7 @@
|
||||
"ResetAPIKeyMessageText": "Наистина ли искате да нулирате своя API ключ?",
|
||||
"RestartProwlarr": "Рестартирайте {appName}",
|
||||
"IndexerHDBitsSettingsMediums": "Среден",
|
||||
"CustomFilter": "Персонализирани филтри"
|
||||
"CustomFilter": "Персонализирани филтри",
|
||||
"GrabRelease": "Grab Release",
|
||||
"ProxyValidationBadRequest": "Неуспешно тестване на прокси. Код на състоянието: {statusCode}"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"ScriptPath": "Camí de l'script",
|
||||
"Search": "Cerca",
|
||||
"Files": "Fitxers",
|
||||
"SettingsEnableColorImpairedModeHelpText": "Estil alternat per a permetre als usuaris amb problemes de color distingir millor la informació codificada per colors",
|
||||
"SettingsEnableColorImpairedModeHelpText": "Estil modificat per a permetre als usuaris amb problemes visuals distingir millor la informació codificada per colors",
|
||||
"TagIsNotUsedAndCanBeDeleted": "L'etiqueta no està en ús i es pot suprimir",
|
||||
"TagsSettingsSummary": "Consulta totes les etiquetes i com s'utilitzen. Les etiquetes no utilitzades es poden eliminar",
|
||||
"Tasks": "Tasques",
|
||||
@@ -196,7 +196,7 @@
|
||||
"MovieIndexScrollBottom": "Índex de pel·lícules: Desplaçament inferior",
|
||||
"OnApplicationUpdate": "A l'actualitzar de l'aplicació",
|
||||
"OnApplicationUpdateHelpText": "A l'actualitzar de l'aplicació",
|
||||
"OnGrab": "Al capturar",
|
||||
"OnGrab": "Al capturar llançament",
|
||||
"PackageVersion": "Versió del paquet",
|
||||
"ProxyFailedToTestHealthCheckMessage": "No s'ha pogut provar el servidor intermediari: {url}",
|
||||
"ProxyResolveIpHealthCheckMessage": "No s'ha pogut resoldre l'adreça IP de l'amfitrió intermediari configurat {proxyHostName}",
|
||||
@@ -216,7 +216,7 @@
|
||||
"SettingsShowRelativeDatesHelpText": "Mostra dates relatives (avui/ahir/etc) o absolutes",
|
||||
"ShowSearch": "Mostra la cerca",
|
||||
"ShowSearchHelpText": "Mostra el botó de cerca al passar el cursor",
|
||||
"Shutdown": "Tanca",
|
||||
"Shutdown": "Apaga",
|
||||
"Sort": "Ordena",
|
||||
"Source": "Font",
|
||||
"SSLCertPassword": "Contrasenya de certificat SSL",
|
||||
@@ -240,7 +240,7 @@
|
||||
"IndexerPriority": "Prioritat de l'indexador",
|
||||
"UnsavedChanges": "Canvis no desats",
|
||||
"UpdateAutomaticallyHelpText": "Baixeu i instal·leu les actualitzacions automàticament. Encara podreu instal·lar des de Sistema: Actualitzacions",
|
||||
"UpdateStartupTranslocationHealthCheckMessage": "No es pot instal·lar l'actualització perquè la carpeta d'inici \"{startupFolder}\" es troba en una carpeta de translocació d'aplicacions.",
|
||||
"UpdateStartupTranslocationHealthCheckMessage": "No es pot instal·lar l'actualització perquè la carpeta d'inici '{startupFolder}' es troba en una carpeta de translocació d'aplicacions.",
|
||||
"UpdateUiNotWritableHealthCheckMessage": "No es pot instal·lar l'actualització perquè l'usuari '{userName}' no pot escriure la carpeta de la IU '{uiFolder}'.",
|
||||
"UpdateScriptPathHelpText": "Camí a un script personalitzat que pren un paquet d'actualització i gestiona la resta del procés d'actualització",
|
||||
"Uptime": "Temps de funcionament",
|
||||
@@ -294,62 +294,62 @@
|
||||
"UnableToAddANewNotificationPleaseTryAgain": "No es pot afegir una notificació nova, torneu-ho a provar.",
|
||||
"UnableToLoadGeneralSettings": "No es pot carregar la configuració general",
|
||||
"UnableToLoadHistory": "No es pot carregar l'historial",
|
||||
"UpdateStartupNotWritableHealthCheckMessage": "No es pot instal·lar l'actualització perquè l'usuari \"{userName}\" no pot escriure la carpeta d'inici \"{startupFolder}\".",
|
||||
"UpdateStartupNotWritableHealthCheckMessage": "No es pot instal·lar l'actualització perquè l'usuari '{userName}' no té permisos d'escriptura de la carpeta d'inici '{startupFolder}'.",
|
||||
"URLBase": "Base URL",
|
||||
"Usenet": "Usenet",
|
||||
"View": "Visualitza",
|
||||
"Yesterday": "Ahir",
|
||||
"ApplicationStatusCheckSingleClientMessage": "Llistes no disponibles a causa d'errors: {0}",
|
||||
"ApplicationStatusCheckSingleClientMessage": "Aplicacions no disponibles a causa d'errors: {0}",
|
||||
"AnalyticsEnabledHelpText": "Envieu informació anònima d'ús i errors als servidors de {appName}. Això inclou informació sobre el vostre navegador, quines pàgines {appName} WebUI feu servir, informes d'errors, així com el sistema operatiu i la versió de l'entorn d'execució. Utilitzarem aquesta informació per a prioritzar les funcions i les correccions d'errors.",
|
||||
"HistoryCleanupDaysHelpTextWarning": "Els fitxers de la paperera de reciclatge més antics que el nombre de dies seleccionat es netejaran automàticament",
|
||||
"UnableToAddANewAppProfilePleaseTryAgain": "No es pot afegir un perfil de qualitat nou, torneu-ho a provar.",
|
||||
"UnableToAddANewAppProfilePleaseTryAgain": "No es pot afegir un perfil d'aplicació nou, torneu-ho a provar.",
|
||||
"BackupFolderHelpText": "Els camins relatius estaran sota el directori AppData de {appName}",
|
||||
"AllIndexersHiddenDueToFilter": "Totes les pel·lícules estan ocultes a causa del filtre aplicat.",
|
||||
"AllIndexersHiddenDueToFilter": "Tots els indexadors estan ocults a causa del filtre aplicat.",
|
||||
"EnableRss": "Activa RSS",
|
||||
"Grabs": "Captura",
|
||||
"EnableAutomaticSearchHelpText": "S'utilitzarà quan es realitzin cerques automàtiques mitjançant la interfície d'usuari o per {appName}",
|
||||
"UnableToAddANewApplicationPleaseTryAgain": "No es pot afegir una notificació nova, torneu-ho a provar.",
|
||||
"UnableToAddANewApplicationPleaseTryAgain": "No es pot afegir una aplicació nova, torneu-ho a provar.",
|
||||
"Application": "Aplicacions",
|
||||
"Applications": "Aplicacions",
|
||||
"ApplicationStatusCheckAllClientMessage": "Totes les llistes no estan disponibles a causa d'errors",
|
||||
"ApplicationStatusCheckAllClientMessage": "Totes les aplicacions no estan disponibles a causa d'errors",
|
||||
"AuthenticationMethodHelpText": "Es requereix nom d'usuari i contrasenya per a accedir a {appName}",
|
||||
"ApplicationLongTermStatusCheckAllClientMessage": "Tots els indexadors no estan disponibles a causa d'errors durant més de 6 hores",
|
||||
"ApplicationLongTermStatusCheckSingleClientMessage": "Els indexadors no estan disponibles a causa d'errors durant més de 6 hores: {0}",
|
||||
"ApplicationLongTermStatusCheckAllClientMessage": "Totes les aplicacions no estan disponibles a causa d'errors durant més de 6 hores",
|
||||
"ApplicationLongTermStatusCheckSingleClientMessage": "Aplicacions no disponibles a causa d'errors durant més de 6 hores: {0}",
|
||||
"BindAddressHelpText": "Adreça IP vàlida, localhost o '*' per a totes les interfícies",
|
||||
"BranchUpdate": "Branca que s'utilitza per a actualitzar {appName}",
|
||||
"Connect": "Notificacions",
|
||||
"DeleteApplicationMessageText": "Esteu segur que voleu suprimir la notificació '{0}'?",
|
||||
"DeleteIndexerProxyMessageText": "Esteu segur que voleu suprimir la llista '{0}'?",
|
||||
"DeleteApplicationMessageText": "Esteu segur que voleu suprimir l'aplicació '{name}'?",
|
||||
"DeleteIndexerProxyMessageText": "Esteu segur que voleu suprimir el servidor intermediari de l'indexador '{name}'?",
|
||||
"Encoding": "Codificació",
|
||||
"ForMoreInformationOnTheIndividualDownloadClients": "Per obtenir més informació sobre els clients de baixada individuals, feu clic als botons de més informació.",
|
||||
"ForMoreInformationOnTheIndividualDownloadClients": "Per a obtenir més informació sobre els clients de baixada individuals, feu clic als botons de més informació.",
|
||||
"GeneralSettingsSummary": "Port, SSL, nom d'usuari/contrasenya, servidor intermediari, analítiques i actualitzacions",
|
||||
"GrabReleases": "Captura novetat",
|
||||
"GrabReleases": "Captura llançament(s)",
|
||||
"HistoryCleanupDaysHelpText": "Establiu a 0 per desactivar la neteja automàtica",
|
||||
"Notification": "Notificacions",
|
||||
"Notifications": "Notificacions",
|
||||
"ReleaseBranchCheckOfficialBranchMessage": "La branca {0} no és una branca de llançament de {appName} vàlida, no rebreu actualitzacions",
|
||||
"TagsHelpText": "S'aplica a pel·lícules amb almenys una etiqueta coincident",
|
||||
"Torrent": "Torrent",
|
||||
"UnableToAddANewIndexerProxyPleaseTryAgain": "No es pot afegir un indexador nou, torneu-ho a provar.",
|
||||
"TagsHelpText": "S'aplica a indexadors amb almenys una etiqueta coincident",
|
||||
"Torrent": "Torrents",
|
||||
"UnableToAddANewIndexerProxyPleaseTryAgain": "No es pot afegir un servidor intermediari d'indexador nou, torneu-ho a provar.",
|
||||
"UpdateMechanismHelpText": "Utilitzeu l'actualitzador integrat de {appName} o un script",
|
||||
"UserAgentProvidedByTheAppThatCalledTheAPI": "Agent d'usuari proporcionat per l'aplicació per fer peticions a l'API",
|
||||
"IndexerProxyStatusAllUnavailableHealthCheckMessage": "Tots els indexadors no estan disponibles a causa d'errors",
|
||||
"IndexerProxyStatusUnavailableHealthCheckMessage": "Els indexadors no estan disponibles a causa d'errors: {indexerProxyNames}",
|
||||
"LaunchBrowserHelpText": " Obriu un navegador web i navegueu a la pàgina d'inici de {appName} a l'inici de l'aplicació.",
|
||||
"Link": "Enllaços",
|
||||
"Link": "Enllaç",
|
||||
"UILanguageHelpText": "Idioma que utilitzarà {appName} per a la interfície d'usuari",
|
||||
"Remove": "Elimina",
|
||||
"Replace": "Substitueix",
|
||||
"TheLatestVersionIsAlreadyInstalled": "La darrera versió de {0} ja està instal·lada",
|
||||
"ThemeHelpText": "Canvieu el tema de la interfície d'usuari de l'aplicació, el tema \"Automàtic\" utilitzarà el tema del vostre sistema operatiu per configurar el mode clar o fosc. Inspirat en Theme.Park",
|
||||
"TheLatestVersionIsAlreadyInstalled": "La darrera versió de {appName} ja està instal·lada",
|
||||
"ThemeHelpText": "Canvieu el tema de la interfície d'usuari de l'aplicació, el tema \"Automàtic\" utilitzarà el tema del vostre sistema operatiu per configurar el mode clar o fosc. Inspirat en {inspiredBy}.",
|
||||
"ApplicationURL": "URL de l'aplicació",
|
||||
"Publisher": "Editor",
|
||||
"ApplicationUrlHelpText": "URL extern d'aquesta aplicació, inclòs http(s)://, port i URL base",
|
||||
"ApplyTagsHelpTextAdd": "Afegeix: afegeix les etiquetes a la llista d'etiquetes existent",
|
||||
"ApplyTagsHelpTextHowToApplyApplications": "Com aplicar etiquetes a les pel·lícules seleccionades",
|
||||
"ApplicationUrlHelpText": "URL extern de l'aplicació, inclòs http(s)://, port i URL base",
|
||||
"ApplyTagsHelpTextAdd": "Afegiment: afegeix les etiquetes a la llista d'etiquetes existent",
|
||||
"ApplyTagsHelpTextHowToApplyApplications": "Com aplicar etiquetes a les aplicacions seleccionades",
|
||||
"ApplyTagsHelpTextHowToApplyIndexers": "Com aplicar etiquetes als indexadors seleccionats",
|
||||
"ApplyTagsHelpTextRemove": "Eliminar: elimina les etiquetes introduïdes",
|
||||
"DeleteSelectedApplicationsMessageText": "Esteu segur que voleu suprimir l'indexador '{0}'?",
|
||||
"ApplyTagsHelpTextRemove": "Eliminació: elimina les etiquetes introduïdes",
|
||||
"DeleteSelectedApplicationsMessageText": "Esteu segur que voleu suprimir {count} sol·licituds seleccionades?",
|
||||
"Label": "Etiqueta",
|
||||
"ApplyTagsHelpTextReplace": "Substitució: substituïu les etiquetes per les etiquetes introduïdes (no introduïu cap etiqueta per a esborrar totes les etiquetes)",
|
||||
"DeleteSelectedDownloadClients": "Suprimeix el(s) client(s) de baixada",
|
||||
@@ -360,23 +360,23 @@
|
||||
"More": "Més",
|
||||
"Season": "Temporada",
|
||||
"Theme": "Tema",
|
||||
"Track": "Traça",
|
||||
"Track": "Pista",
|
||||
"Year": "Any",
|
||||
"UpdateAvailableHealthCheckMessage": "Nova actualització disponible",
|
||||
"ConnectionLostReconnect": "{appName} intentarà connectar-se automàticament, o podeu fer clic a recarregar.",
|
||||
"ConnectionLostToBackend": "{appName} ha perdut la connexió amb el backend i s'haurà de tornar a carregar per a restaurar la funcionalitat.",
|
||||
"RecentChanges": "Canvis recents",
|
||||
"WhatsNew": "Que hi ha de nou?",
|
||||
"minutes": "Minuts",
|
||||
"DeleteAppProfileMessageText": "Esteu segur que voleu suprimir el perfil de qualitat {0}",
|
||||
"NotificationStatusSingleClientHealthCheckMessage": "Llistes no disponibles a causa d'errors: {notificationNames}",
|
||||
"WhatsNew": "Novetats",
|
||||
"minutes": "minuts",
|
||||
"DeleteAppProfileMessageText": "Esteu segur que voleu suprimir el perfil de l'aplicació '{name}'?",
|
||||
"NotificationStatusSingleClientHealthCheckMessage": "Notificacions no disponibles a causa d'errors: {notificationNames}",
|
||||
"AddConnection": "Afegeix una connexió",
|
||||
"NotificationStatusAllClientHealthCheckMessage": "Totes les notificacions no estan disponibles a causa d'errors",
|
||||
"AuthBasic": "Basic (finestra emergent del navegador)",
|
||||
"AuthBasic": "Bàsic (finestra emergent del navegador)",
|
||||
"AuthForm": "Formularis (pàgina d'inici de sessió)",
|
||||
"DisabledForLocalAddresses": "Desactivat per a adreces locals",
|
||||
"None": "Cap",
|
||||
"ResetAPIKeyMessageText": "Esteu segur que voleu restablir la clau de l'API?",
|
||||
"ResetAPIKeyMessageText": "Esteu segur que voleu restablir la clau API?",
|
||||
"RestartProwlarr": "Reinicia {appName}",
|
||||
"AuthenticationRequired": "Autenticació necessària",
|
||||
"CountDownloadClientsSelected": "{count} client(s) de baixada seleccionat(s)",
|
||||
@@ -412,15 +412,15 @@
|
||||
"AuthenticationRequiredPasswordConfirmationHelpTextWarning": "Confirmeu la nova contrasenya",
|
||||
"AuthenticationRequiredUsernameHelpTextWarning": "Introduïu un nom d'usuari nou",
|
||||
"Categories": "Categories",
|
||||
"ApiKeyValidationHealthCheckMessage": "Actualitzeu la vostra clau de l'API perquè tingui almenys {length} caràcters. Podeu fer-ho mitjançant la configuració o el fitxer de configuració",
|
||||
"ApiKeyValidationHealthCheckMessage": "Actualitzeu la vostra clau API perquè tingui almenys {length} caràcters. Podeu fer-ho mitjançant la configuració o el fitxer de configuració",
|
||||
"Episode": "Episodi",
|
||||
"EditApplicationImplementation": "Edita la notificació - {implementationName}",
|
||||
"EditApplicationImplementation": "Edita l'aplicació - {implementationName}",
|
||||
"EditConnectionImplementation": "Afegeix una connexió - {implementationName}",
|
||||
"EditIndexerProxyImplementation": "Edita l'indexador - {implementationName}",
|
||||
"EditIndexerProxyImplementation": "Edita el servidor intermediari de l'indexador - {implementationName}",
|
||||
"days": "dies",
|
||||
"Album": "àlbum",
|
||||
"Artist": "artista",
|
||||
"AddApplicationImplementation": "Afegeix una condició - {implementationName}",
|
||||
"Album": "Àlbum",
|
||||
"Artist": "Artista",
|
||||
"AddApplicationImplementation": "Afegeix una aplicació - {implementationName}",
|
||||
"AddIndexerProxyImplementation": "Afegeix un indexador - {implementationName}",
|
||||
"Category": "Categoria",
|
||||
"Clone": "Clona",
|
||||
@@ -431,7 +431,7 @@
|
||||
"Id": "ID",
|
||||
"Author": "Autor",
|
||||
"ManageClients": "Gestiona els clients",
|
||||
"CountApplicationsSelected": "{count} Col·lecció(ns) seleccionades",
|
||||
"CountApplicationsSelected": "{count} aplicacions seleccionades",
|
||||
"DownloadClientAriaSettingsDirectoryHelpText": "Ubicació opcional per a les baixades, deixeu-lo en blanc per utilitzar la ubicació predeterminada d'Aria2",
|
||||
"Donate": "Dona",
|
||||
"BlackholeFolderHelpText": "Carpeta on {appName} emmagatzemarà els fitxers {extension}",
|
||||
@@ -439,9 +439,34 @@
|
||||
"Directory": "Directori",
|
||||
"DownloadClientDelugeSettingsUrlBaseHelpText": "Afegeix un prefix a l'url json del Deluge, vegeu {url}",
|
||||
"CustomFilter": "Filtres personalitzats",
|
||||
"IndexerHDBitsSettingsCodecs": "Còdec",
|
||||
"DownloadClientSettingsUrlBaseHelpText": "Afegeix un prefix a l'URL {connectionName}, com ara {url}",
|
||||
"DownloadClientTransmissionSettingsDirectoryHelpText": "Ubicació opcional per a les baixades, deixeu-lo en blanc per utilitzar la ubicació predeterminada d'Aria2",
|
||||
"DownloadClientRTorrentSettingsDirectoryHelpText": "Ubicació opcional de les baixades completades, deixeu-lo en blanc per utilitzar la ubicació predeterminada de Deluge",
|
||||
"IndexerHDBitsSettingsMediums": "Suport"
|
||||
"IndexerHDBitsSettingsCodecs": "Còdecs",
|
||||
"DownloadClientSettingsUrlBaseHelpText": "Afegeix un prefix a l'URL {clientName}, com ara {url}",
|
||||
"DownloadClientTransmissionSettingsDirectoryHelpText": "Ubicació opcional per a les baixades, deixeu-lo en blanc per utilitzar la ubicació predeterminada de Transmission",
|
||||
"DownloadClientRTorrentSettingsDirectoryHelpText": "Ubicació opcional de les baixades completades, deixeu-lo en blanc per utilitzar la ubicació predeterminada de rTorrent",
|
||||
"IndexerHDBitsSettingsMediums": "Mitjans",
|
||||
"Query": "Consulta",
|
||||
"AddApplication": "Afegeix una aplicació",
|
||||
"AddRemoveOnly": "Només afegeix i elimina",
|
||||
"AddSyncProfile": "Afegeix perfil de sincronització",
|
||||
"Auth": "Autenticació",
|
||||
"ActiveIndexers": "Indexadors actius",
|
||||
"Description": "Descripció",
|
||||
"DeleteSelectedIndexers": "Suprimeix els indexadors seleccionats",
|
||||
"IndexerSettingsBaseUrlHelpText": "Seleccioneu quina URL base utilitzarà {appName} per a les sol·licituds al lloc",
|
||||
"ActiveApps": "Aplicacions actives",
|
||||
"AddCategory": "Afegeix una categoria",
|
||||
"AddDownloadClientToProwlarr": "Afegir un client de baixada permet que {appName} enviï publicacions directament des de la interfície d'usuari mentre fa una cerca manual.",
|
||||
"AddIndexerProxy": "Afegeix un servidor intermediari per a l'indexador",
|
||||
"AddNewIndexer": "Afegeix un nou indexador",
|
||||
"AddToDownloadClient": "Afegeix llançament al client de baixades",
|
||||
"AddedToDownloadClient": "Baixada afegida al client",
|
||||
"DevelopmentSettings": "Configuració de desenvolupament",
|
||||
"Mixed": "Combinat",
|
||||
"Apps": "Aplicacions",
|
||||
"Privacy": "Privacitat",
|
||||
"Redirect": "Redirecció",
|
||||
"Stats": "Estadístiques",
|
||||
"Private": "Privat",
|
||||
"Proxies": "Servidors intermediaris",
|
||||
"Public": "Públic"
|
||||
}
|
||||
|
||||
@@ -412,5 +412,8 @@
|
||||
"IndexerHDBitsSettingsCodecs": "Kodek",
|
||||
"IndexerHDBitsSettingsMediums": "Střední",
|
||||
"Directory": "Adresář",
|
||||
"CustomFilter": "Vlastní filtry"
|
||||
"CustomFilter": "Vlastní filtry",
|
||||
"ProxyValidationBadRequest": "Nepodařilo se otestovat proxy. StatusCode: {statusCode}",
|
||||
"Default": "Výchozí",
|
||||
"GrabRelease": "Uchopte uvolnění"
|
||||
}
|
||||
|
||||
@@ -392,5 +392,7 @@
|
||||
"EditApplicationImplementation": "Tilføj forbindelse - {implementationName}",
|
||||
"EditDownloadClientImplementation": "Tilføj downloadklient - {implementationName}",
|
||||
"IndexerHDBitsSettingsMediums": "Medium",
|
||||
"CustomFilter": "Bruger Tilpassede Filtere"
|
||||
"CustomFilter": "Bruger Tilpassede Filtere",
|
||||
"ProxyValidationBadRequest": "Kunne ikke teste proxy. Statuskode: {statusCode}",
|
||||
"GrabRelease": "Grab Release"
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
"Discord": "Discord",
|
||||
"Docker": "Docker",
|
||||
"Donations": "Spenden",
|
||||
"DownloadClient": "Download Client",
|
||||
"DownloadClient": "Downloader",
|
||||
"DownloadClientSettings": "Downloader Einstellungen",
|
||||
"DownloadClientStatusAllClientHealthCheckMessage": "Alle Download Clients sind aufgrund von Fehlern nicht verfügbar",
|
||||
"DownloadClientStatusSingleClientHealthCheckMessage": "Download Clients aufgrund von Fehlern nicht verfügbar: {downloadClientNames}",
|
||||
@@ -179,7 +179,7 @@
|
||||
"IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Alle Indexer sind wegen über 6 Stunden langen bestehender Fehler nicht verfügbar",
|
||||
"IndexerLongTermStatusUnavailableHealthCheckMessage": "Indexer wegen über 6 Stunden langen bestehenden Fehlern nicht verfügbar: {indexerNames}",
|
||||
"IndexerName": "Indexer-Name",
|
||||
"IndexerNoDefCheckMessage": "Indexer haben keine Definition und werden nicht funktionieren: {0}. Bitte entferne und (oder) füge diese neu zu {appName} hinzu",
|
||||
"IndexerNoDefinitionCheckHealthCheckMessage": "Indexer haben keine Definition und werden nicht funktionieren: {0}. Bitte entferne und (oder) füge diese neu zu {appName} hinzu",
|
||||
"IndexerObsoleteCheckMessage": "Indexer sind nicht mehr verfügbar oder wurden aktualiiert: {0}. Bitte enfernen und (oder) neu zu {appName} hinzufügen",
|
||||
"IndexerPriority": "Priorität",
|
||||
"IndexerPriorityHelpText": "Indexer Priorität von 1 (höchste) bis 50 (niedrigste). Standard: 25.",
|
||||
@@ -546,5 +546,64 @@
|
||||
"CountApplicationsSelected": "{count} Ausgewählte Sammlung(en)",
|
||||
"DownloadClientAriaSettingsDirectoryHelpText": "Optionaler Speicherort für Downloads. Lassen Sie das Feld leer, um den standardmäßigen rTorrent-Speicherort zu verwenden",
|
||||
"ManageClients": "Verwalte Clienten",
|
||||
"BlackholeFolderHelpText": "Ordner, in dem {appName} die Datei {extension} speichert"
|
||||
"BlackholeFolderHelpText": "Ordner, in dem {appName} die Datei {extension} speichert",
|
||||
"DownloadClientSettingsInitialStateHelpText": "Anfangszustand für zu {clientName} hinzugefügte Torrents",
|
||||
"DownloadClientSettingsDestinationHelpText": "Legt das Ziel für den Download manuell fest; lassen Sie es leer, um die Standardeinstellung zu verwenden",
|
||||
"DownloadClientSettingsUrlBaseHelpText": "Fügt ein Präfix zur {clientName} Url hinzu, z.B. {url}",
|
||||
"DownloadClientSettingsUseSslHelpText": "Sichere Verbindung verwenden, wenn Verbindung zu {clientName} hergestellt wird",
|
||||
"DownloadClientTransmissionSettingsDirectoryHelpText": "Optionaler Speicherort für Downloads; leer lassen, um den Standardspeicherort für Übertragungen zu verwenden",
|
||||
"CustomFilter": "Benutzerdefinierter Filter",
|
||||
"DownloadClientSettingsInitialState": "Ausgangszustand",
|
||||
"Donate": "Spenden",
|
||||
"DownloadClientFreeboxSettingsAppId": "App-ID",
|
||||
"DownloadClientFreeboxSettingsAppIdHelpText": "App-ID, die beim Erstellen des Zugriffs auf die Freebox-API angegeben wird (z. B. „app_id“)",
|
||||
"DownloadClientFreeboxSettingsPortHelpText": "Port, der für den Zugriff auf die Freebox-Schnittstelle verwendet wird, standardmäßig ist „{port}“",
|
||||
"DownloadClientNzbgetSettingsAddPausedHelpText": "Diese Option erfordert mindestens NzbGet Version 16.0",
|
||||
"DownloadClientQbittorrentSettingsFirstAndLastFirstHelpText": "Laden Sie zuerst das erste und das letzte Stück herunter (qBittorrent 4.1.0+)",
|
||||
"IndexerBeyondHDSettingsSearchTypes": "Suchtyp",
|
||||
"DownloadClientFloodSettingsUrlBaseHelpText": "Fügt der Flood-API ein Präfix hinzu, z. B. {url}",
|
||||
"DownloadClientFreeboxSettingsApiUrl": "API-URL",
|
||||
"DownloadClientFreeboxSettingsApiUrlHelpText": "Definieren Sie die Freebox-API-Basis-URL mit der API-Version, z. B. „{url}“, standardmäßig ist „{defaultApiUrl}“.",
|
||||
"DownloadClientRTorrentSettingsUrlPathHelpText": "Pfad zum XMLRPC-Endpunkt, siehe {url}. Dies ist normalerweise RPC2 oder [Pfad zu ruTorrent]{url2}, wenn ruTorrent verwendet wird.",
|
||||
"DownloadClientSettingsAddPaused": "Pausiert hinzufügen",
|
||||
"SelectDownloadClientModalTitle": "{modalTitle} – Wähle Download-Client",
|
||||
"DownloadClientQbittorrentSettingsFirstAndLastFirst": "Erster und Letzter Erster",
|
||||
"DownloadClientQbittorrentSettingsInitialStateHelpText": "Ausgangszustand für zu qBittorrent hinzugefügte Torrents. Beachten Sie, dass erzwungene Torrents nicht den Seed-Beschränkungen unterliegen",
|
||||
"IndexerSettingsAppsMinimumSeeders": "Apps Mindestanzahl von Seedern",
|
||||
"IndexerHDBitsSettingsMediums": "Medium",
|
||||
"Destination": "Ziel",
|
||||
"Directory": "Verzeichnis",
|
||||
"DownloadClientDelugeSettingsUrlBaseHelpText": "Fügt der Deluge-JSON-URL ein Präfix hinzu, siehe {url}",
|
||||
"DownloadClientFloodSettingsAdditionalTags": "Zusätzliche Tags",
|
||||
"DownloadClientFloodSettingsTagsHelpText": "Erste Tags eines Downloads. Um erkannt zu werden, muss ein Download über alle Anfangs-Tags verfügen. Dies vermeidet Konflikte mit nicht verwandten Downloads.",
|
||||
"DownloadClientFreeboxSettingsAppToken": "App-Token",
|
||||
"DownloadClientFreeboxSettingsAppTokenHelpText": "App-Token, das beim Erstellen des Zugriffs auf die Freebox-API abgerufen wird (z. B. „app_token“)",
|
||||
"DownloadClientFreeboxSettingsHostHelpText": "Hostname oder Host-IP-Adresse der Freebox, standardmäßig „{url}“ (funktioniert nur im selben Netzwerk)",
|
||||
"DownloadClientPneumaticSettingsNzbFolder": "NZB-Ordner",
|
||||
"DownloadClientPneumaticSettingsNzbFolderHelpText": "Dieser Ordner muss über XBMC erreichbar sein",
|
||||
"DownloadClientPneumaticSettingsStrmFolder": "Strm-Ordner",
|
||||
"DownloadClientPneumaticSettingsStrmFolderHelpText": ".strm-Dateien in diesem Ordner werden von der Drohne importiert",
|
||||
"DownloadClientQbittorrentSettingsSequentialOrder": "Fortlaufende Reihenfolge",
|
||||
"DownloadClientQbittorrentSettingsSequentialOrderHelpText": "In sequentieller Reihenfolge herunterladen (qBittorrent 4.1.0+)",
|
||||
"DownloadClientQbittorrentSettingsUseSslHelpText": "Verwenden Sie eine sichere Verbindung. Siehe Optionen -> Web-Benutzeroberfläche -> „HTTPS statt HTTP verwenden“ in qBittorrent.",
|
||||
"DownloadClientRTorrentSettingsAddStopped": "Hinzufügen gestoppt",
|
||||
"DownloadClientRTorrentSettingsAddStoppedHelpText": "Durch die Aktivierung werden Torrents und Magnete im gestoppten Zustand zu rTorrent hinzugefügt. Dadurch können Magnetdateien beschädigt werden.",
|
||||
"DownloadClientRTorrentSettingsDirectoryHelpText": "Optionaler Speicherort für Downloads. Lassen Sie das Feld leer, um den standardmäßigen rTorrent-Speicherort zu verwenden",
|
||||
"DownloadClientRTorrentSettingsUrlPath": "URL-Pfad",
|
||||
"IndexerHDBitsSettingsCodecs": "Codec",
|
||||
"IndexerSettingsVipExpiration": "VIP Ablaufdatum",
|
||||
"TorrentBlackholeSaveMagnetFilesHelpText": "Speichern Sie den Magnet-Link, wenn keine .torrent-Datei verfügbar ist (nur nützlich, wenn der Download-Client in einer Datei gespeicherte Magnete unterstützt)",
|
||||
"TorrentBlackholeTorrentFolder": "Torrent-Ordner",
|
||||
"UseSsl": "SSL verwenden",
|
||||
"UsenetBlackholeNzbFolder": "NZB-Ordner",
|
||||
"TorrentBlackholeSaveMagnetFilesExtensionHelpText": "Für Magnet-Links zu verwendende Erweiterung, standardmäßig „.magnet“.",
|
||||
"XmlRpcPath": "XML-RPC-Pfad",
|
||||
"ProxyValidationBadRequest": "Proxy konnte nicht getestet werden. StatusCode: {statusCode}",
|
||||
"SecretToken": "Geheimer Token",
|
||||
"DownloadClientDownloadStationSettingsDirectoryHelpText": "Optionaler freigegebener Ordner zum Ablegen von Downloads. Lassen Sie das Feld leer, um den Standardspeicherort der Download Station zu verwenden",
|
||||
"DownloadClientFloodSettingsAdditionalTagsHelpText": "Fügt Eigenschaften von Medien als Tags hinzu. Hinweise sind Beispiele.",
|
||||
"TorrentBlackholeSaveMagnetFiles": "Speicher Magnetdateien",
|
||||
"TorrentBlackholeSaveMagnetFilesExtension": "Speicher die Magnet-Dateienerweiterung",
|
||||
"Default": "Standard",
|
||||
"GrabRelease": "Release erfassen"
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@
|
||||
"IndexerInfo": "Πληροφορίες ευρετηρίου",
|
||||
"IndexerName": "Όνομα ευρετηρίου",
|
||||
"IndexerProxies": "Proxer Indexer",
|
||||
"IndexerNoDefCheckMessage": "Τα ευρετήρια δεν έχουν ορισμό και δεν θα λειτουργήσουν: {0}. Αφαιρέστε και (ή) προσθέστε ξανά στο {appName}",
|
||||
"IndexerNoDefinitionCheckHealthCheckMessage": "Τα ευρετήρια δεν έχουν ορισμό και δεν θα λειτουργήσουν: {0}. Αφαιρέστε και (ή) προσθέστε ξανά στο {appName}",
|
||||
"SemiPrivate": "Ημι-ιδιωτικό",
|
||||
"SettingsIndexerLoggingHelpText": "Καταγραφή πρόσθετων δεδομένων ευρετηρίου συμπεριλαμβανομένης της απόκρισης",
|
||||
"SearchTypes": "Τύποι αναζήτησης",
|
||||
@@ -522,5 +522,7 @@
|
||||
"IndexerBeyondHDSettingsSearchTypes": "Τύποι αναζήτησης",
|
||||
"IndexerHDBitsSettingsMediums": "Μεσαίο",
|
||||
"UseSsl": "Χρησιμοποιήστε SSL",
|
||||
"CustomFilter": "Custom Φιλτρα"
|
||||
"CustomFilter": "Custom Φιλτρα",
|
||||
"GrabRelease": "Πιάσε την απελευθέρωση",
|
||||
"ProxyValidationBadRequest": "Αποτυχία δοκιμής διακομιστή μεσολάβησης. StatusCode: {statusCode}"
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"DatabaseMigration": "Database Migration",
|
||||
"Date": "Date",
|
||||
"Dates": "Dates",
|
||||
"Default": "Default",
|
||||
"DefaultCategory": "Default Category",
|
||||
"DefaultNameCopiedProfile": "{name} - Copy",
|
||||
"Delete": "Delete",
|
||||
@@ -286,6 +287,7 @@
|
||||
"GeneralSettingsSummary": "Port, SSL, username/password, proxy, analytics, and updates",
|
||||
"Genre": "Genre",
|
||||
"GoToApplication": "Go to application",
|
||||
"GrabRelease": "Grab Release",
|
||||
"GrabReleases": "Grab Release(s)",
|
||||
"GrabTitle": "Grab Title",
|
||||
"Grabbed": "Grabbed",
|
||||
@@ -366,7 +368,7 @@
|
||||
"IndexerNewznabSettingsAdditionalParametersHelpText": "Additional Newznab parameters",
|
||||
"IndexerNewznabSettingsApiKeyHelpText": "Site API Key",
|
||||
"IndexerNewznabSettingsVipExpirationHelpText": "Enter date (yyyy-mm-dd) for VIP Expiration or blank, {appName} will notify 1 week from expiration of VIP",
|
||||
"IndexerNoDefCheckMessage": "Indexers have no definition and will not work: {0}. Please remove and (or) re-add to {appName}",
|
||||
"IndexerNoDefinitionCheckHealthCheckMessage": "Indexers have no definition and will not work: {indexerNames}. Please remove and (or) re-add to {appName}.",
|
||||
"IndexerNzbIndexSettingsApiKeyHelpText": "Site API Key",
|
||||
"IndexerObsoleteCheckMessage": "Indexers are obsolete or have been updated: {0}. Please remove and (or) re-add to {appName}",
|
||||
"IndexerOrpheusSettingsApiKeyHelpText": "API Key from the Site (Found in Settings => Access Settings)",
|
||||
@@ -448,6 +450,7 @@
|
||||
"ManageClients": "Manage Clients",
|
||||
"ManageDownloadClients": "Manage Download Clients",
|
||||
"Manual": "Manual",
|
||||
"ManualGrab": "Manual Grab",
|
||||
"MappedCategories": "Mapped Categories",
|
||||
"MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information",
|
||||
"MassEditor": "Mass Editor",
|
||||
@@ -508,9 +511,12 @@
|
||||
"OnHealthIssueHelpText": "On Health Issue",
|
||||
"OnHealthRestored": "On Health Restored",
|
||||
"OnHealthRestoredHelpText": "On Health Restored",
|
||||
"Open": "Open",
|
||||
"OpenBrowserOnStart": "Open browser on start",
|
||||
"OpenThisModal": "Open This Modal",
|
||||
"Options": "Options",
|
||||
"OverrideAndAddToDownloadClient": "Override and add to download client",
|
||||
"OverrideGrabModalTitle": "Override and Grab - {title}",
|
||||
"PackSeedTime": "Pack Seed Time",
|
||||
"PackSeedTimeHelpText": "The time a pack (season or discography) torrent should be seeded before stopping, empty is app's default",
|
||||
"PackageVersion": "Package Version",
|
||||
@@ -527,6 +533,7 @@
|
||||
"PortNumber": "Port Number",
|
||||
"Presets": "Presets",
|
||||
"Priority": "Priority",
|
||||
"PrioritySettings": "Priority: {priority}",
|
||||
"Privacy": "Privacy",
|
||||
"Private": "Private",
|
||||
"Protocol": "Protocol",
|
||||
@@ -543,6 +550,8 @@
|
||||
"ProxyResolveIpHealthCheckMessage": "Failed to resolve the IP Address for the Configured Proxy Host {proxyHostName}",
|
||||
"ProxyType": "Proxy Type",
|
||||
"ProxyUsernameHelpText": "You only need to enter a username and password if one is required. Leave them blank otherwise.",
|
||||
"ProxyValidationBadRequest": "Failed to test proxy. Status code: {statusCode}",
|
||||
"ProxyValidationUnableToConnect": "Unable to connect to proxy: {exceptionMessage}. Check the log surrounding this error for details",
|
||||
"Public": "Public",
|
||||
"Publisher": "Publisher",
|
||||
"Query": "Query",
|
||||
@@ -610,6 +619,7 @@
|
||||
"SeedTimeHelpText": "The time a torrent should be seeded before stopping, empty is app's default",
|
||||
"Seeders": "Seeders",
|
||||
"SelectAll": "Select All",
|
||||
"SelectDownloadClientModalTitle": "{modalTitle} - Select Download Client",
|
||||
"SelectIndexers": "Select Indexers",
|
||||
"SelectedCountOfCountReleases": "Selected {selectedCount} of {itemCount} releases",
|
||||
"SemiPrivate": "Semi-Private",
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
"View": "Vista",
|
||||
"Updates": "Actualizaciones",
|
||||
"UpdateUiNotWritableHealthCheckMessage": "No se puede instalar la actualización porque la carpeta de interfaz de usuario '{uiFolder}' no es modificable por el usuario '{userName}'.",
|
||||
"UpdateStartupTranslocationHealthCheckMessage": "No se puede instalar la actualización porque la carpeta de arranque '{startupFolder}' está en una carpeta de \"App Translocation\".",
|
||||
"UpdateStartupTranslocationHealthCheckMessage": "No se puede instalar la actualización porque la carpeta de arranque '{startupFolder}' está en una carpeta de translocación de aplicaciones.",
|
||||
"UpdateStartupNotWritableHealthCheckMessage": "No se puede instalar la actualización porque la carpeta de arranque '{startupFolder}' no tiene permisos de escritura para el usuario '{userName}'.",
|
||||
"UnselectAll": "Desmarcar todo",
|
||||
"UI": "UI",
|
||||
"UI": "Interfaz",
|
||||
"Tasks": "Tareas",
|
||||
"Tags": "Etiquetas",
|
||||
"System": "Sistema",
|
||||
@@ -167,7 +167,7 @@
|
||||
"URLBase": "URL base",
|
||||
"Uptime": "Tiempo de actividad",
|
||||
"UpdateScriptPathHelpText": "Ruta a un script personalizado que toma un paquete de actualización extraído y gestiona el resto del proceso de actualización",
|
||||
"UpdateMechanismHelpText": "Usar el actualizador incorporado de {appName} o un script",
|
||||
"UpdateMechanismHelpText": "Usar el actualizador integrado de {appName} o un script",
|
||||
"UpdateAutomaticallyHelpText": "Descargar e instalar actualizaciones automáticamente. Todavía puedes instalar desde Sistema: Actualizaciones",
|
||||
"UnableToLoadTags": "No se pueden cargar las Etiquetas",
|
||||
"UnableToLoadNotifications": "No se pueden cargar las Notificaciones",
|
||||
@@ -214,7 +214,7 @@
|
||||
"LogLevel": "Nivel de Registro",
|
||||
"LaunchBrowserHelpText": " Abrir un navegador web e ir a la página de inicio de {appName} al arrancar la app.",
|
||||
"Interval": "Intervalo",
|
||||
"IndexerFlags": "Banderas del indexador",
|
||||
"IndexerFlags": "Indicadores del indexador",
|
||||
"IncludeHealthWarningsHelpText": "Incluir Alertas de Salud",
|
||||
"IllRestartLater": "Lo reiniciaré más tarde",
|
||||
"IgnoredAddresses": "Ignorar direcciones",
|
||||
@@ -250,12 +250,12 @@
|
||||
"UnableToLoadGeneralSettings": "No se han podido cargar los ajustes Generales",
|
||||
"UnableToLoadBackups": "No se pudo cargar las copias de seguridad",
|
||||
"UnableToAddANewNotificationPleaseTryAgain": "No se ha podido añadir una nueva notificación, prueba otra vez.",
|
||||
"UnableToAddANewIndexerPleaseTryAgain": "No se ha podido añadir un nuevo indexer, prueba otra vez.",
|
||||
"UnableToAddANewIndexerPleaseTryAgain": "No se pudo añadir un nuevo indexador, por favor inténtalo de nuevo.",
|
||||
"UnableToAddANewDownloadClientPleaseTryAgain": "No se ha podido añadir un nuevo gestor de descargas, prueba otra vez.",
|
||||
"TagIsNotUsedAndCanBeDeleted": "La etiqueta no se usa y puede ser borrada",
|
||||
"StartTypingOrSelectAPathBelow": "Comienza a escribir o selecciona una ruta debajo",
|
||||
"Restore": "Restaurar",
|
||||
"ProwlarrSupportsAnyIndexer": "{appName} soporta cualquier indexer que utilice el estandar Newznab, como también cualquiera de los indexers listados debajo.",
|
||||
"ProwlarrSupportsAnyIndexer": "{appName} soporta muchos indexadores junto a cualquier indexador que utilice el estándar Newznab/Torznab usando 'Newznab genérico' (para usenet) o 'Torznab genérico' (para torrents). Busca y selecciona tu indexador a continuación.",
|
||||
"ProwlarrSupportsAnyDownloadClient": "{appName} soporta cualquier gestor de descargas indicado debajo.",
|
||||
"NoUpdatesAreAvailable": "No hay actualizaciones disponibles",
|
||||
"NoTagsHaveBeenAddedYet": "Ninguna etiqueta ha sido añadida aún",
|
||||
@@ -271,9 +271,9 @@
|
||||
"UILanguage": "Lenguaje de UI",
|
||||
"Priority": "Prioridad",
|
||||
"InteractiveSearch": "Búsqueda Interactiva",
|
||||
"IndexerPriorityHelpText": "Prioridad del Indexer de 1 (La más alta) a 50 (La más baja). Por defecto: 25.",
|
||||
"IndexerPriorityHelpText": "Prioridad del indexador de 1 (la más alta) a 50 (la más baja). Predeterminado: 25.",
|
||||
"IndexerPriority": "Prioridad del indexador",
|
||||
"EditIndexer": "Editar Indexer",
|
||||
"EditIndexer": "Editar indexador",
|
||||
"Disabled": "Deshabilitado",
|
||||
"AutomaticSearch": "Búsqueda Automática",
|
||||
"AddIndexer": "Añadir Indexador",
|
||||
@@ -284,8 +284,8 @@
|
||||
"MovieIndexScrollBottom": "Indice de Películas: Desplazar hacia abajo",
|
||||
"CloseCurrentModal": "Cerrar esta Ventana Modal",
|
||||
"AcceptConfirmationModal": "Aceptar Confirmación de esta Ventana Modal",
|
||||
"IndexerLongTermStatusUnavailableHealthCheckMessage": "Indexers no disponible por errores durando más de 6 horas: {indexerNames}",
|
||||
"IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Ningún indexer está disponible por errores durando más de 6 horas",
|
||||
"IndexerLongTermStatusUnavailableHealthCheckMessage": "Indexadores no disponibles debido a errores durante más de 6 horas: {indexerNames}",
|
||||
"IndexerLongTermStatusAllUnavailableHealthCheckMessage": "Ningún indexador está disponible debido a errores durante más de 6 horas",
|
||||
"Reddit": "Reddit",
|
||||
"UnableToAddANewAppProfilePleaseTryAgain": "No se ha podido añadir un nuevo perfil de calidad, prueba otra vez.",
|
||||
"DeleteIndexerProxyMessageText": "¿Seguro que quieres eliminar el proxy indexador '{name}'?",
|
||||
@@ -295,21 +295,21 @@
|
||||
"Donations": "Donaciones",
|
||||
"SearchIndexers": "Buscar Indexadores",
|
||||
"Enabled": "Habilitado",
|
||||
"Grabs": "Capturar",
|
||||
"Grabs": "Capturas",
|
||||
"Presets": "Preajustes",
|
||||
"Rss": "RSS",
|
||||
"Today": "Hoy",
|
||||
"Tomorrow": "Mañana",
|
||||
"Torrent": "Torrents",
|
||||
"UnableToAddANewIndexerProxyPleaseTryAgain": "No se ha podido añadir un nuevo indexer, prueba otra vez.",
|
||||
"UnableToAddANewIndexerProxyPleaseTryAgain": "No se pudo añadir un nuevo proxy de indexador, por favor inténtalo de nuevo.",
|
||||
"Wiki": "Wiki",
|
||||
"Yesterday": "Ayer",
|
||||
"ApplicationStatusCheckAllClientMessage": "Las listas no están disponibles debido a errores",
|
||||
"ApplicationStatusCheckSingleClientMessage": "Listas no disponibles debido a errores: {0}",
|
||||
"AllIndexersHiddenDueToFilter": "Todos los indexadores están ocultas debido al filtro aplicado.",
|
||||
"DeleteApplicationMessageText": "Seguro que quieres eliminar la notificación '{name}'?",
|
||||
"IndexerProxyStatusAllUnavailableHealthCheckMessage": "Los indexers no están disponibles debido a errores",
|
||||
"IndexerProxyStatusUnavailableHealthCheckMessage": "Indexers no disponibles debido a errores: {indexerProxyNames}",
|
||||
"IndexerProxyStatusAllUnavailableHealthCheckMessage": "Ningún indexador disponible debido a errores",
|
||||
"IndexerProxyStatusUnavailableHealthCheckMessage": "Proxies de indexador no disponibles debido a errores: {indexerProxyNames}",
|
||||
"NoLinks": "Sin enlaces",
|
||||
"AddDownloadClient": "Añadir Cliente de Descarga",
|
||||
"CouldNotConnectSignalR": "No se pudo conectar a SignalR, la interfaz de usuario no se actualiza",
|
||||
@@ -342,13 +342,13 @@
|
||||
"Encoding": "Codificación",
|
||||
"GrabReleases": "Capturar Lanzamiento(s)",
|
||||
"Link": "Enlace",
|
||||
"MappedDrivesRunningAsService": "Las unidades de red asignadas no están disponibles cuando se ejecutan como un servicio de Windows. Consulte las preguntas frecuentes para obtener más información",
|
||||
"MappedDrivesRunningAsService": "Las unidades de red asignadas no están disponibles cuando se ejecutan como un servicio de Windows. Consulta las preguntas frecuentes para obtener más información",
|
||||
"No": "No",
|
||||
"Notification": "Notificaciones",
|
||||
"Notification": "Notificación",
|
||||
"Notifications": "Notificaciones",
|
||||
"UnableToLoadIndexers": "No se pueden cargar los indexadores",
|
||||
"Yes": "Sí",
|
||||
"UserAgentProvidedByTheAppThatCalledTheAPI": "User-Agent proporcionado por la aplicación llamó a la API",
|
||||
"UserAgentProvidedByTheAppThatCalledTheAPI": "User-Agent proporcionado por la aplicación que llamó a la API",
|
||||
"InstanceName": "Nombre de la Instancia",
|
||||
"InstanceNameHelpText": "Nombre de la instancia en la pestaña y para la aplicación Syslog",
|
||||
"Database": "Base de datos",
|
||||
@@ -374,9 +374,9 @@
|
||||
"Theme": "Tema",
|
||||
"ApplyTagsHelpTextAdd": "Añadir: Añade las etiquetas a la lista de etiquetas existente",
|
||||
"DeleteSelectedApplicationsMessageText": "¿Está seguro de querer eliminar {count} aplicación(es) de seleccionada(s)?",
|
||||
"DeleteSelectedDownloadClients": "Borrar Cliente de Descarga(s)",
|
||||
"DeleteSelectedIndexersMessageText": "¿Está seguro de querer eliminar {count} indexador(es) seleccionado(s)?",
|
||||
"DeleteSelectedDownloadClientsMessageText": "¿Está seguro de querer eliminar {count} cliente(s) de descarga seleccionado(s)?",
|
||||
"DeleteSelectedDownloadClients": "Borrar cliente(s) de descarga",
|
||||
"DeleteSelectedIndexersMessageText": "¿Estás seguro que quieres eliminar {count} indexador(es) seleccionado(s)?",
|
||||
"DeleteSelectedDownloadClientsMessageText": "¿Estás seguro que quieres eliminar {count} cliente(s) de descarga seleccionado(s)?",
|
||||
"ApplyTagsHelpTextHowToApplyApplications": "Cómo aplicar etiquetas a las aplicaciones seleccionadas",
|
||||
"SelectIndexers": "Seleccionar Indexadores",
|
||||
"ApplyTagsHelpTextHowToApplyIndexers": "Cómo aplicar etiquetas a los indexadores seleccionados",
|
||||
@@ -386,7 +386,7 @@
|
||||
"DownloadClientPriorityHelpText": "Priorizar varios Clientes de Descarga. Round-Robin se utiliza para clientes con la misma prioridad.",
|
||||
"Season": "Temporada",
|
||||
"More": "Más",
|
||||
"Track": "Rastro",
|
||||
"Track": "Pista",
|
||||
"Year": "Año",
|
||||
"UpdateAvailableHealthCheckMessage": "La nueva actualización está disponible",
|
||||
"Genre": "Género",
|
||||
@@ -402,17 +402,17 @@
|
||||
"ApiKeyValidationHealthCheckMessage": "Actualice su clave de API para que tenga al menos {length} carácteres. Puede hacerlo en los ajustes o en el archivo de configuración",
|
||||
"IndexerDownloadClientHealthCheckMessage": "Indexadores con clientes de descarga inválidos: {indexerNames}.",
|
||||
"Episode": "Episodio",
|
||||
"ConnectionLostReconnect": "{appName} intentará conectarse automáticamente, o puede hacer clic en recargar abajo.",
|
||||
"ConnectionLostToBackend": "{appName} ha perdido su conexión con el backend y tendrá que ser recargado para recuperar su funcionalidad.",
|
||||
"ConnectionLostReconnect": "{appName} intentará conectarse automáticamente, o puedes pulsar en recargar abajo.",
|
||||
"ConnectionLostToBackend": "{appName} ha perdido su conexión con el backend y tendrá que ser recargado para restaurar su funcionalidad.",
|
||||
"RecentChanges": "Cambios recientes",
|
||||
"WhatsNew": "Que es lo nuevo?",
|
||||
"minutes": "Minutos",
|
||||
"WhatsNew": "¿Qué hay nuevo?",
|
||||
"minutes": "minutos",
|
||||
"Album": "Álbum",
|
||||
"Artist": "Artista",
|
||||
"DeleteAppProfileMessageText": "¿Estás seguro de que quieres eliminar el perfil de la aplicación '{name}'?",
|
||||
"AddConnection": "Añadir Conexión",
|
||||
"NotificationStatusAllClientHealthCheckMessage": "Las notificaciones no están disponibles debido a fallos",
|
||||
"NotificationStatusSingleClientHealthCheckMessage": "Listas no disponibles debido a errores: {notificationNames}",
|
||||
"NotificationStatusAllClientHealthCheckMessage": "Las notificaciones no están disponibles debido a errores",
|
||||
"NotificationStatusSingleClientHealthCheckMessage": "Notificaciones no disponibles debido a errores: {notificationNames}",
|
||||
"EditIndexerImplementation": "Editar Indexador - {implementationName}",
|
||||
"AuthBasic": "Básico (ventana emergente del navegador)",
|
||||
"AuthForm": "Formularios (Página de inicio de sesión)",
|
||||
@@ -496,7 +496,7 @@
|
||||
"InvalidUILanguage": "Su interfaz de usuario está configurada en un idioma no válido, corríjalo y guarde la configuración",
|
||||
"DownloadClientQbittorrentSettingsContentLayout": "Diseño del contenido",
|
||||
"DownloadClientQbittorrentSettingsContentLayoutHelpText": "Si usar el diseño de contenido configurado de qBittorrent, el diseño original del torrent o siempre crear una subcarpeta (qBittorrent 4.3.2+)",
|
||||
"EnableRssHelpText": "Habilitar feed RSS para el Indexador",
|
||||
"EnableRssHelpText": "Habilitar canal RSS para el Indexador",
|
||||
"days": "días",
|
||||
"ElapsedTime": "Tiempo transcurrido",
|
||||
"GrabTitle": "Capturar título",
|
||||
@@ -505,29 +505,29 @@
|
||||
"Redirect": "Redirección",
|
||||
"RssQueries": "Consultas RSS",
|
||||
"SeedRatio": "Proporción de Semillado",
|
||||
"RssFeed": "Feed RSS",
|
||||
"RssFeed": "Canal RSS",
|
||||
"SearchType": "Tipo de búsqueda",
|
||||
"RepeatSearch": "Repetir búsqueda",
|
||||
"SeedRatioHelpText": "El ratio que un torrent debe alcanzar antes de detenerse, si está vacío se usará el valor por defecto",
|
||||
"SeedTime": "Tiempo de Semillado",
|
||||
"SearchTypes": "Tipos de búsquedas",
|
||||
"DeleteIndexerProxy": "Eliminar proxy indexador",
|
||||
"OnGrabHelpText": "Al Capturar lanzamiento",
|
||||
"SeedTimeHelpText": "El ratio que un torrent debe alcanzar antes de detenerse, si está vacío se usará el valor por defecto",
|
||||
"OnGrabHelpText": "Al capturar lanzamiento",
|
||||
"SeedTimeHelpText": "El tiempo que un torrent debería ser sembrado antes de detenerse, si está vacío usa el valor predeterminado",
|
||||
"IndexerTagsHelpTextWarning": "Las etiquetas deben utilizarse con precaución, ya que pueden tener efectos no deseados. Un indexador con una etiqueta solo se sincronizará con aplicaciones que tengan la misma etiqueta.",
|
||||
"TVSearchTypes": "Tipos de búsquedas",
|
||||
"TVSearchTypes": "Tipos de búsqueda de TV",
|
||||
"DownloadClientAriaSettingsDirectoryHelpText": "Ubicación opcional en la que poner las descargas, dejar en blanco para usar la ubicación de Aria2 predeterminada",
|
||||
"IndexerNoDefCheckMessage": "Los indexadores no tienen definición y no funcionarán: {0}. Por favor elimínelos y (o) vuelva a añadirlos a {appName}",
|
||||
"IndexerNoDefinitionCheckHealthCheckMessage": "Los indexadores no tienen definición y no funcionarán: {0}. Por favor elimínelos y (o) vuelva a añadirlos a {appName}",
|
||||
"IndexerProxy": "Proxy del Indexador",
|
||||
"IndexerObsoleteCheckMessage": "Los indexadores están obsoletos o se han actualizado: {0}. Por favor elimínelos y (o) vuelva a añadirlos a {appName}",
|
||||
"IncludeManualGrabsHelpText": "Incluir las Capturas Manuales realizadas en {appName}",
|
||||
"IndexerRss": "RSS del Indexador",
|
||||
"IndexerSettingsSummary": "Configurar varios ajustes globales del Indexador, incluyendo Proxies.",
|
||||
"IndexerName": "Nombre del Indexador",
|
||||
"IndexerVipExpiringHealthCheckMessage": "Las ventajas VIP de los indexadores expiran pronto: {indexerNames}",
|
||||
"IndexerVipExpiringHealthCheckMessage": "Las ventajas VIP del indexador expiran pronto: {indexerNames}",
|
||||
"IndexerProxies": "Proxies del Indexador",
|
||||
"IndexerHistoryLoadError": "Error al cargar el historial del Indexador",
|
||||
"IndexerVipExpiredHealthCheckMessage": "Las ventajas VIP del Indexador han caducado: {indexerNames}",
|
||||
"IndexerVipExpiredHealthCheckMessage": "Las ventajas VIP del indexador han expirado: {indexerNames}",
|
||||
"IndexerQuery": "Consulta dedl Indexador",
|
||||
"IndexerSite": "Sitio del Indexador",
|
||||
"IndexerStatus": "Estado del indexador",
|
||||
@@ -613,7 +613,7 @@
|
||||
"IndexerHDBitsSettingsPasskeyHelpText": "Clave de acceso desde los Detalles de Usuario",
|
||||
"IndexerSettingsPasskey": "Clave de Acceso",
|
||||
"BlackholeFolderHelpText": "La carpeta en donde {appName} se almacenaran los {extension} file",
|
||||
"CustomFilter": "Filtros personalizados",
|
||||
"CustomFilter": "Filtro personalizado",
|
||||
"LabelIsRequired": "Se requiere etiqueta",
|
||||
"TorrentBlackholeSaveMagnetFiles": "Guardar archivos magnet",
|
||||
"TorrentBlackholeTorrentFolder": "Carpeta de torrent",
|
||||
@@ -645,7 +645,7 @@
|
||||
"DownloadClientRTorrentSettingsUrlPath": "Ruta de url",
|
||||
"IndexerHDBitsSettingsOrigins": "Orígenes",
|
||||
"IndexerIPTorrentsSettingsCookieUserAgentHelpText": "User-Agent asociado con la cookie usada desde el navegador",
|
||||
"IndexerSettingsSeedRatioHelpText": "El ratio que un torrent debería alcanzar antes de detenerse, vacío usa el predeterminado del cliente de descarga. El ratio debería ser al menos 1.0 y seguir las reglas de los indexadores",
|
||||
"IndexerSettingsSeedRatioHelpText": "El ratio que un torrent debería alcanzar antes de parar, vacío usa el predeterminado del cliente de descarga. El ratio debería ser al menos 1.0 y seguir las reglas de los indexadores",
|
||||
"ApplicationSettingsSyncRejectBlocklistedTorrentHashes": "Sincronizar rechazo de hashes de torrents en la lista de bloqueo mientras se captura",
|
||||
"IndexerGazelleGamesSettingsApiKeyHelpTextWarning": "Debes tener permisos de usuario y de torrents",
|
||||
"IndexerGazelleGamesSettingsSearchGroupNamesHelpText": "Busca lanzamientos por nombres de grupo",
|
||||
@@ -679,7 +679,7 @@
|
||||
"IndexerSettingsCookie": "Cookie",
|
||||
"IndexerSettingsRssKey": "Clave RSS",
|
||||
"IndexerSettingsSeedRatio": "Ratio de sembrado",
|
||||
"IndexerSettingsSeedTimeHelpText": "El tiempo que un torrent debería ser compartido antes de detenerse, vació usa el predeterminado del cliente de descarga",
|
||||
"IndexerSettingsSeedTimeHelpText": "El tiempo que un torrent debería ser sembrado antes de parar, vacío usa el predeterminado del cliente de descarga",
|
||||
"IndexerMTeamTpSettingsApiKeyHelpText": "Clave API del sitio (encontrada en Panel de control de usuario => Seguridad => Laboratorio)",
|
||||
"IndexerMTeamTpSettingsFreeleechOnlyHelpText": "Buscar solo lanzamientos freeleech",
|
||||
"Menu": "Menú",
|
||||
@@ -702,7 +702,7 @@
|
||||
"IndexerBeyondHDSettingsFreeleechOnlyHelpText": "Buscar solo lanzamientos freeleech",
|
||||
"IndexerBeyondHDSettingsLimitedOnlyHelpText": "Buscar solo freeleech (UL limitada)",
|
||||
"IndexerFileListSettingsFreeleechOnlyHelpText": "Buscar solo lanzamientos freeleech",
|
||||
"IndexerFileListSettingsPasskeyHelpText": "Clave de acceso del sitio (esto es la cadena alfanumérica en la url del tracket mostrada en tu cliente de descarga)",
|
||||
"IndexerFileListSettingsPasskeyHelpText": "Clave de acceso del sitio (Esto es la cadena alfanumérica en la url del tracker mostrada en tu cliente de descarga)",
|
||||
"IndexerFileListSettingsUsernameHelpText": "Usuario del sitio",
|
||||
"IndexerHDBitsSettingsMediums": "Medios",
|
||||
"IndexerHDBitsSettingsCodecs": "Códecs",
|
||||
@@ -749,5 +749,16 @@
|
||||
"IndexerSettingsVipExpiration": "Expiración del VIP",
|
||||
"XmlRpcPath": "Ruta RPC de XML",
|
||||
"NotificationsTelegramSettingsIncludeAppName": "Incluir {appName} en el título",
|
||||
"NotificationsTelegramSettingsIncludeAppNameHelpText": "Opcionalmente prefija el título del mensaje con {appName} para diferenciar las notificaciones de las diferentes aplicaciones"
|
||||
"NotificationsTelegramSettingsIncludeAppNameHelpText": "Opcionalmente prefija el título del mensaje con {appName} para diferenciar las notificaciones de las diferentes aplicaciones",
|
||||
"IndexerGazelleGamesSettingsFreeleechOnlyHelpText": "Busca solo lanzamientos freeleech",
|
||||
"ProxyValidationBadRequest": "Fallo al probar el proxy. Código de estado: {statusCode}",
|
||||
"ProxyValidationUnableToConnect": "No se pudo conectar al proxy: {exceptionMessage}. Comprueba el registro de este error para más detalles",
|
||||
"GrabRelease": "Capturar lanzamiento",
|
||||
"ManualGrab": "Captura manual",
|
||||
"Open": "Abrir",
|
||||
"OverrideAndAddToDownloadClient": "Sobrescribir y añadir al cliente de descarga",
|
||||
"OverrideGrabModalTitle": "Sobrescribir y capturar - {title}",
|
||||
"PrioritySettings": "Prioridad: {priority}",
|
||||
"SelectDownloadClientModalTitle": "{modalTitle} - Seleccionar cliente de descarga",
|
||||
"Default": "Predeterminado"
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@
|
||||
"ApplicationsLoadError": "Sovelluslistausta ei voitu ladata",
|
||||
"Url": "URL",
|
||||
"Website": "Verkkosivusto",
|
||||
"IndexerNoDefCheckMessage": "Tietolähteillä ei ole määritystä, eivätkä ne toimi: {0}. Poista ja/tai lisää {appName}iin uudelleen",
|
||||
"IndexerNoDefinitionCheckHealthCheckMessage": "Tietolähteillä ei ole määritystä, eivätkä ne toimi: {0}. Poista ja/tai lisää {appName}iin uudelleen",
|
||||
"Private": "Yksityinen",
|
||||
"QueryResults": "Kyselyn tulokset",
|
||||
"Application": "Sovellus",
|
||||
@@ -668,7 +668,7 @@
|
||||
"DownloadClientFreeboxSettingsPortHelpText": "Freebox-liittymän portti. Oletus on \"{port}\".",
|
||||
"DownloadClientPneumaticSettingsNzbFolder": "NZB-kansio",
|
||||
"DownloadClientQbittorrentSettingsSequentialOrder": "Peräkkäinen järjestys",
|
||||
"CustomFilter": "Omat suodattimet",
|
||||
"CustomFilter": "Oma suodatin",
|
||||
"DownloadClientFreeboxSettingsAppIdHelpText": "Freebox-rajapinnan käyttöoikeutta määritettäessä käytettävä App ID -sovellustunniste.",
|
||||
"DownloadClientFreeboxSettingsAppToken": "Sovellustietue",
|
||||
"DownloadClientNzbgetSettingsAddPausedHelpText": "Tämä vaatii vähintään NzbGet-version 16.0.",
|
||||
@@ -680,5 +680,15 @@
|
||||
"TorrentBlackholeSaveMagnetFilesHelpText": "Tallenna magnet-linkki, jos .torrent-tiedostoa ei ole käytettävissä (hyödyllinen vain lataustyökalun tukiessa tiedostoon tallennettuja magnet-linkkejä).",
|
||||
"TorrentBlackholeTorrentFolder": "Torrent-kansio",
|
||||
"TorrentBlackholeSaveMagnetFilesExtension": "Tallennettujen magnet-tiedostojen pääte",
|
||||
"TorrentBlackholeSaveMagnetFilesExtensionHelpText": "Magnet-linkeille käytettävä tiedostopääte. Oletus on \".magnet\"."
|
||||
"TorrentBlackholeSaveMagnetFilesExtensionHelpText": "Magnet-linkeille käytettävä tiedostopääte. Oletus on \".magnet\".",
|
||||
"LabelIsRequired": "Nimi on pakollinen",
|
||||
"Default": "Oletus",
|
||||
"GrabRelease": "Kaappaa julkaisu",
|
||||
"OverrideGrabModalTitle": "Ohita ja sieppaa - {title}",
|
||||
"PrioritySettings": "Painotus: {priority}",
|
||||
"SelectDownloadClientModalTitle": "{modalTitle} - Valitse lataustyökalu",
|
||||
"ProxyValidationBadRequest": "Välityspalvelintesti epäonnistui. Tilakoodi: {statusCode}.",
|
||||
"ProxyValidationUnableToConnect": "Tietolähdettä ei tavoiteta: {exceptionMessage}. Etsi tietoja tämän virheen lähellä olevista lokimerkinnöistä.",
|
||||
"ManualGrab": "Manuaalinen kaappaus",
|
||||
"OverrideAndAddToDownloadClient": "Ohita ja lisää latausjonoon"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user