mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2026-04-17 21:44:48 -04:00
Compare commits
111 Commits
v1.8.5.389
...
v1.9.4.403
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3ee00e1f | ||
|
|
dace1982d6 | ||
|
|
980bd35f95 | ||
|
|
4b2f81bee8 | ||
|
|
30eb481c65 | ||
|
|
29f1c36f54 | ||
|
|
f1c01343bf | ||
|
|
bae79b22ad | ||
|
|
229d879f86 | ||
|
|
d1cee950a4 | ||
|
|
7e32b54547 | ||
|
|
b1f7d30021 | ||
|
|
c41a7e0ccc | ||
|
|
42c533386b | ||
|
|
bdae7a2cdc | ||
|
|
5e8d3542f4 | ||
|
|
d9d2aa8493 | ||
|
|
09bf1500d6 | ||
|
|
34464160cb | ||
|
|
bada5fe309 | ||
|
|
b088febbc4 | ||
|
|
1a307b8e21 | ||
|
|
32db2af0ea | ||
|
|
e602862102 | ||
|
|
bd5336e4c4 | ||
|
|
c664eaa9b5 | ||
|
|
b7e57f0c08 | ||
|
|
c06bf0e4ea | ||
|
|
c6db30c35a | ||
|
|
75c30dd318 | ||
|
|
6e7bf55dbd | ||
|
|
eb642dd2f9 | ||
|
|
19a196e2c7 | ||
|
|
93ec6cf89b | ||
|
|
52c6b56a4c | ||
|
|
82688d8a55 | ||
|
|
c81cbc801a | ||
|
|
993d189c61 | ||
|
|
1901af5a51 | ||
|
|
c1b399be39 | ||
|
|
2100e96570 | ||
|
|
3ff144421d | ||
|
|
f37ccba3f9 | ||
|
|
181cb2e0fe | ||
|
|
93c81bb7d3 | ||
|
|
7dd289b5f9 | ||
|
|
09cef8cf94 | ||
|
|
ca08c818e6 | ||
|
|
3e95bc4056 | ||
|
|
e241112915 | ||
|
|
0d98c12fa2 | ||
|
|
a0bcf5c9ae | ||
|
|
e318a47b3a | ||
|
|
b8df720c6c | ||
|
|
9625be723d | ||
|
|
d4b037db78 | ||
|
|
add2988789 | ||
|
|
9869c2272a | ||
|
|
4c8b0c9eec | ||
|
|
43cb22ff2b | ||
|
|
3cabc0589a | ||
|
|
cdb3ed36f6 | ||
|
|
840f2ae3e6 | ||
|
|
3ed6ef0336 | ||
|
|
c2ae0cce03 | ||
|
|
934b908b37 | ||
|
|
6c831f11a6 | ||
|
|
9adbfd2391 | ||
|
|
4a7cc82f0d | ||
|
|
c061c309bd | ||
|
|
0f3a77c336 | ||
|
|
478d5a624f | ||
|
|
3283d144f5 | ||
|
|
1a9ec4febd | ||
|
|
0598211319 | ||
|
|
0b0d6b7590 | ||
|
|
86cec51ebe | ||
|
|
80e5ac4aa9 | ||
|
|
ee5ed0c91b | ||
|
|
ba278930ed | ||
|
|
6449b89eb6 | ||
|
|
73b85e240e | ||
|
|
6338460ff4 | ||
|
|
0463e66881 | ||
|
|
bd75621437 | ||
|
|
9615c1183d | ||
|
|
bbf042ed55 | ||
|
|
98e948dbb2 | ||
|
|
2af9f7eb8d | ||
|
|
96413f99c7 | ||
|
|
d44b946d30 | ||
|
|
fe9cad5697 | ||
|
|
098be3cff6 | ||
|
|
8f2fea0be8 | ||
|
|
8d035c6c1f | ||
|
|
7dbfa74c40 | ||
|
|
caaf50ed9c | ||
|
|
b472a022a6 | ||
|
|
0a439a4a96 | ||
|
|
4410636b97 | ||
|
|
ba3ebc7574 | ||
|
|
2ce49a0785 | ||
|
|
d7df946c2b | ||
|
|
3dd3c80b54 | ||
|
|
0f160707d3 | ||
|
|
b608e38454 | ||
|
|
c873b3ffac | ||
|
|
07b98f4137 | ||
|
|
09606af351 | ||
|
|
1d79b92fca | ||
|
|
fbcf1b03c5 |
@@ -9,13 +9,13 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '1.8.5'
|
||||
majorVersion: '1.9.4'
|
||||
minorVersion: $[counter('minorVersion', 1)]
|
||||
prowlarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
|
||||
sentryOrg: 'servarr'
|
||||
sentryUrl: 'https://sentry.servarr.com'
|
||||
dotnetVersion: '6.0.408'
|
||||
dotnetVersion: '6.0.413'
|
||||
nodeVersion: '16.X'
|
||||
innoVersion: '6.2.0'
|
||||
windowsImage: 'windows-2022'
|
||||
|
||||
@@ -4,14 +4,14 @@ module.exports = {
|
||||
plugins: [
|
||||
// Stage 1
|
||||
'@babel/plugin-proposal-export-default-from',
|
||||
['@babel/plugin-proposal-optional-chaining', { loose }],
|
||||
['@babel/plugin-proposal-nullish-coalescing-operator', { loose }],
|
||||
['@babel/plugin-transform-optional-chaining', { loose }],
|
||||
['@babel/plugin-transform-nullish-coalescing-operator', { loose }],
|
||||
|
||||
// Stage 2
|
||||
'@babel/plugin-proposal-export-namespace-from',
|
||||
'@babel/plugin-transform-export-namespace-from',
|
||||
|
||||
// Stage 3
|
||||
['@babel/plugin-proposal-class-properties', { loose }],
|
||||
['@babel/plugin-transform-class-properties', { loose }],
|
||||
'@babel/plugin-syntax-dynamic-import'
|
||||
],
|
||||
env: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import CommandAppState from './CommandAppState';
|
||||
import HistoryAppState from './HistoryAppState';
|
||||
import IndexerAppState, {
|
||||
IndexerHistoryAppState,
|
||||
IndexerIndexAppState,
|
||||
IndexerStatusAppState,
|
||||
} from './IndexerAppState';
|
||||
@@ -42,6 +44,8 @@ export interface CustomFilter {
|
||||
|
||||
interface AppState {
|
||||
commands: CommandAppState;
|
||||
history: HistoryAppState;
|
||||
indexerHistory: IndexerHistoryAppState;
|
||||
indexerIndex: IndexerIndexAppState;
|
||||
indexerStats: IndexerStatsAppState;
|
||||
indexerStatus: IndexerStatusAppState;
|
||||
|
||||
10
frontend/src/App/State/HistoryAppState.ts
Normal file
10
frontend/src/App/State/HistoryAppState.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Column from 'Components/Table/Column';
|
||||
import History from 'typings/History';
|
||||
|
||||
interface HistoryAppState extends AppSectionState<History> {
|
||||
pageSize: number;
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
export default HistoryAppState;
|
||||
@@ -1,6 +1,7 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import Indexer, { IndexerStatus } from 'Indexer/Indexer';
|
||||
import History from 'typings/History';
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
@@ -34,4 +35,6 @@ interface IndexerAppState
|
||||
|
||||
export type IndexerStatusAppState = AppSectionState<IndexerStatus>;
|
||||
|
||||
export type IndexerHistoryAppState = AppSectionState<History>;
|
||||
|
||||
export default IndexerAppState;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { AppSectionItemState } from 'App/State/AppSectionState';
|
||||
import { Filter } from 'App/State/AppState';
|
||||
import { Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||
import Indexer from 'Indexer/Indexer';
|
||||
import { IndexerStats } from 'typings/IndexerStats';
|
||||
|
||||
export interface IndexerStatsAppState
|
||||
extends AppSectionItemState<IndexerStats> {
|
||||
filterBuilderProps: FilterBuilderProp<Indexer>[];
|
||||
selectedFilterKey: string;
|
||||
filters: Filter[];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Chart from 'chart.js/auto';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { defaultFontFamily } from 'Styles/Variables/fonts';
|
||||
|
||||
function getColors(kind) {
|
||||
|
||||
@@ -39,7 +40,15 @@ class BarChart extends Component {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: this.props.title
|
||||
align: 'start',
|
||||
text: this.props.title,
|
||||
padding: {
|
||||
bottom: 30
|
||||
},
|
||||
font: {
|
||||
size: 14,
|
||||
family: defaultFontFamily
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: this.props.legend
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Chart from 'chart.js/auto';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { defaultFontFamily } from 'Styles/Variables/fonts';
|
||||
|
||||
function getColors(kind) {
|
||||
|
||||
@@ -22,7 +23,15 @@ class DoughnutChart extends Component {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: this.props.title
|
||||
align: 'start',
|
||||
text: this.props.title,
|
||||
padding: {
|
||||
bottom: 30
|
||||
},
|
||||
font: {
|
||||
size: 14,
|
||||
family: defaultFontFamily
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Chart from 'chart.js/auto';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { defaultFontFamily } from 'Styles/Variables/fonts';
|
||||
|
||||
function getColors(index) {
|
||||
|
||||
@@ -36,7 +37,15 @@ class StackedBarChart extends Component {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: this.props.title
|
||||
align: 'start',
|
||||
text: this.props.title,
|
||||
padding: {
|
||||
bottom: 30
|
||||
},
|
||||
font: {
|
||||
size: 14,
|
||||
family: defaultFontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-right: $formLabelRightMarginWidth;
|
||||
padding-top: 8px;
|
||||
min-height: 35px;
|
||||
text-align: end;
|
||||
font-weight: bold;
|
||||
line-height: 35px;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ModalContent.css';
|
||||
|
||||
function ModalContent(props) {
|
||||
@@ -28,6 +29,7 @@ function ModalContent(props) {
|
||||
<Icon
|
||||
name={icons.CLOSE}
|
||||
size={18}
|
||||
title={translate('Close')}
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ class PageHeader extends Component {
|
||||
aria-label="Donate"
|
||||
to="https://prowlarr.com/donate"
|
||||
size={14}
|
||||
title={translate('Donate')}
|
||||
/>
|
||||
<IconButton
|
||||
className={styles.translate}
|
||||
|
||||
@@ -24,6 +24,7 @@ function PageHeaderActionsMenu(props) {
|
||||
<MenuButton className={styles.menuButton} aria-label="Menu Button">
|
||||
<Icon
|
||||
name={icons.INTERACTIVE}
|
||||
title={translate('Menu')}
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
|
||||
@@ -1,58 +1,66 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { PureComponent } from 'react';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import TableRowCell from './TableRowCell';
|
||||
import styles from './RelativeDateCell.css';
|
||||
|
||||
class RelativeDateCell extends PureComponent {
|
||||
function createRelativeDateCellSelector() {
|
||||
return createSelector(createUISettingsSelector(), (uiSettings) => {
|
||||
return {
|
||||
showRelativeDates: uiSettings.showRelativeDates,
|
||||
shortDateFormat: uiSettings.shortDateFormat,
|
||||
longDateFormat: uiSettings.longDateFormat,
|
||||
timeFormat: uiSettings.timeFormat
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function RelativeDateCell(props) {
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
date,
|
||||
includeSeconds,
|
||||
showRelativeDates,
|
||||
shortDateFormat,
|
||||
longDateFormat,
|
||||
timeFormat,
|
||||
component: Component,
|
||||
dispatch,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
const {
|
||||
className,
|
||||
date,
|
||||
includeSeconds,
|
||||
component: Component,
|
||||
dispatch,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
if (!date) {
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
|
||||
useSelector(createRelativeDateCellSelector());
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
title={formatDateTime(date, longDateFormat, timeFormat, { includeSeconds, includeRelativeDay: !showRelativeDates })}
|
||||
{...otherProps}
|
||||
>
|
||||
{getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })}
|
||||
</Component>
|
||||
);
|
||||
if (!date) {
|
||||
return <Component className={className} {...otherProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
title={formatDateTime(date, longDateFormat, timeFormat, {
|
||||
includeSeconds,
|
||||
includeRelativeDay: !showRelativeDates
|
||||
})}
|
||||
{...otherProps}
|
||||
>
|
||||
{getRelativeDate(date, shortDateFormat, showRelativeDates, {
|
||||
timeFormat,
|
||||
includeSeconds,
|
||||
timeForToday: true
|
||||
})}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
RelativeDateCell.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
date: PropTypes.string,
|
||||
includeSeconds: PropTypes.bool.isRequired,
|
||||
showRelativeDates: PropTypes.bool.isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
component: PropTypes.elementType,
|
||||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
@@ -192,7 +192,7 @@ class TableOptionsModal extends Component {
|
||||
<TableOptionsColumnDragSource
|
||||
key={name}
|
||||
name={name}
|
||||
label={label || columnLabel}
|
||||
label={columnLabel || label}
|
||||
isVisible={isVisible}
|
||||
isModifiable={true}
|
||||
index={index}
|
||||
@@ -210,7 +210,7 @@ class TableOptionsModal extends Component {
|
||||
<TableOptionsColumn
|
||||
key={name}
|
||||
name={name}
|
||||
label={label || columnLabel}
|
||||
label={columnLabel || label}
|
||||
isVisible={isVisible}
|
||||
index={index}
|
||||
isModifiable={false}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.markAsFailedButton {
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryDetails from './HistoryDetails';
|
||||
import styles from './HistoryDetailsModal.css';
|
||||
|
||||
function getHeaderTitle(eventType) {
|
||||
switch (eventType) {
|
||||
@@ -33,10 +30,8 @@ function HistoryDetailsModal(props) {
|
||||
eventType,
|
||||
indexer,
|
||||
data,
|
||||
isMarkingAsFailed,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
onMarkAsFailedPress,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
@@ -61,18 +56,6 @@ function HistoryDetailsModal(props) {
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
eventType === 'grabbed' &&
|
||||
<SpinnerButton
|
||||
className={styles.markAsFailedButton}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isMarkingAsFailed}
|
||||
onPress={onMarkAsFailedPress}
|
||||
>
|
||||
Mark as Failed
|
||||
</SpinnerButton>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
@@ -89,10 +72,8 @@ HistoryDetailsModal.propTypes = {
|
||||
eventType: PropTypes.string.isRequired,
|
||||
indexer: PropTypes.object.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
isMarkingAsFailed: PropTypes.bool.isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
onMarkAsFailedPress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
@@ -14,7 +14,7 @@ import HistoryEventTypeCell from './HistoryEventTypeCell';
|
||||
import HistoryRowParameter from './HistoryRowParameter';
|
||||
import styles from './HistoryRow.css';
|
||||
|
||||
const historyParameters = [
|
||||
export const historyParameters = [
|
||||
{ key: historyDataTypes.IMDB_ID, title: 'IMDb' },
|
||||
{ key: historyDataTypes.TMDB_ID, title: 'TMDb' },
|
||||
{ key: historyDataTypes.TVDB_ID, title: 'TVDb' },
|
||||
@@ -353,7 +353,7 @@ class HistoryRow extends Component {
|
||||
|
||||
if (name === 'date') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
date={date}
|
||||
className={styles.date}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './HistoryRowParameter.css';
|
||||
|
||||
class HistoryRowParameter extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
value
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.parameter}>
|
||||
<div className={styles.info}>
|
||||
<span>
|
||||
{
|
||||
title
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.value}
|
||||
>
|
||||
{
|
||||
value
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
HistoryRowParameter.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default HistoryRowParameter;
|
||||
44
frontend/src/History/HistoryRowParameter.tsx
Normal file
44
frontend/src/History/HistoryRowParameter.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './HistoryRowParameter.css';
|
||||
|
||||
interface HistoryRowParameterProps {
|
||||
title: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function HistoryRowParameter(props: HistoryRowParameterProps) {
|
||||
const { title, value } = props;
|
||||
|
||||
const type = title.toLowerCase();
|
||||
|
||||
let link = null;
|
||||
|
||||
if (type === 'imdb') {
|
||||
link = <Link to={`https://imdb.com/title/${value}/`}>{value}</Link>;
|
||||
} else if (type === 'tmdb') {
|
||||
link = (
|
||||
<Link to={`https://www.themoviedb.org/movie/${value}`}>{value}</Link>
|
||||
);
|
||||
} else if (type === 'tvdb') {
|
||||
link = (
|
||||
<Link to={`https://www.thetvdb.com/?tab=series&id=${value}`}>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
} else if (type === 'tvmaze') {
|
||||
link = <Link to={`https://www.tvmaze.com/shows/${value}/_`}>{value}</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.parameter}>
|
||||
<div className={styles.info}>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.value}>{link ? link : value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryRowParameter;
|
||||
@@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import AddIndexerModalContentConnector from './AddIndexerModalContentConnector';
|
||||
import styles from './AddIndexerModal.css';
|
||||
|
||||
@@ -8,6 +9,7 @@ function AddIndexerModal({ isOpen, onModalClose, onSelectIndexer, ...otherProps
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
size={sizes.EXTRA_LARGE}
|
||||
onModalClose={onModalClose}
|
||||
className={styles.modal}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ import TableBody from 'Components/Table/TableBody';
|
||||
import { kinds, scrollDirections } from 'Helpers/Props';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SelectIndexerRowConnector from './SelectIndexerRowConnector';
|
||||
import SelectIndexerRow from './SelectIndexerRow';
|
||||
import styles from './AddIndexerModalContent.css';
|
||||
|
||||
const columns = [
|
||||
@@ -49,6 +49,12 @@ const columns = [
|
||||
label: () => translate('Privacy'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
label: () => translate('Categories'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
@@ -260,7 +266,7 @@ class AddIndexerModalContent extends Component {
|
||||
<TableBody>
|
||||
{
|
||||
filteredIndexers.map((indexer) => (
|
||||
<SelectIndexerRowConnector
|
||||
<SelectIndexerRow
|
||||
key={`${indexer.implementation}-${indexer.name}`}
|
||||
implementation={indexer.implementation}
|
||||
implementationName={indexer.implementationName}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { some } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchIndexerSchema, selectIndexerSchema, setIndexerSchemaSort } from 'Store/Actions/indexerActions';
|
||||
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import AddIndexerModalContent from './AddIndexerModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createClientSideCollectionSelector('indexers.schema'),
|
||||
(indexers) => {
|
||||
createAllIndexersSelector(),
|
||||
(indexers, allIndexers) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
@@ -19,11 +22,19 @@ function createMapStateToProps() {
|
||||
sortKey
|
||||
} = indexers;
|
||||
|
||||
const indexerList = items.map((item) => {
|
||||
const { definitionName } = item;
|
||||
return {
|
||||
...item,
|
||||
isExistingIndexer: some(allIndexers, { definitionName })
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
indexers: items,
|
||||
indexers: indexerList,
|
||||
sortKey,
|
||||
sortDirection
|
||||
};
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
|
||||
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SelectIndexerRow.css';
|
||||
|
||||
class SelectIndexerRow extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
const {
|
||||
implementation,
|
||||
implementationName,
|
||||
name
|
||||
} = this.props;
|
||||
|
||||
this.props.onIndexerSelect({ implementation, implementationName, name });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
protocol,
|
||||
privacy,
|
||||
name,
|
||||
language,
|
||||
description,
|
||||
isExistingIndexer
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={this.onPress}>
|
||||
<TableRowCell className={styles.protocol}>
|
||||
<ProtocolLabel
|
||||
protocol={protocol}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
{name}
|
||||
{
|
||||
isExistingIndexer ?
|
||||
<Icon
|
||||
className={styles.alreadyExistsIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={15}
|
||||
title={translate('IndexerAlreadySetup')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
{language}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
{description}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
{translate(firstCharToUpper(privacy))}
|
||||
</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectIndexerRow.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
protocol: PropTypes.string.isRequired,
|
||||
privacy: PropTypes.string.isRequired,
|
||||
language: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
implementation: PropTypes.string.isRequired,
|
||||
implementationName: PropTypes.string.isRequired,
|
||||
onIndexerSelect: PropTypes.func.isRequired,
|
||||
isExistingIndexer: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default SelectIndexerRow;
|
||||
75
frontend/src/Indexer/Add/SelectIndexerRow.tsx
Normal file
75
frontend/src/Indexer/Add/SelectIndexerRow.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import CapabilitiesLabel from 'Indexer/Index/Table/CapabilitiesLabel';
|
||||
import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel';
|
||||
import { IndexerCapabilities } from 'Indexer/Indexer';
|
||||
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SelectIndexerRow.css';
|
||||
|
||||
interface SelectIndexerRowProps {
|
||||
name: string;
|
||||
protocol: string;
|
||||
privacy: string;
|
||||
language: string;
|
||||
description: string;
|
||||
capabilities: IndexerCapabilities;
|
||||
implementation: string;
|
||||
implementationName: string;
|
||||
isExistingIndexer: boolean;
|
||||
onIndexerSelect(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
function SelectIndexerRow(props: SelectIndexerRowProps) {
|
||||
const {
|
||||
name,
|
||||
protocol,
|
||||
privacy,
|
||||
language,
|
||||
description,
|
||||
capabilities,
|
||||
implementation,
|
||||
implementationName,
|
||||
isExistingIndexer,
|
||||
onIndexerSelect,
|
||||
} = props;
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
onIndexerSelect({ implementation, implementationName, name });
|
||||
}, [implementation, implementationName, name, onIndexerSelect]);
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={onPress}>
|
||||
<TableRowCell className={styles.protocol}>
|
||||
<ProtocolLabel protocol={protocol} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
{name}
|
||||
{isExistingIndexer ? (
|
||||
<Icon
|
||||
className={styles.alreadyExistsIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={15}
|
||||
title={translate('IndexerAlreadySetup')}
|
||||
/>
|
||||
) : null}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>{language}</TableRowCell>
|
||||
|
||||
<TableRowCell>{description}</TableRowCell>
|
||||
|
||||
<TableRowCell>{translate(firstCharToUpper(privacy))}</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<CapabilitiesLabel capabilities={capabilities} />
|
||||
</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectIndexerRow;
|
||||
@@ -1,18 +0,0 @@
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createExistingIndexerSelector from 'Store/Selectors/createExistingIndexerSelector';
|
||||
import SelectIndexerRow from './SelectIndexerRow';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createExistingIndexerSelector(),
|
||||
(isExistingIndexer, dimensions) => {
|
||||
return {
|
||||
isExistingIndexer
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(SelectIndexerRow);
|
||||
@@ -187,6 +187,7 @@ function EditIndexerModalContent(props) {
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
helpText={translate('IndexerTagsHelpText')}
|
||||
helpTextWarning={translate('IndexerTagsHelpTextWarning')}
|
||||
{...tags}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ import AddIndexerModal from 'Indexer/Add/AddIndexerModal';
|
||||
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
|
||||
import NoIndexer from 'Indexer/NoIndexer';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { testAllIndexers } from 'Store/Actions/indexerActions';
|
||||
import { cloneIndexer, testAllIndexers } from 'Store/Actions/indexerActions';
|
||||
import {
|
||||
setIndexerFilter,
|
||||
setIndexerSort,
|
||||
@@ -98,6 +98,15 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
setIsEditIndexerModalOpen(false);
|
||||
}, [setIsEditIndexerModalOpen]);
|
||||
|
||||
const onCloneIndexerPress = useCallback(
|
||||
(id: number) => {
|
||||
dispatch(cloneIndexer({ id }));
|
||||
|
||||
setIsEditIndexerModalOpen(true);
|
||||
},
|
||||
[dispatch, setIsEditIndexerModalOpen]
|
||||
);
|
||||
|
||||
const onAppIndexerSyncPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
@@ -303,6 +312,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSmallScreen={isSmallScreen}
|
||||
onCloneIndexerPress={onCloneIndexerPress}
|
||||
/>
|
||||
|
||||
<IndexerIndexFooter />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { uniqBy } from 'lodash';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { IndexerCapabilities } from 'Indexer/Indexer';
|
||||
@@ -23,14 +24,18 @@ function CapabilitiesLabel(props: CapabilitiesLabelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const nameList = Array.from(
|
||||
new Set(filteredList.map((item) => item.name).sort())
|
||||
const indexerCategories = uniqBy(filteredList, 'id').sort(
|
||||
(a, b) => a.id - b.id
|
||||
);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{nameList.map((category) => {
|
||||
return <Label key={category}>{category}</Label>;
|
||||
{indexerCategories.map((category) => {
|
||||
return (
|
||||
<Label key={category.id} title={`${category.id}`}>
|
||||
{category.name}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredList.length === 0 ? <Label>{'None'}</Label> : null}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||
import Column from 'Components/Table/Column';
|
||||
@@ -27,10 +27,11 @@ interface IndexerIndexRowProps {
|
||||
sortKey: string;
|
||||
columns: Column[];
|
||||
isSelectMode: boolean;
|
||||
onCloneIndexerPress(id: number): void;
|
||||
}
|
||||
|
||||
function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
const { indexerId, columns, isSelectMode } = props;
|
||||
const { indexerId, columns, isSelectMode, onCloneIndexerPress } = props;
|
||||
|
||||
const { indexer, appProfile, status, longDateFormat, timeFormat } =
|
||||
useSelector(createIndexerIndexItemSelector(props.indexerId));
|
||||
@@ -153,6 +154,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
<IndexerTitleLink
|
||||
indexerId={indexerId}
|
||||
indexerName={indexerName}
|
||||
onCloneIndexerPress={onCloneIndexerPress}
|
||||
/>
|
||||
</VirtualTableRowCell>
|
||||
);
|
||||
@@ -202,7 +204,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore ts(2739)
|
||||
<RelativeDateCellConnector
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={added.toString()}
|
||||
@@ -215,7 +217,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore ts(2739)
|
||||
<RelativeDateCellConnector
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
className={styles[name]}
|
||||
date={vipExpiration}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface RowItemData {
|
||||
sortKey: string;
|
||||
columns: Column[];
|
||||
isSelectMode: boolean;
|
||||
onCloneIndexerPress(id: number): void;
|
||||
}
|
||||
|
||||
interface IndexerIndexTableProps {
|
||||
@@ -37,6 +38,7 @@ interface IndexerIndexTableProps {
|
||||
scrollerRef: RefObject<HTMLElement>;
|
||||
isSelectMode: boolean;
|
||||
isSmallScreen: boolean;
|
||||
onCloneIndexerPress(id: number): void;
|
||||
}
|
||||
|
||||
const columnsSelector = createSelector(
|
||||
@@ -49,7 +51,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
||||
style,
|
||||
data,
|
||||
}) => {
|
||||
const { items, sortKey, columns, isSelectMode } = data;
|
||||
const { items, sortKey, columns, isSelectMode, onCloneIndexerPress } = data;
|
||||
|
||||
if (index >= items.length) {
|
||||
return null;
|
||||
@@ -71,6 +73,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
||||
sortKey={sortKey}
|
||||
columns={columns}
|
||||
isSelectMode={isSelectMode}
|
||||
onCloneIndexerPress={onCloneIndexerPress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -89,6 +92,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
isSelectMode,
|
||||
isSmallScreen,
|
||||
scrollerRef,
|
||||
onCloneIndexerPress,
|
||||
} = props;
|
||||
|
||||
const columns = useSelector(columnsSelector);
|
||||
@@ -198,6 +202,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
|
||||
sortKey,
|
||||
columns,
|
||||
isSelectMode,
|
||||
onCloneIndexerPress,
|
||||
}}
|
||||
>
|
||||
{Row}
|
||||
|
||||
@@ -7,10 +7,11 @@ import styles from './IndexerTitleLink.css';
|
||||
interface IndexerTitleLinkProps {
|
||||
indexerName: string;
|
||||
indexerId: number;
|
||||
onCloneIndexerPress(id: number): void;
|
||||
}
|
||||
|
||||
function IndexerTitleLink(props: IndexerTitleLinkProps) {
|
||||
const { indexerName, indexerId } = props;
|
||||
const { indexerName, indexerId, onCloneIndexerPress } = props;
|
||||
|
||||
const [isIndexerInfoModalOpen, setIsIndexerInfoModalOpen] = useState(false);
|
||||
|
||||
@@ -32,6 +33,7 @@ function IndexerTitleLink(props: IndexerTitleLinkProps) {
|
||||
indexerId={indexerId}
|
||||
isOpen={isIndexerInfoModalOpen}
|
||||
onModalClose={onIndexerInfoModalClose}
|
||||
onCloneIndexerPress={onCloneIndexerPress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
139
frontend/src/Indexer/Info/History/IndexerHistory.tsx
Normal file
139
frontend/src/Indexer/Info/History/IndexerHistory.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { IndexerHistoryAppState } from 'App/State/IndexerAppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Indexer from 'Indexer/Indexer';
|
||||
import {
|
||||
clearIndexerHistory,
|
||||
fetchIndexerHistory,
|
||||
} from 'Store/Actions/indexerHistoryActions';
|
||||
import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import IndexerHistoryRow from './IndexerHistoryRow';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'eventType',
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'query',
|
||||
label: () => translate('Query'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'parameters',
|
||||
label: () => translate('Parameters'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
label: () => translate('Source'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
label: () => translate('Details'),
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
function createIndexerHistorySelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.indexerHistory,
|
||||
createUISettingsSelector(),
|
||||
(state: AppState) => state.history.pageSize,
|
||||
(indexerHistory: IndexerHistoryAppState, uiSettings, pageSize) => {
|
||||
return {
|
||||
...indexerHistory,
|
||||
shortDateFormat: uiSettings.shortDateFormat,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface IndexerHistoryProps {
|
||||
indexerId: number;
|
||||
}
|
||||
|
||||
function IndexerHistory(props: IndexerHistoryProps) {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
pageSize,
|
||||
} = useSelector(createIndexerHistorySelector());
|
||||
|
||||
const indexer = useSelector(
|
||||
createIndexerSelectorForHook(props.indexerId)
|
||||
) as Indexer;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
fetchIndexerHistory({ indexerId: props.indexerId, limit: pageSize })
|
||||
);
|
||||
|
||||
return () => {
|
||||
dispatch(clearIndexerHistory());
|
||||
};
|
||||
}, [props, pageSize, dispatch]);
|
||||
|
||||
const hasItems = !!items.length;
|
||||
|
||||
if (isFetching) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!isFetching && !!error) {
|
||||
return (
|
||||
<Alert kind={kinds.DANGER}>{translate('IndexerHistoryLoadError')}</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPopulated && !hasItems && !error) {
|
||||
return <Alert kind={kinds.INFO}>{translate('NoIndexerHistory')}</Alert>;
|
||||
}
|
||||
|
||||
if (isPopulated && hasItems && !error) {
|
||||
return (
|
||||
<Table columns={columns}>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<IndexerHistoryRow
|
||||
key={item.id}
|
||||
indexer={indexer}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default IndexerHistory;
|
||||
23
frontend/src/Indexer/Info/History/IndexerHistoryRow.css
Normal file
23
frontend/src/Indexer/Info/History/IndexerHistoryRow.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.query {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.elapsedTime,
|
||||
.source {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.details {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.parametersContent {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'markAsFailedButton': string;
|
||||
'details': string;
|
||||
'elapsedTime': string;
|
||||
'parametersContent': string;
|
||||
'query': string;
|
||||
'source': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
104
frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx
Normal file
104
frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import HistoryDetailsModal from 'History/Details/HistoryDetailsModal';
|
||||
import HistoryEventTypeCell from 'History/HistoryEventTypeCell';
|
||||
import { historyParameters } from 'History/HistoryRow';
|
||||
import HistoryRowParameter from 'History/HistoryRowParameter';
|
||||
import Indexer from 'Indexer/Indexer';
|
||||
import { HistoryData } from 'typings/History';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './IndexerHistoryRow.css';
|
||||
|
||||
interface IndexerHistoryRowProps {
|
||||
data: HistoryData;
|
||||
date: string;
|
||||
eventType: string;
|
||||
successful: boolean;
|
||||
indexer: Indexer;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function IndexerHistoryRow(props: IndexerHistoryRowProps) {
|
||||
const {
|
||||
data,
|
||||
date,
|
||||
eventType,
|
||||
successful,
|
||||
indexer,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
} = props;
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const onDetailsModalPress = useCallback(() => {
|
||||
setIsDetailsModalOpen(true);
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const onDetailsModalClose = useCallback(() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const parameters = historyParameters.filter(
|
||||
(parameter) =>
|
||||
parameter.key in data && data[parameter.key as keyof HistoryData]
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<HistoryEventTypeCell
|
||||
indexer={indexer}
|
||||
eventType={eventType}
|
||||
data={data}
|
||||
successful={successful}
|
||||
/>
|
||||
|
||||
<TableRowCell className={styles.query}>{data.query}</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<div className={styles.parametersContent}>
|
||||
{parameters.map((parameter) => {
|
||||
return (
|
||||
<HistoryRowParameter
|
||||
key={parameter.key}
|
||||
title={parameter.title}
|
||||
value={data[parameter.key as keyof HistoryData].toString()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCell date={date} />
|
||||
|
||||
<TableRowCell className={styles.source}>
|
||||
{data.source ? data.source : null}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.details}>
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
onPress={onDetailsModalPress}
|
||||
title={translate('HistoryDetails')}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<HistoryDetailsModal
|
||||
isOpen={isDetailsModalOpen}
|
||||
eventType={eventType}
|
||||
data={data}
|
||||
indexer={indexer}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
onModalClose={onDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexerHistoryRow;
|
||||
@@ -7,16 +7,18 @@ interface IndexerInfoModalProps {
|
||||
isOpen: boolean;
|
||||
indexerId: number;
|
||||
onModalClose(): void;
|
||||
onCloneIndexerPress(id: number): void;
|
||||
}
|
||||
|
||||
function IndexerInfoModal(props: IndexerInfoModalProps) {
|
||||
const { isOpen, onModalClose, indexerId } = props;
|
||||
const { isOpen, indexerId, onModalClose, onCloneIndexerPress } = props;
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<Modal size={sizes.LARGE} isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<IndexerInfoModalContent
|
||||
indexerId={indexerId}
|
||||
onModalClose={onModalClose}
|
||||
onCloneIndexerPress={onCloneIndexerPress}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -9,3 +9,47 @@
|
||||
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: -32px;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
bottom: -1px;
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-top: none;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectedTab {
|
||||
border-color: var(--borderColor);
|
||||
border-radius: 0 0 5px 5px;
|
||||
background-color: rgba(239, 239, 239, 0.4);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
composes: modalFooter from '~Components/Modal/ModalFooter.css';
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointExtraSmall) {
|
||||
.modalFooter {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
interface CssExports {
|
||||
'deleteButton': string;
|
||||
'description': string;
|
||||
'modalFooter': string;
|
||||
'selectedTab': string;
|
||||
'tab': string;
|
||||
'tabContent': string;
|
||||
'tabList': string;
|
||||
'tabs': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { uniqBy } from 'lodash';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
import { createSelector } from 'reselect';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
@@ -25,6 +26,7 @@ import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
|
||||
import Indexer from 'Indexer/Indexer';
|
||||
import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import IndexerHistory from './History/IndexerHistory';
|
||||
import styles from './IndexerInfoModalContent.css';
|
||||
|
||||
function createIndexerInfoItemSelector(indexerId: number) {
|
||||
@@ -38,15 +40,18 @@ function createIndexerInfoItemSelector(indexerId: number) {
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = ['details', 'categories', 'history', 'stats'];
|
||||
|
||||
interface IndexerInfoModalContentProps {
|
||||
indexerId: number;
|
||||
onModalClose(): void;
|
||||
onCloneIndexerPress(id: number): void;
|
||||
}
|
||||
|
||||
function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
|
||||
const { indexer } = useSelector(
|
||||
createIndexerInfoItemSelector(props.indexerId)
|
||||
);
|
||||
const { indexerId, onCloneIndexerPress } = props;
|
||||
|
||||
const { indexer } = useSelector(createIndexerInfoItemSelector(indexerId));
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -70,10 +75,19 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
|
||||
const vipExpiration =
|
||||
fields.find((field) => field.name === 'vipExpiration')?.value ?? undefined;
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState(tabs[0]);
|
||||
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
|
||||
const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const onTabSelect = useCallback(
|
||||
(index: number) => {
|
||||
const selectedTab = tabs[index];
|
||||
setSelectedTab(selectedTab);
|
||||
},
|
||||
[setSelectedTab]
|
||||
);
|
||||
|
||||
const onEditIndexerPress = useCallback(() => {
|
||||
setIsEditIndexerModalOpen(true);
|
||||
}, [setIsEditIndexerModalOpen]);
|
||||
@@ -92,220 +106,265 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
|
||||
onModalClose();
|
||||
}, [setIsDeleteIndexerModalOpen, onModalClose]);
|
||||
|
||||
const onCloneIndexerPressWrapper = useCallback(() => {
|
||||
onCloneIndexerPress(id);
|
||||
onModalClose();
|
||||
}, [id, onCloneIndexerPress, onModalClose]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{`${name}`}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FieldSet legend={translate('IndexerDetails')}>
|
||||
<div>
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Id')}
|
||||
data={id}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Description')}
|
||||
data={description ? description : '-'}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Encoding')}
|
||||
data={encoding ? encoding : '-'}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Language')}
|
||||
data={language ?? '-'}
|
||||
/>
|
||||
{vipExpiration ? (
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('VipExpiration')}
|
||||
data={vipExpiration}
|
||||
/>
|
||||
) : null}
|
||||
<DescriptionListItemTitle>
|
||||
{translate('IndexerSite')}
|
||||
</DescriptionListItemTitle>
|
||||
<DescriptionListItemDescription>
|
||||
{baseUrl ? (
|
||||
<Link to={baseUrl}>
|
||||
{baseUrl.replace(/(:\/\/)api\./, '$1')}
|
||||
</Link>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</DescriptionListItemDescription>
|
||||
<DescriptionListItemTitle>
|
||||
{protocol === 'usenet'
|
||||
? translate('NewznabUrl')
|
||||
: translate('TorznabUrl')}
|
||||
</DescriptionListItemTitle>
|
||||
<DescriptionListItemDescription>
|
||||
{`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`}
|
||||
</DescriptionListItemDescription>
|
||||
{tags.length > 0 ? (
|
||||
<>
|
||||
<DescriptionListItemTitle>
|
||||
{translate('Tags')}
|
||||
</DescriptionListItemTitle>
|
||||
<DescriptionListItemDescription>
|
||||
<TagListConnector tags={tags} />
|
||||
</DescriptionListItemDescription>
|
||||
</>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</FieldSet>
|
||||
<Tabs
|
||||
className={styles.tabs}
|
||||
selectedIndex={tabs.indexOf(selectedTab)}
|
||||
onSelect={onTabSelect}
|
||||
>
|
||||
<TabList className={styles.tabList}>
|
||||
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||
{translate('Details')}
|
||||
</Tab>
|
||||
|
||||
<FieldSet legend={translate('SearchCapabilities')}>
|
||||
<div>
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('RawSearchSupported')}
|
||||
data={
|
||||
capabilities.supportsRawSearch
|
||||
? translate('Yes')
|
||||
: translate('No')
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('SearchTypes')}
|
||||
data={
|
||||
capabilities.searchParams.length === 0 ? (
|
||||
translate('NotSupported')
|
||||
) : (
|
||||
<Label kind={kinds.PRIMARY}>
|
||||
{capabilities.searchParams[0]}
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('TVSearchTypes')}
|
||||
data={
|
||||
capabilities.tvSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.tvSearchParams.map((p) => {
|
||||
return (
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('MovieSearchTypes')}
|
||||
data={
|
||||
capabilities.movieSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.movieSearchParams.map((p) => {
|
||||
return (
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('BookSearchTypes')}
|
||||
data={
|
||||
capabilities.bookSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.bookSearchParams.map((p) => {
|
||||
return (
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('MusicSearchTypes')}
|
||||
data={
|
||||
capabilities.musicSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.musicSearchParams.map((p) => {
|
||||
return (
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</FieldSet>
|
||||
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||
{translate('Categories')}
|
||||
</Tab>
|
||||
|
||||
{capabilities?.categories?.length > 0 ? (
|
||||
<FieldSet legend={translate('IndexerCategories')}>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
name: 'id',
|
||||
label: translate('Id'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: translate('Name'),
|
||||
isVisible: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{uniqBy(capabilities.categories, 'id')
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((category) => {
|
||||
return (
|
||||
<TableBody key={category.id}>
|
||||
<TableRow key={category.id}>
|
||||
<TableRowCell>{category.id}</TableRowCell>
|
||||
<TableRowCell>{category.name}</TableRowCell>
|
||||
</TableRow>
|
||||
{category?.subCategories?.length > 0
|
||||
? uniqBy(category.subCategories, 'id')
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((subCategory) => {
|
||||
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||
{translate('History')}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<FieldSet legend={translate('IndexerDetails')}>
|
||||
<div>
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Id')}
|
||||
data={id}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Description')}
|
||||
data={description ? description : '-'}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Encoding')}
|
||||
data={encoding ? encoding : '-'}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Language')}
|
||||
data={language ?? '-'}
|
||||
/>
|
||||
{vipExpiration ? (
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('VipExpiration')}
|
||||
data={vipExpiration}
|
||||
/>
|
||||
) : null}
|
||||
<DescriptionListItemTitle>
|
||||
{translate('IndexerSite')}
|
||||
</DescriptionListItemTitle>
|
||||
<DescriptionListItemDescription>
|
||||
{baseUrl ? (
|
||||
<Link to={baseUrl}>
|
||||
{baseUrl.replace(/(:\/\/)api\./, '$1')}
|
||||
</Link>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</DescriptionListItemDescription>
|
||||
<DescriptionListItemTitle>
|
||||
{protocol === 'usenet'
|
||||
? translate('NewznabUrl')
|
||||
: translate('TorznabUrl')}
|
||||
</DescriptionListItemTitle>
|
||||
<DescriptionListItemDescription>
|
||||
{`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`}
|
||||
</DescriptionListItemDescription>
|
||||
{tags.length > 0 ? (
|
||||
<>
|
||||
<DescriptionListItemTitle>
|
||||
{translate('Tags')}
|
||||
</DescriptionListItemTitle>
|
||||
<DescriptionListItemDescription>
|
||||
<TagListConnector tags={tags} />
|
||||
</DescriptionListItemDescription>
|
||||
</>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('SearchCapabilities')}>
|
||||
<div>
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('RawSearchSupported')}
|
||||
data={
|
||||
capabilities.supportsRawSearch
|
||||
? translate('Yes')
|
||||
: translate('No')
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('SearchTypes')}
|
||||
data={
|
||||
capabilities.searchParams.length === 0 ? (
|
||||
translate('NotSupported')
|
||||
) : (
|
||||
<Label kind={kinds.PRIMARY}>
|
||||
{capabilities.searchParams[0]}
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('TVSearchTypes')}
|
||||
data={
|
||||
capabilities.tvSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.tvSearchParams.map((p) => {
|
||||
return (
|
||||
<TableRow key={subCategory.id}>
|
||||
<TableRowCell>{subCategory.id}</TableRowCell>
|
||||
<TableRowCell>
|
||||
{subCategory.name}
|
||||
</TableRowCell>
|
||||
</TableRow>
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
</FieldSet>
|
||||
) : null}
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('MovieSearchTypes')}
|
||||
data={
|
||||
capabilities.movieSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.movieSearchParams.map((p) => {
|
||||
return (
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('BookSearchTypes')}
|
||||
data={
|
||||
capabilities.bookSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.bookSearchParams.map((p) => {
|
||||
return (
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('MusicSearchTypes')}
|
||||
data={
|
||||
capabilities.musicSearchParams.length === 0
|
||||
? translate('NotSupported')
|
||||
: capabilities.musicSearchParams.map((p) => {
|
||||
return (
|
||||
<Label key={p} kind={kinds.PRIMARY}>
|
||||
{p}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DescriptionList>
|
||||
</div>
|
||||
</FieldSet>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
{capabilities?.categories?.length > 0 ? (
|
||||
<FieldSet legend={translate('IndexerCategories')}>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
name: 'id',
|
||||
label: translate('Id'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: translate('Name'),
|
||||
isVisible: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{uniqBy(capabilities.categories, 'id')
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((category) => {
|
||||
return (
|
||||
<TableBody key={category.id}>
|
||||
<TableRow key={category.id}>
|
||||
<TableRowCell>{category.id}</TableRowCell>
|
||||
<TableRowCell>{category.name}</TableRowCell>
|
||||
</TableRow>
|
||||
{category?.subCategories?.length > 0
|
||||
? uniqBy(category.subCategories, 'id')
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((subCategory) => {
|
||||
return (
|
||||
<TableRow key={subCategory.id}>
|
||||
<TableRowCell>
|
||||
{subCategory.id}
|
||||
</TableRowCell>
|
||||
<TableRowCell>
|
||||
{subCategory.name}
|
||||
</TableRowCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
</FieldSet>
|
||||
) : null}
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<IndexerHistory indexerId={id} />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteIndexerPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
<Button onPress={onEditIndexerPress}>{translate('Edit')}</Button>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div>
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteIndexerPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
<Button onPress={onCloneIndexerPressWrapper}>
|
||||
{translate('Clone')}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onPress={onEditIndexerPress}>{translate('Edit')}</Button>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
<EditIndexerModalConnector
|
||||
|
||||
@@ -1,22 +1,52 @@
|
||||
.fullWidthChart {
|
||||
display: inline-block;
|
||||
padding: 15px 25px;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.halfWidthChart {
|
||||
display: inline-block;
|
||||
padding: 15px 25px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.quarterWidthChart {
|
||||
display: inline-block;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
margin: 5px;
|
||||
padding: 15px 25px;
|
||||
height: 300px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--chartBackgroundColor);
|
||||
}
|
||||
|
||||
.statContainer {
|
||||
margin: 5px;
|
||||
padding: 15px 25px;
|
||||
height: 150px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--chartBackgroundColor);
|
||||
}
|
||||
|
||||
.statTitle {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-weight: bold;
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.halfWidthChart {
|
||||
display: inline-block;
|
||||
padding: 15px 25px;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.quarterWidthChart {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'chartContainer': string;
|
||||
'fullWidthChart': string;
|
||||
'halfWidthChart': string;
|
||||
'quarterWidthChart': string;
|
||||
'stat': string;
|
||||
'statContainer': string;
|
||||
'statTitle': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -8,6 +8,7 @@ import BarChart from 'Components/Chart/BarChart';
|
||||
import DoughnutChart from 'Components/Chart/DoughnutChart';
|
||||
import StackedBarChart from 'Components/Chart/StackedBarChart';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
@@ -17,14 +18,16 @@ import {
|
||||
fetchIndexerStats,
|
||||
setIndexerStatsFilter,
|
||||
} from 'Store/Actions/indexerStatsActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import {
|
||||
IndexerStatsHost,
|
||||
IndexerStatsIndexer,
|
||||
IndexerStatsUserAgent,
|
||||
} from 'typings/IndexerStats';
|
||||
import abbreviateNumber from 'Utilities/Number/abbreviateNumber';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import IndexerStatsFilterMenu from './IndexerStatsFilterMenu';
|
||||
import IndexerStatsFilterModal from './IndexerStatsFilterModal';
|
||||
import styles from './IndexerStats.css';
|
||||
|
||||
function getAverageResponseTimeData(indexerStats: IndexerStatsIndexer[]) {
|
||||
@@ -165,15 +168,26 @@ function getHostQueryData(indexerStats: IndexerStatsHost[]) {
|
||||
const indexerStatsSelector = () => {
|
||||
return createSelector(
|
||||
(state: AppState) => state.indexerStats,
|
||||
(indexerStats: IndexerStatsAppState) => {
|
||||
return indexerStats;
|
||||
createCustomFiltersSelector('indexerStats'),
|
||||
(indexerStats: IndexerStatsAppState, customFilters) => {
|
||||
return {
|
||||
...indexerStats,
|
||||
customFilters,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function IndexerStats() {
|
||||
const { isFetching, isPopulated, item, error, filters, selectedFilterKey } =
|
||||
useSelector(indexerStatsSelector());
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
item,
|
||||
error,
|
||||
filters,
|
||||
customFilters,
|
||||
selectedFilterKey,
|
||||
} = useSelector(indexerStatsSelector());
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -188,15 +202,33 @@ function IndexerStats() {
|
||||
);
|
||||
|
||||
const isLoaded = !error && isPopulated;
|
||||
const indexerCount = item.indexers?.length ?? 0;
|
||||
const userAgentCount = item.userAgents?.length ?? 0;
|
||||
const queryCount =
|
||||
item.indexers?.reduce((total, indexer) => {
|
||||
return (
|
||||
total +
|
||||
indexer.numberOfQueries +
|
||||
indexer.numberOfRssQueries +
|
||||
indexer.numberOfAuthQueries
|
||||
);
|
||||
}, 0) ?? 0;
|
||||
const grabCount =
|
||||
item.indexers?.reduce((total, indexer) => {
|
||||
return total + indexer.numberOfGrabs;
|
||||
}, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
|
||||
<IndexerStatsFilterMenu
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
onFilterSelect={onFilterSelect}
|
||||
filterModalConnectorComponent={IndexerStatsFilterModal}
|
||||
isDisabled={false}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
@@ -212,58 +244,110 @@ function IndexerStats() {
|
||||
|
||||
{isLoaded && (
|
||||
<div>
|
||||
<div className={styles.fullWidthChart}>
|
||||
<BarChart
|
||||
data={getAverageResponseTimeData(item.indexers)}
|
||||
title={translate('AverageResponseTimesMs')}
|
||||
/>
|
||||
<div className={styles.quarterWidthChart}>
|
||||
<div className={styles.statContainer}>
|
||||
<div className={styles.statTitle}>
|
||||
{translate('ActiveIndexers')}
|
||||
</div>
|
||||
<div className={styles.stat}>{indexerCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.quarterWidthChart}>
|
||||
<div className={styles.statContainer}>
|
||||
<div className={styles.statTitle}>
|
||||
{translate('TotalQueries')}
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
{abbreviateNumber(queryCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.quarterWidthChart}>
|
||||
<div className={styles.statContainer}>
|
||||
<div className={styles.statTitle}>
|
||||
{translate('TotalGrabs')}
|
||||
</div>
|
||||
<div className={styles.stat}>{abbreviateNumber(grabCount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.quarterWidthChart}>
|
||||
<div className={styles.statContainer}>
|
||||
<div className={styles.statTitle}>
|
||||
{translate('ActiveApps')}
|
||||
</div>
|
||||
<div className={styles.stat}>{userAgentCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.fullWidthChart}>
|
||||
<BarChart
|
||||
data={getFailureRateData(item.indexers)}
|
||||
title={translate('IndexerFailureRate')}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
<div className={styles.chartContainer}>
|
||||
<BarChart
|
||||
data={getAverageResponseTimeData(item.indexers)}
|
||||
title={translate('AverageResponseTimesMs')}
|
||||
stepSize={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.fullWidthChart}>
|
||||
<div className={styles.chartContainer}>
|
||||
<BarChart
|
||||
data={getFailureRateData(item.indexers)}
|
||||
title={translate('IndexerFailureRate')}
|
||||
stepSize={0.1}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.halfWidthChart}>
|
||||
<StackedBarChart
|
||||
data={getTotalRequestsData(item.indexers)}
|
||||
title={translate('TotalIndexerQueries')}
|
||||
/>
|
||||
<div className={styles.chartContainer}>
|
||||
<StackedBarChart
|
||||
data={getTotalRequestsData(item.indexers)}
|
||||
title={translate('TotalIndexerQueries')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.halfWidthChart}>
|
||||
<BarChart
|
||||
data={getNumberGrabsData(item.indexers)}
|
||||
title={translate('TotalIndexerSuccessfulGrabs')}
|
||||
/>
|
||||
<div className={styles.chartContainer}>
|
||||
<BarChart
|
||||
data={getNumberGrabsData(item.indexers)}
|
||||
title={translate('TotalIndexerSuccessfulGrabs')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.halfWidthChart}>
|
||||
<BarChart
|
||||
data={getUserAgentQueryData(item.userAgents)}
|
||||
title={translate('TotalUserAgentQueries')}
|
||||
horizontal={true}
|
||||
/>
|
||||
<div className={styles.chartContainer}>
|
||||
<BarChart
|
||||
data={getUserAgentQueryData(item.userAgents)}
|
||||
title={translate('TotalUserAgentQueries')}
|
||||
horizontal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.halfWidthChart}>
|
||||
<BarChart
|
||||
data={getUserAgentGrabsData(item.userAgents)}
|
||||
title={translate('TotalUserAgentGrabs')}
|
||||
horizontal={true}
|
||||
/>
|
||||
<div className={styles.chartContainer}>
|
||||
<BarChart
|
||||
data={getUserAgentGrabsData(item.userAgents)}
|
||||
title={translate('TotalUserAgentGrabs')}
|
||||
horizontal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.halfWidthChart}>
|
||||
<DoughnutChart
|
||||
data={getHostQueryData(item.hosts)}
|
||||
title={translate('TotalHostQueries')}
|
||||
horizontal={true}
|
||||
/>
|
||||
<div className={styles.chartContainer}>
|
||||
<DoughnutChart
|
||||
data={getHostQueryData(item.hosts)}
|
||||
title={translate('TotalHostQueries')}
|
||||
horizontal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.halfWidthChart}>
|
||||
<DoughnutChart
|
||||
data={getHostGrabsData(item.hosts)}
|
||||
title={translate('TotalHostGrabs')}
|
||||
horizontal={true}
|
||||
/>
|
||||
<div className={styles.chartContainer}>
|
||||
<DoughnutChart
|
||||
data={getHostGrabsData(item.hosts)}
|
||||
title={translate('TotalHostGrabs')}
|
||||
horizontal={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import { align } from 'Helpers/Props';
|
||||
|
||||
interface IndexerStatsFilterMenuProps {
|
||||
selectedFilterKey: string | number;
|
||||
filters: object[];
|
||||
isDisabled: boolean;
|
||||
onFilterSelect(filterName: string): unknown;
|
||||
}
|
||||
|
||||
function IndexerStatsFilterMenu(props: IndexerStatsFilterMenuProps) {
|
||||
const { selectedFilterKey, filters, isDisabled, onFilterSelect } = props;
|
||||
|
||||
return (
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={isDisabled}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={[]}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexerStatsFilterMenu;
|
||||
56
frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx
Normal file
56
frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions';
|
||||
|
||||
function createIndexerStatsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.indexerStats.item,
|
||||
(indexerStats) => {
|
||||
return indexerStats;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.indexerStats.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface IndexerStatsFilterModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function IndexerStatsFilterModal(
|
||||
props: IndexerStatsFilterModalProps
|
||||
) {
|
||||
const sectionItems = [useSelector(createIndexerStatsSelector())];
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'indexerStats';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setIndexerStatsFilter(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
// TODO: Don't spread all the props
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
|
||||
function CategoryLabel({ categories }) {
|
||||
const sortedCategories = categories.filter((cat) => cat.name !== undefined).sort((c) => c.id);
|
||||
|
||||
if (categories?.length === 0) {
|
||||
return (
|
||||
<Tooltip
|
||||
anchor={<Label kind={kinds.DANGER}>Unknown</Label>}
|
||||
tooltip="Please report this issue to the GitHub as this shouldn't be happening"
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
sortedCategories.map((category) => {
|
||||
return (
|
||||
<Label key={category.name}>
|
||||
{category.name}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
CategoryLabel.defaultProps = {
|
||||
categories: []
|
||||
};
|
||||
|
||||
CategoryLabel.propTypes = {
|
||||
categories: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default CategoryLabel;
|
||||
36
frontend/src/Search/Table/CategoryLabel.tsx
Normal file
36
frontend/src/Search/Table/CategoryLabel.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { IndexerCategory } from 'Indexer/Indexer';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface CategoryLabelProps {
|
||||
categories: IndexerCategory[];
|
||||
}
|
||||
|
||||
function CategoryLabel({ categories = [] }: CategoryLabelProps) {
|
||||
if (categories?.length === 0) {
|
||||
return (
|
||||
<Tooltip
|
||||
anchor={<Label kind={kinds.DANGER}>{translate('Unknown')}</Label>}
|
||||
tooltip="Please report this issue to the GitHub as this shouldn't be happening"
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sortedCategories = categories
|
||||
.filter((cat) => cat.name !== undefined)
|
||||
.sort((a, b) => a.id - b.id);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{sortedCategories.map((category) => {
|
||||
return <Label key={category.id}>{category.name}</Label>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryLabel;
|
||||
@@ -133,7 +133,8 @@ function EditApplicationModalContent(props) {
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
helpText={translate('TagsHelpText')}
|
||||
helpText={translate('ApplicationTagsHelpText')}
|
||||
helpTextWarning={translate('ApplicationTagsHelpTextWarning')}
|
||||
{...tags}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/create
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
//
|
||||
// Variables
|
||||
@@ -87,7 +88,7 @@ export default {
|
||||
const pendingChanges = { ...item, id: 0 };
|
||||
delete pendingChanges.id;
|
||||
|
||||
pendingChanges.name = `${pendingChanges.name} - Copy`;
|
||||
pendingChanges.name = translate('DefaultNameCopiedProfile', { name: pendingChanges.name });
|
||||
newState.pendingChanges = pendingChanges;
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as commands from './commandActions';
|
||||
import * as customFilters from './customFilterActions';
|
||||
import * as history from './historyActions';
|
||||
import * as indexers from './indexerActions';
|
||||
import * as indexerHistory from './indexerHistoryActions';
|
||||
import * as indexerIndex from './indexerIndexActions';
|
||||
import * as indexerStats from './indexerStatsActions';
|
||||
import * as indexerStatus from './indexerStatusActions';
|
||||
@@ -28,6 +29,7 @@ export default [
|
||||
releases,
|
||||
localization,
|
||||
indexers,
|
||||
indexerHistory,
|
||||
indexerIndex,
|
||||
indexerStats,
|
||||
indexerStatus,
|
||||
|
||||
@@ -210,7 +210,7 @@ export const reducers = createHandleActions({
|
||||
|
||||
// Set the name in pendingChanges
|
||||
newState.pendingChanges = {
|
||||
name: `${item.name} - Copy`
|
||||
name: translate('DefaultNameCopiedProfile', { name: item.name })
|
||||
};
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
|
||||
81
frontend/src/Store/Actions/indexerHistoryActions.js
Normal file
81
frontend/src/Store/Actions/indexerHistoryActions.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import { set, update } from './baseActions';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'indexerHistory';
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
export const defaultState = {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: []
|
||||
};
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_INDEXER_HISTORY = 'indexerHistory/fetchIndexerHistory';
|
||||
export const CLEAR_INDEXER_HISTORY = 'indexerHistory/clearIndexerHistory';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchIndexerHistory = createThunk(FETCH_INDEXER_HISTORY);
|
||||
export const clearIndexerHistory = createAction(CLEAR_INDEXER_HISTORY);
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
|
||||
[FETCH_INDEXER_HISTORY]: function(getState, payload, dispatch) {
|
||||
dispatch(set({ section, isFetching: true }));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/history/indexer',
|
||||
data: payload
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
update({ section, data }),
|
||||
|
||||
set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: true,
|
||||
error: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: xhr
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[CLEAR_INDEXER_HISTORY]: (state) => {
|
||||
return Object.assign({}, state, defaultState);
|
||||
}
|
||||
|
||||
}, defaultState, section);
|
||||
@@ -3,6 +3,7 @@ import { batchActions } from 'redux-batched-actions';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { set, update } from './baseActions';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
@@ -55,19 +56,26 @@ export const defaultState = {
|
||||
|
||||
filterBuilderProps: [
|
||||
{
|
||||
name: 'startDate',
|
||||
label: 'Start Date',
|
||||
type: filterBuilderTypes.EXACT,
|
||||
valueType: filterBuilderValueTypes.DATE
|
||||
name: 'indexers',
|
||||
label: () => translate('Indexers'),
|
||||
type: filterBuilderTypes.CONTAINS,
|
||||
valueType: filterBuilderValueTypes.INDEXER
|
||||
},
|
||||
{
|
||||
name: 'endDate',
|
||||
label: 'End Date',
|
||||
name: 'protocols',
|
||||
label: () => translate('Protocols'),
|
||||
type: filterBuilderTypes.EXACT,
|
||||
valueType: filterBuilderValueTypes.DATE
|
||||
valueType: filterBuilderValueTypes.PROTOCOL
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: () => translate('Tags'),
|
||||
type: filterBuilderTypes.CONTAINS,
|
||||
valueType: filterBuilderValueTypes.TAG
|
||||
}
|
||||
],
|
||||
selectedFilterKey: 'all'
|
||||
selectedFilterKey: 'all',
|
||||
customFilters: []
|
||||
};
|
||||
|
||||
export const persistState = [
|
||||
@@ -81,6 +89,10 @@ export const persistState = [
|
||||
export const FETCH_INDEXER_STATS = 'indexerStats/fetchIndexerStats';
|
||||
export const SET_INDEXER_STATS_FILTER = 'indexerStats/setIndexerStatsFilter';
|
||||
|
||||
function getCustomFilters(state, type) {
|
||||
return state.customFilters.items.filter((customFilter) => customFilter.type === type);
|
||||
}
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
@@ -94,23 +106,39 @@ export const actionHandlers = handleThunks({
|
||||
[FETCH_INDEXER_STATS]: function(getState, payload, dispatch) {
|
||||
const state = getState();
|
||||
const indexerStats = state.indexerStats;
|
||||
const customFilters = getCustomFilters(state, section);
|
||||
const selectedFilters = findSelectedFilters(indexerStats.selectedFilterKey, indexerStats.filters, customFilters);
|
||||
|
||||
const requestParams = {
|
||||
endDate: moment().toISOString()
|
||||
};
|
||||
|
||||
selectedFilters.forEach((selectedFilter) => {
|
||||
if (selectedFilter.key === 'indexers') {
|
||||
requestParams.indexers = selectedFilter.value.join(',');
|
||||
}
|
||||
|
||||
if (selectedFilter.key === 'protocols') {
|
||||
requestParams.protocols = selectedFilter.value.join(',');
|
||||
}
|
||||
|
||||
if (selectedFilter.key === 'tags') {
|
||||
requestParams.tags = selectedFilter.value.join(',');
|
||||
}
|
||||
});
|
||||
|
||||
if (indexerStats.selectedFilterKey !== 'all') {
|
||||
let dayCount = 7;
|
||||
if (indexerStats.selectedFilterKey === 'lastSeven') {
|
||||
requestParams.startDate = moment().add(-7, 'days').endOf('day').toISOString();
|
||||
}
|
||||
|
||||
if (indexerStats.selectedFilterKey === 'lastThirty') {
|
||||
dayCount = 30;
|
||||
requestParams.startDate = moment().add(-30, 'days').endOf('day').toISOString();
|
||||
}
|
||||
|
||||
if (indexerStats.selectedFilterKey === 'lastNinety') {
|
||||
dayCount = 90;
|
||||
requestParams.startDate = moment().add(-90, 'days').endOf('day').toISOString();
|
||||
}
|
||||
|
||||
requestParams.startDate = moment().add(-dayCount, 'days').endOf('day').toISOString();
|
||||
}
|
||||
|
||||
const basesAttrs = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
@@ -110,7 +112,11 @@ export const defaultState = {
|
||||
},
|
||||
{
|
||||
name: 'indexerFlags',
|
||||
columnLabel: 'Indexer Flags',
|
||||
columnLabel: () => translate('IndexerFlags'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.FLAG,
|
||||
title: () => translate('IndexerFlags')
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
|
||||
@@ -187,6 +187,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']
|
||||
|
||||
@@ -187,6 +187,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']
|
||||
|
||||
@@ -4,7 +4,7 @@ import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
@@ -110,7 +110,7 @@ class BackupRow extends Component {
|
||||
{formatBytes(size)}
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCellConnector
|
||||
<RelativeDateCell
|
||||
date={time}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
@@ -98,7 +98,7 @@ class LogsTableRow extends Component {
|
||||
|
||||
if (name === 'time') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
date={time}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import styles from './LogFilesTableRow.css';
|
||||
@@ -22,7 +22,7 @@ class LogFilesTableRow extends Component {
|
||||
<TableRow>
|
||||
<TableRowCell>{filename}</TableRowCell>
|
||||
|
||||
<RelativeDateCellConnector
|
||||
<RelativeDateCell
|
||||
date={lastWriteTime}
|
||||
/>
|
||||
|
||||
|
||||
19
frontend/src/Utilities/Number/abbreviateNumber.js
Normal file
19
frontend/src/Utilities/Number/abbreviateNumber.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export default function abbreviateNumber(num, decimalPlaces) {
|
||||
if (num === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (num === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
decimalPlaces = (!decimalPlaces || decimalPlaces < 0) ? 0 : decimalPlaces;
|
||||
|
||||
const b = (num).toPrecision(2).split('e');
|
||||
const k = b.length === 1 ? 0 : Math.floor(Math.min(b[1].slice(1), 14) / 3);
|
||||
const c = k < 1 ? num.toFixed(0 + decimalPlaces) : (num / Math.pow(10, k * 3) ).toFixed(1 + decimalPlaces);
|
||||
const d = c < 0 ? c : Math.abs(c);
|
||||
const e = d + ['', 'K', 'M', 'B', 'T'][k];
|
||||
|
||||
return e;
|
||||
}
|
||||
21
frontend/src/typings/History.ts
Normal file
21
frontend/src/typings/History.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export interface HistoryData {
|
||||
source: string;
|
||||
host: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
elapsedTime: number;
|
||||
query: string;
|
||||
queryType: string;
|
||||
}
|
||||
|
||||
interface History extends ModelBase {
|
||||
indexerId: number;
|
||||
date: string;
|
||||
successful: boolean;
|
||||
eventType: string;
|
||||
data: HistoryData;
|
||||
}
|
||||
|
||||
export default History;
|
||||
21
package.json
21
package.json
@@ -29,7 +29,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@juggle/resize-observer": "3.4.0",
|
||||
"@microsoft/signalr": "6.0.16",
|
||||
"@microsoft/signalr": "6.0.21",
|
||||
"@sentry/browser": "7.51.2",
|
||||
"@sentry/integrations": "7.51.2",
|
||||
"@types/node": "18.15.11",
|
||||
@@ -71,6 +71,7 @@
|
||||
"react-redux": "7.2.4",
|
||||
"react-router": "5.2.0",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-tabs": "4.3.0",
|
||||
"react-text-truncate": "0.19.0",
|
||||
"react-use-measure": "2.1.1",
|
||||
"react-virtualized": "9.21.1",
|
||||
@@ -85,17 +86,13 @@
|
||||
"typescript": "5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.22.9",
|
||||
"@babel/eslint-parser": "7.22.9",
|
||||
"@babel/plugin-proposal-class-properties": "7.18.6",
|
||||
"@babel/core": "7.22.11",
|
||||
"@babel/eslint-parser": "7.22.11",
|
||||
"@babel/plugin-proposal-export-default-from": "7.22.5",
|
||||
"@babel/plugin-proposal-export-namespace-from": "7.18.9",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
|
||||
"@babel/plugin-proposal-optional-chaining": "7.21.0",
|
||||
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||
"@babel/preset-env": "7.22.9",
|
||||
"@babel/preset-env": "7.22.14",
|
||||
"@babel/preset-react": "7.22.5",
|
||||
"@babel/preset-typescript": "7.22.5",
|
||||
"@babel/preset-typescript": "7.22.11",
|
||||
"@types/lodash": "4.14.194",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/react-text-truncate": "0.14.1",
|
||||
@@ -108,7 +105,7 @@
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-inline-classnames": "2.0.1",
|
||||
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
||||
"core-js": "3.31.1",
|
||||
"core-js": "3.33.0",
|
||||
"css-loader": "6.7.3",
|
||||
"css-modules-typescript-loader": "4.0.1",
|
||||
"eslint": "8.45.0",
|
||||
@@ -125,7 +122,7 @@
|
||||
"html-webpack-plugin": "5.5.1",
|
||||
"loader-utils": "^3.2.1",
|
||||
"mini-css-extract-plugin": "2.7.5",
|
||||
"postcss": "8.4.23",
|
||||
"postcss": "8.4.31",
|
||||
"postcss-color-function": "4.1.0",
|
||||
"postcss-loader": "7.3.0",
|
||||
"postcss-mixins": "9.0.4",
|
||||
@@ -144,7 +141,7 @@
|
||||
"ts-loader": "9.4.2",
|
||||
"typescript-plugin-css-modules": "5.0.1",
|
||||
"url-loader": "4.1.1",
|
||||
"webpack": "5.88.1",
|
||||
"webpack": "5.89.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-livereload-plugin": "3.0.2"
|
||||
}
|
||||
|
||||
@@ -128,6 +128,16 @@ namespace NzbDrone.Common.Test.Http
|
||||
response.Content.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_throw_timeout_request()
|
||||
{
|
||||
var request = new HttpRequest($"https://{_httpBinHost}/delay/10");
|
||||
|
||||
request.RequestTimeout = new TimeSpan(0, 0, 5);
|
||||
|
||||
Assert.ThrowsAsync<WebException>(async () => await Subject.ExecuteAsync(request));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_execute_https_get()
|
||||
{
|
||||
|
||||
@@ -89,8 +89,16 @@ namespace NzbDrone.Common.Test.InstrumentationTests
|
||||
// Download Station
|
||||
[TestCase(@"webapi/entry.cgi?api=(removed)&version=2&method=login&account=01233210&passwd=mySecret&format=sid&session=DownloadStation")]
|
||||
|
||||
// Tracker Responses
|
||||
[TestCase(@"tracker"":""http://xxx.yyy/announce.php?passkey=9pr04sg601233210imaveql2tyu8xyui"",""info"":""http://xxx.yyy/info?a=b""")]
|
||||
// Announce URLs (passkeys) Magnet & Tracker
|
||||
[TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2f9pr04sg601233210IMAveQL2tyu8xyui%2fannounce""}")]
|
||||
[TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2ftracker.php%2f9pr04sg601233210IMAveQL2tyu8xyui%2fannounce""}")]
|
||||
[TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce%2f9pr04sg601233210IMAveQL2tyu8xyui""}")]
|
||||
[TestCase(@"magnet_uri"":""magnet:?xt=urn:btih:9pr04sgkillroyimaveql2tyu8xyui&dn=&tr=https%3a%2f%2fxxx.yyy%2fannounce.php%3fpasskey%3d9pr04sg601233210IMAveQL2tyu8xyui""}")]
|
||||
[TestCase(@"tracker"":""https://xxx.yyy/9pr04sg601233210IMAveQL2tyu8xyui/announce""}")]
|
||||
[TestCase(@"tracker"":""https://xxx.yyy/tracker.php/9pr04sg601233210IMAveQL2tyu8xyui/announce""}")]
|
||||
[TestCase(@"tracker"":""https://xxx.yyy/announce/9pr04sg601233210IMAveQL2tyu8xyui""}")]
|
||||
[TestCase(@"tracker"":""https://xxx.yyy/announce.php?passkey=9pr04sg601233210IMAveQL2tyu8xyui""}")]
|
||||
[TestCase(@"tracker"":""http://xxx.yyy/announce.php?passkey=9pr04sg601233210IMAveQL2tyu8xyui"",""info"":""http://xxx.yyy/info?a=b""")]
|
||||
|
||||
// BroadcastheNet
|
||||
[TestCase(@"method: ""getTorrents"", ""params"": [ ""mySecret"",")]
|
||||
|
||||
@@ -160,7 +160,7 @@ namespace NzbDrone.Common.Extensions
|
||||
{
|
||||
if (text.IsNullOrWhiteSpace())
|
||||
{
|
||||
throw new ArgumentNullException("text");
|
||||
throw new ArgumentNullException(nameof(text));
|
||||
}
|
||||
|
||||
return text.IndexOfAny(Path.GetInvalidPathChars()) >= 0;
|
||||
|
||||
@@ -107,52 +107,59 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
|
||||
sw.Start();
|
||||
|
||||
using var responseMessage = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
try
|
||||
{
|
||||
byte[] data = null;
|
||||
|
||||
try
|
||||
using var responseMessage = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
{
|
||||
if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK)
|
||||
byte[] data = null;
|
||||
|
||||
try
|
||||
{
|
||||
await responseMessage.Content.CopyToAsync(request.ResponseStream, null, cts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
data = responseMessage.Content.ReadAsByteArrayAsync(cts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null);
|
||||
}
|
||||
|
||||
var headers = responseMessage.Headers.ToNameValueCollection();
|
||||
|
||||
headers.Add(responseMessage.Content.Headers.ToNameValueCollection());
|
||||
|
||||
var responseCookies = new CookieContainer();
|
||||
|
||||
if (responseMessage.Headers.TryGetValues("Set-Cookie", out var cookieHeaders))
|
||||
{
|
||||
foreach (var responseCookieHeader in cookieHeaders)
|
||||
{
|
||||
try
|
||||
if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
cookies.SetCookies(responseMessage.RequestMessage.RequestUri, responseCookieHeader);
|
||||
await responseMessage.Content.CopyToAsync(request.ResponseStream, null, cts.Token);
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
// Ignore invalid cookies
|
||||
data = await responseMessage.Content.ReadAsByteArrayAsync(cts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null);
|
||||
}
|
||||
|
||||
var headers = responseMessage.Headers.ToNameValueCollection();
|
||||
|
||||
headers.Add(responseMessage.Content.Headers.ToNameValueCollection());
|
||||
|
||||
var responseCookies = new CookieContainer();
|
||||
|
||||
if (responseMessage.Headers.TryGetValues("Set-Cookie", out var cookieHeaders))
|
||||
{
|
||||
foreach (var responseCookieHeader in cookieHeaders)
|
||||
{
|
||||
try
|
||||
{
|
||||
cookies.SetCookies(responseMessage.RequestMessage.RequestUri, responseCookieHeader);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore invalid cookies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cookieCollection = cookies.GetCookies(responseMessage.RequestMessage.RequestUri);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new HttpResponse(request, new HttpHeader(headers), cookieCollection, data, sw.ElapsedMilliseconds, responseMessage.StatusCode, responseMessage.Version);
|
||||
}
|
||||
|
||||
var cookieCollection = cookies.GetCookies(responseMessage.RequestMessage.RequestUri);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new HttpResponse(request, new HttpHeader(headers), cookieCollection, data, sw.ElapsedMilliseconds, responseMessage.StatusCode, responseMessage.Version);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (cts.IsCancellationRequested)
|
||||
{
|
||||
throw new WebException("Http request timed out", ex.InnerException, WebExceptionStatus.Timeout, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,11 +221,18 @@ namespace NzbDrone.Common.Http
|
||||
};
|
||||
}
|
||||
|
||||
sourceContainer.Add((Uri)request.Url, cookie);
|
||||
|
||||
if (request.StoreRequestCookie)
|
||||
try
|
||||
{
|
||||
presistentContainer.Add((Uri)request.Url, cookie);
|
||||
sourceContainer.Add((Uri)request.Url, cookie);
|
||||
|
||||
if (request.StoreRequestCookie)
|
||||
{
|
||||
presistentContainer.Add((Uri)request.Url, cookie);
|
||||
}
|
||||
}
|
||||
catch (CookieException ex)
|
||||
{
|
||||
_logger.Debug(ex, "Invalid cookie in {0}", (Uri)request.Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +267,14 @@ namespace NzbDrone.Common.Http
|
||||
};
|
||||
}
|
||||
|
||||
sourceContainer.Add((Uri)request.Url, cookie);
|
||||
try
|
||||
{
|
||||
sourceContainer.Add((Uri)request.Url, cookie);
|
||||
}
|
||||
catch (CookieException ex)
|
||||
{
|
||||
_logger.Debug(ex, "Invalid cookie in {0}", (Uri)request.Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
new (@"""/(home|Users)/(?<secret>[^/""]+?)(/|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
|
||||
// Trackers Announce Keys; Designed for Qbit Json; should work for all in theory
|
||||
new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?<secret>[a-z0-9]{16,})|(?<secret>[a-z0-9]{16,})(/|%2f)announce"),
|
||||
new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?<secret>[a-z0-9]{16,})|(?<secret>[a-z0-9]{16,})(/|%2f)announce", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
|
||||
// NzbGet
|
||||
new (@"""Name""\s*:\s*""[^""]*(username|password)""\s*,\s*""Value""\s*:\s*""(?<secret>[^""]+?)""", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<DefineConstants Condition="'$(RuntimeIdentifier)' == 'linux-musl-x64' or '$(RuntimeIdentifier)' == 'linux-musl-arm64'">ISMUSL</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DryIoc.dll" Version="5.3.4" />
|
||||
<PackageReference Include="DryIoc.dll" Version="5.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NLog" Version="5.2.0" />
|
||||
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.0" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" />
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="6.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Update="EnsureThat\Resources\ExceptionMessages.Designer.cs">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NzbDrone.Common.Serializer;
|
||||
|
||||
public class BooleanConverter : JsonConverter<bool>
|
||||
{
|
||||
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
return reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.True => true,
|
||||
JsonTokenType.False => false,
|
||||
JsonTokenType.Number => reader.GetInt64() switch
|
||||
{
|
||||
1 => true,
|
||||
0 => false,
|
||||
_ => throw new JsonException()
|
||||
},
|
||||
_ => throw new JsonException()
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteBooleanValue(value);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ namespace NzbDrone.Common.Serializer
|
||||
serializerSettings.Converters.Add(new STJTimeSpanConverter());
|
||||
serializerSettings.Converters.Add(new STJUtcConverter());
|
||||
serializerSettings.Converters.Add(new DictionaryStringObjectConverter());
|
||||
serializerSettings.Converters.Add(new BooleanConverter());
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
|
||||
17
src/NzbDrone.Common/TPL/DebounceManager.cs
Normal file
17
src/NzbDrone.Common/TPL/DebounceManager.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace NzbDrone.Common.TPL
|
||||
{
|
||||
public interface IDebounceManager
|
||||
{
|
||||
Debouncer CreateDebouncer(Action action, TimeSpan debounceDuration);
|
||||
}
|
||||
|
||||
public class DebounceManager : IDebounceManager
|
||||
{
|
||||
public Debouncer CreateDebouncer(Action action, TimeSpan debounceDuration)
|
||||
{
|
||||
return new Debouncer(action, debounceDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ namespace NzbDrone.Common.TPL
|
||||
{
|
||||
public class Debouncer
|
||||
{
|
||||
private readonly Action _action;
|
||||
private readonly System.Timers.Timer _timer;
|
||||
protected readonly Action _action;
|
||||
protected readonly System.Timers.Timer _timer;
|
||||
|
||||
private volatile int _paused;
|
||||
private volatile bool _triggered;
|
||||
protected volatile int _paused;
|
||||
protected volatile bool _triggered;
|
||||
|
||||
public Debouncer(Action action, TimeSpan debounceDuration)
|
||||
{
|
||||
@@ -27,7 +27,7 @@ namespace NzbDrone.Common.TPL
|
||||
}
|
||||
}
|
||||
|
||||
public void Execute()
|
||||
public virtual void Execute()
|
||||
{
|
||||
lock (_timer)
|
||||
{
|
||||
@@ -39,7 +39,7 @@ namespace NzbDrone.Common.TPL
|
||||
}
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
public virtual void Pause()
|
||||
{
|
||||
lock (_timer)
|
||||
{
|
||||
@@ -48,7 +48,7 @@ namespace NzbDrone.Common.TPL
|
||||
}
|
||||
}
|
||||
|
||||
public void Resume()
|
||||
public virtual void Resume()
|
||||
{
|
||||
lock (_timer)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Common.TPL;
|
||||
using NzbDrone.Core.HealthCheck;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
namespace NzbDrone.Core.Test.HealthCheck
|
||||
{
|
||||
@@ -19,10 +23,10 @@ namespace NzbDrone.Core.Test.HealthCheck
|
||||
|
||||
Mocker.SetConstant<IEnumerable<IProvideHealthCheck>>(new[] { _healthCheck });
|
||||
Mocker.SetConstant<ICacheManager>(Mocker.Resolve<CacheManager>());
|
||||
Mocker.SetConstant<IDebounceManager>(Mocker.Resolve<DebounceManager>());
|
||||
|
||||
Mocker.GetMock<IServerSideNotificationService>()
|
||||
.Setup(v => v.GetServerChecks())
|
||||
.Returns(new List<Core.HealthCheck.HealthCheck>());
|
||||
Mocker.GetMock<IDebounceManager>().Setup(s => s.CreateDebouncer(It.IsAny<Action>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<Action, TimeSpan>((a, t) => new MockDebouncer(a, t));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace NzbDrone.Core.Test.IndexerStatsTests
|
||||
.Setup(o => o.Between(It.IsAny<DateTime>(), It.IsAny<DateTime>()))
|
||||
.Returns<DateTime, DateTime>((s, f) => history);
|
||||
|
||||
var statistics = Subject.IndexerStatistics(DateTime.UtcNow.AddMonths(-1), DateTime.UtcNow);
|
||||
var statistics = Subject.IndexerStatistics(DateTime.UtcNow.AddMonths(-1), DateTime.UtcNow, new List<int> { 5 });
|
||||
|
||||
statistics.IndexerStatistics.Count.Should().Be(1);
|
||||
statistics.IndexerStatistics.First().AverageResponseTime.Should().Be(0);
|
||||
|
||||
@@ -19,6 +19,11 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
Subject.Definition = new IndexerDefinition
|
||||
{
|
||||
Name = "Newznab"
|
||||
};
|
||||
|
||||
Subject.Settings = new NewznabSettings()
|
||||
{
|
||||
BaseUrl = "http://127.0.0.1:1234/",
|
||||
|
||||
@@ -9,8 +9,8 @@ using NUnit.Framework;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.Definitions;
|
||||
using NzbDrone.Core.Indexers.Definitions.Gazelle;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.IndexerTests.OrpheusTests
|
||||
@@ -40,9 +40,9 @@ namespace NzbDrone.Core.Test.IndexerTests.OrpheusTests
|
||||
var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 3000 } })).Releases;
|
||||
|
||||
releases.Should().HaveCount(65);
|
||||
releases.First().Should().BeOfType<GazelleInfo>();
|
||||
releases.First().Should().BeOfType<TorrentInfo>();
|
||||
|
||||
var torrentInfo = releases.First() as GazelleInfo;
|
||||
var torrentInfo = releases.First() as TorrentInfo;
|
||||
|
||||
torrentInfo.Title.Should().Be("The Beatles - Abbey Road [1969] [Album] [2.0 Mix 2019] [MP3 V2 (VBR)] [BD]");
|
||||
torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent);
|
||||
|
||||
@@ -6,9 +6,8 @@ using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.PassThePopcorn;
|
||||
using NzbDrone.Core.Indexers.Definitions.PassThePopcorn;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
@@ -21,26 +20,22 @@ namespace NzbDrone.Core.Test.IndexerTests.PTPTests
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
Subject.Definition = new IndexerDefinition()
|
||||
Subject.Definition = new IndexerDefinition
|
||||
{
|
||||
Name = "PTP",
|
||||
Settings = new PassThePopcornSettings() { APIUser = "asdf", APIKey = "sad" }
|
||||
Settings = new PassThePopcornSettings
|
||||
{
|
||||
APIUser = "asdf",
|
||||
APIKey = "sad"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[TestCase("Files/Indexers/PTP/imdbsearch.json")]
|
||||
public async Task should_parse_feed_from_PTP(string fileName)
|
||||
{
|
||||
var authResponse = new PassThePopcornAuthResponse { Result = "Ok" };
|
||||
|
||||
var authStream = new System.IO.StringWriter();
|
||||
Json.Serialize(authResponse, authStream);
|
||||
var responseJson = ReadAllText(fileName);
|
||||
|
||||
Mocker.GetMock<IIndexerHttpClient>()
|
||||
.Setup(o => o.ExecuteProxiedAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.Post), Subject.Definition))
|
||||
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader(), new CookieCollection(), authStream.ToString())));
|
||||
|
||||
Mocker.GetMock<IIndexerHttpClient>()
|
||||
.Setup(o => o.ExecuteProxiedAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.Get), Subject.Definition))
|
||||
.Returns<HttpRequest, IndexerDefinition>((r, d) => Task.FromResult(new HttpResponse(r, new HttpHeader { ContentType = HttpAccept.Json.Value }, new CookieCollection(), responseJson)));
|
||||
|
||||
@@ -9,8 +9,8 @@ using NUnit.Framework;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.Definitions;
|
||||
using NzbDrone.Core.Indexers.Definitions.Gazelle;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.IndexerTests.RedactedTests
|
||||
@@ -40,9 +40,9 @@ namespace NzbDrone.Core.Test.IndexerTests.RedactedTests
|
||||
var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 3000 } })).Releases;
|
||||
|
||||
releases.Should().HaveCount(39);
|
||||
releases.First().Should().BeOfType<GazelleInfo>();
|
||||
releases.First().Should().BeOfType<TorrentInfo>();
|
||||
|
||||
var torrentInfo = releases.First() as GazelleInfo;
|
||||
var torrentInfo = releases.First() as TorrentInfo;
|
||||
|
||||
torrentInfo.Title.Should().Be("Red Hot Chili Peppers - Californication [1999] [Album] [US / Reissue 2020] [FLAC 24bit Lossless] [Vinyl]");
|
||||
torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent);
|
||||
|
||||
@@ -11,6 +11,7 @@ using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.Definitions;
|
||||
using NzbDrone.Core.Indexers.Definitions.Gazelle;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.IndexerTests.SecretCinemaTests
|
||||
@@ -40,9 +41,9 @@ namespace NzbDrone.Core.Test.IndexerTests.SecretCinemaTests
|
||||
var releases = (await Subject.Fetch(new BasicSearchCriteria { Categories = new[] { 2000 } })).Releases;
|
||||
|
||||
releases.Should().HaveCount(3);
|
||||
releases.First().Should().BeOfType<GazelleInfo>();
|
||||
releases.First().Should().BeOfType<TorrentInfo>();
|
||||
|
||||
var torrentInfo = releases.First() as GazelleInfo;
|
||||
var torrentInfo = releases.First() as TorrentInfo;
|
||||
|
||||
torrentInfo.Title.Should().Be("Singin' in the Rain (1952) 2160p");
|
||||
torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="NBuilder" Version="6.1.0" />
|
||||
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.1.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Test.Common\Prowlarr.Test.Common.csproj" />
|
||||
|
||||
@@ -202,9 +202,17 @@ namespace NzbDrone.Core.Applications
|
||||
|
||||
private bool ShouldHandleIndexer(ProviderDefinition app, ProviderDefinition indexer)
|
||||
{
|
||||
if (!indexer.Settings.Validate().IsValid)
|
||||
{
|
||||
_logger.Debug("Indexer {0} [{1}] has invalid settings.", indexer.Name, indexer.Id);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (app.Tags.Empty())
|
||||
{
|
||||
_logger.Debug("No tags set to application {0}.", app.Name);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -213,10 +221,12 @@ namespace NzbDrone.Core.Applications
|
||||
if (intersectingTags.Any())
|
||||
{
|
||||
_logger.Debug("Application {0} and indexer {1} [{2}] have {3} intersecting (matching) tags.", app.Name, indexer.Name, indexer.Id, intersectingTags.Length);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.Debug("Application {0} does not have any intersecting (matching) tags with {1} [{2}]. Indexer will neither be synced to nor removed from the application.", app.Name, indexer.Name, indexer.Id);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ namespace NzbDrone.Core.Datastore.Migration.Framework
|
||||
.Configure<ProcessorOptions>(opt =>
|
||||
{
|
||||
opt.PreviewOnly = false;
|
||||
opt.Timeout = TimeSpan.FromSeconds(60);
|
||||
opt.Timeout = TimeSpan.FromMinutes(10);
|
||||
})
|
||||
.Configure<SelectingProcessorAccessorOptions>(cfg =>
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ using NzbDrone.Common.Cache;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Common.Reflection;
|
||||
using NzbDrone.Common.TPL;
|
||||
using NzbDrone.Core.Lifecycle;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
@@ -27,35 +28,35 @@ namespace NzbDrone.Core.HealthCheck
|
||||
private readonly IProvideHealthCheck[] _startupHealthChecks;
|
||||
private readonly IProvideHealthCheck[] _scheduledHealthChecks;
|
||||
private readonly Dictionary<Type, IEventDrivenHealthCheck[]> _eventDrivenHealthChecks;
|
||||
private readonly IServerSideNotificationService _serverSideNotificationService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly ICacheManager _cacheManager;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private readonly ICached<HealthCheck> _healthCheckResults;
|
||||
private readonly HashSet<IProvideHealthCheck> _pendingHealthChecks;
|
||||
private readonly Debouncer _debounce;
|
||||
|
||||
private bool _hasRunHealthChecksAfterGracePeriod;
|
||||
private bool _isRunningHealthChecksAfterGracePeriod;
|
||||
|
||||
public HealthCheckService(IEnumerable<IProvideHealthCheck> healthChecks,
|
||||
IServerSideNotificationService serverSideNotificationService,
|
||||
IEventAggregator eventAggregator,
|
||||
ICacheManager cacheManager,
|
||||
IDebounceManager debounceManager,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
Logger logger)
|
||||
{
|
||||
_healthChecks = healthChecks.ToArray();
|
||||
_serverSideNotificationService = serverSideNotificationService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_cacheManager = cacheManager;
|
||||
_logger = logger;
|
||||
|
||||
_healthCheckResults = _cacheManager.GetCache<HealthCheck>(GetType());
|
||||
_healthCheckResults = cacheManager.GetCache<HealthCheck>(GetType());
|
||||
_pendingHealthChecks = new HashSet<IProvideHealthCheck>();
|
||||
_debounce = debounceManager.CreateDebouncer(ProcessHealthChecks, TimeSpan.FromSeconds(5));
|
||||
|
||||
_startupHealthChecks = _healthChecks.Where(v => v.CheckOnStartup).ToArray();
|
||||
_scheduledHealthChecks = _healthChecks.Where(v => v.CheckOnSchedule).ToArray();
|
||||
_eventDrivenHealthChecks = GetEventDrivenHealthChecks();
|
||||
_startupGracePeriodEndTime = runtimeInfo.StartTime.AddMinutes(15);
|
||||
_startupGracePeriodEndTime = runtimeInfo.StartTime + TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
public List<HealthCheck> Results()
|
||||
@@ -77,63 +78,93 @@ namespace NzbDrone.Core.HealthCheck
|
||||
.ToDictionary(g => g.Key, g => g.ToArray());
|
||||
}
|
||||
|
||||
private void PerformHealthCheck(IProvideHealthCheck[] healthChecks, bool performServerChecks = false)
|
||||
private void ProcessHealthChecks()
|
||||
{
|
||||
var results = healthChecks.Select(c => c.Check())
|
||||
.ToList();
|
||||
List<IProvideHealthCheck> healthChecks;
|
||||
|
||||
if (performServerChecks)
|
||||
lock (_pendingHealthChecks)
|
||||
{
|
||||
results.AddRange(_serverSideNotificationService.GetServerChecks());
|
||||
healthChecks = _pendingHealthChecks.ToList();
|
||||
_pendingHealthChecks.Clear();
|
||||
}
|
||||
|
||||
foreach (var result in results)
|
||||
_debounce.Pause();
|
||||
|
||||
try
|
||||
{
|
||||
if (result.Type == HealthCheckResult.Ok)
|
||||
{
|
||||
var previous = _healthCheckResults.Find(result.Source.Name);
|
||||
|
||||
if (previous != null)
|
||||
var results = healthChecks.Select(c =>
|
||||
{
|
||||
_eventAggregator.PublishEvent(new HealthCheckRestoredEvent(previous, !_hasRunHealthChecksAfterGracePeriod));
|
||||
}
|
||||
_logger.Trace("Check health -> {0}", c.GetType().Name);
|
||||
var result = c.Check();
|
||||
_logger.Trace("Check health <- {0}", c.GetType().Name);
|
||||
|
||||
_healthCheckResults.Remove(result.Source.Name);
|
||||
}
|
||||
else
|
||||
return result;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
if (_healthCheckResults.Find(result.Source.Name) == null)
|
||||
if (result.Type == HealthCheckResult.Ok)
|
||||
{
|
||||
_eventAggregator.PublishEvent(new HealthCheckFailedEvent(result, !_hasRunHealthChecksAfterGracePeriod));
|
||||
}
|
||||
var previous = _healthCheckResults.Find(result.Source.Name);
|
||||
|
||||
_healthCheckResults.Set(result.Source.Name, result);
|
||||
if (previous != null)
|
||||
{
|
||||
_eventAggregator.PublishEvent(new HealthCheckRestoredEvent(previous, !_hasRunHealthChecksAfterGracePeriod));
|
||||
}
|
||||
|
||||
_healthCheckResults.Remove(result.Source.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_healthCheckResults.Find(result.Source.Name) == null)
|
||||
{
|
||||
_eventAggregator.PublishEvent(new HealthCheckFailedEvent(result, !_hasRunHealthChecksAfterGracePeriod));
|
||||
}
|
||||
|
||||
_healthCheckResults.Set(result.Source.Name, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_debounce.Resume();
|
||||
}
|
||||
|
||||
_eventAggregator.PublishEvent(new HealthCheckCompleteEvent());
|
||||
}
|
||||
|
||||
public void Execute(CheckHealthCommand message)
|
||||
{
|
||||
if (message.Trigger == CommandTrigger.Manual)
|
||||
var healthChecks = message.Trigger == CommandTrigger.Manual ? _healthChecks : _scheduledHealthChecks;
|
||||
|
||||
lock (_pendingHealthChecks)
|
||||
{
|
||||
PerformHealthCheck(_healthChecks, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
PerformHealthCheck(_scheduledHealthChecks, true);
|
||||
foreach (var healthCheck in healthChecks)
|
||||
{
|
||||
_pendingHealthChecks.Add(healthCheck);
|
||||
}
|
||||
}
|
||||
|
||||
ProcessHealthChecks();
|
||||
}
|
||||
|
||||
public void HandleAsync(ApplicationStartedEvent message)
|
||||
{
|
||||
PerformHealthCheck(_startupHealthChecks, true);
|
||||
lock (_pendingHealthChecks)
|
||||
{
|
||||
foreach (var healthCheck in _startupHealthChecks)
|
||||
{
|
||||
_pendingHealthChecks.Add(healthCheck);
|
||||
}
|
||||
}
|
||||
|
||||
ProcessHealthChecks();
|
||||
}
|
||||
|
||||
public void HandleAsync(IEvent message)
|
||||
{
|
||||
if (message is HealthCheckCompleteEvent)
|
||||
if (message is HealthCheckCompleteEvent || message is ApplicationStartedEvent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +175,16 @@ namespace NzbDrone.Core.HealthCheck
|
||||
{
|
||||
_isRunningHealthChecksAfterGracePeriod = true;
|
||||
|
||||
PerformHealthCheck(_startupHealthChecks);
|
||||
lock (_pendingHealthChecks)
|
||||
{
|
||||
foreach (var healthCheck in _startupHealthChecks)
|
||||
{
|
||||
_pendingHealthChecks.Add(healthCheck);
|
||||
}
|
||||
}
|
||||
|
||||
// Call it directly so it's not debounced and any alerts can be sent.
|
||||
ProcessHealthChecks();
|
||||
|
||||
// Update after running health checks so new failure notifications aren't sent 2x.
|
||||
_hasRunHealthChecksAfterGracePeriod = true;
|
||||
@@ -176,11 +216,16 @@ namespace NzbDrone.Core.HealthCheck
|
||||
if (eventDrivenHealthCheck.ShouldExecute(message, previouslyFailed))
|
||||
{
|
||||
filteredChecks.Add(eventDrivenHealthCheck.HealthCheck);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add debounce
|
||||
PerformHealthCheck(filteredChecks.ToArray());
|
||||
lock (_pendingHealthChecks)
|
||||
{
|
||||
filteredChecks.ForEach(h => _pendingHealthChecks.Add(h));
|
||||
}
|
||||
|
||||
_debounce.Execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,50 +9,43 @@ using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Localization;
|
||||
|
||||
namespace NzbDrone.Core.HealthCheck
|
||||
{
|
||||
public interface IServerSideNotificationService
|
||||
{
|
||||
public List<HealthCheck> GetServerChecks();
|
||||
}
|
||||
|
||||
public class ServerSideNotificationService : IServerSideNotificationService
|
||||
public class ServerSideNotificationService : HealthCheckBase
|
||||
{
|
||||
private readonly IHttpClient _client;
|
||||
private readonly IProwlarrCloudRequestBuilder _cloudRequestBuilder;
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
private readonly IHttpRequestBuilderFactory _cloudRequestBuilder;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private readonly ICached<List<HealthCheck>> _cache;
|
||||
private readonly ICached<HealthCheck> _cache;
|
||||
|
||||
public ServerSideNotificationService(IHttpClient client,
|
||||
IConfigFileProvider configFileProvider,
|
||||
IProwlarrCloudRequestBuilder cloudRequestBuilder,
|
||||
ICacheManager cacheManager,
|
||||
Logger logger)
|
||||
public ServerSideNotificationService(IHttpClient client, IProwlarrCloudRequestBuilder cloudRequestBuilder, IConfigFileProvider configFileProvider, ICacheManager cacheManager, ILocalizationService localizationService, Logger logger)
|
||||
: base(localizationService)
|
||||
{
|
||||
_client = client;
|
||||
_configFileProvider = configFileProvider;
|
||||
_cloudRequestBuilder = cloudRequestBuilder.Services;
|
||||
_cloudRequestBuilder = cloudRequestBuilder;
|
||||
_logger = logger;
|
||||
|
||||
_cache = cacheManager.GetCache<List<HealthCheck>>(GetType());
|
||||
_cache = cacheManager.GetCache<HealthCheck>(GetType());
|
||||
}
|
||||
|
||||
public List<HealthCheck> GetServerChecks()
|
||||
public override HealthCheck Check()
|
||||
{
|
||||
return _cache.Get("ServerChecks", RetrieveServerChecks, TimeSpan.FromHours(2));
|
||||
}
|
||||
|
||||
private List<HealthCheck> RetrieveServerChecks()
|
||||
private HealthCheck RetrieveServerChecks()
|
||||
{
|
||||
if (BuildInfo.IsDebug)
|
||||
{
|
||||
return new List<HealthCheck>();
|
||||
return new HealthCheck(GetType());
|
||||
}
|
||||
|
||||
var request = _cloudRequestBuilder.Create()
|
||||
var request = _cloudRequestBuilder.Services.Create()
|
||||
.Resource("/notification")
|
||||
.AddQueryParam("version", BuildInfo.Version)
|
||||
.AddQueryParam("os", OsInfo.Os.ToString().ToLowerInvariant())
|
||||
@@ -63,17 +56,22 @@ namespace NzbDrone.Core.HealthCheck
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Trace("Getting server side health notifications");
|
||||
_logger.Trace("Getting notifications");
|
||||
|
||||
var response = _client.Execute(request);
|
||||
var result = Json.Deserialize<List<ServerNotificationResponse>>(response.Content);
|
||||
return result.Select(x => new HealthCheck(GetType(), x.Type, x.Message, x.WikiUrl)).ToList();
|
||||
|
||||
var checks = result.Select(x => new HealthCheck(GetType(), x.Type, x.Message, x.WikiUrl)).ToList();
|
||||
|
||||
// Only one health check is supported, services returns an ordered list, so use the first one
|
||||
return checks.FirstOrDefault() ?? new HealthCheck(GetType());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to retrieve server notifications");
|
||||
}
|
||||
_logger.Error(ex, "Failed to retrieve notifications");
|
||||
|
||||
return new List<HealthCheck>();
|
||||
return new HealthCheck(GetType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@ namespace NzbDrone.Core.History
|
||||
history.Data.Add("Host", message.Host ?? string.Empty);
|
||||
history.Data.Add("GrabMethod", message.Redirect ? "Redirect" : "Proxy");
|
||||
history.Data.Add("GrabTitle", message.Title);
|
||||
history.Data.Add("Categories", string.Join(",", message.Release.Categories.Select(x => x.Id) ?? Array.Empty<int>()));
|
||||
history.Data.Add("Url", message.Url ?? string.Empty);
|
||||
|
||||
_historyRepository.Insert(history);
|
||||
|
||||
@@ -17,6 +17,10 @@ namespace NzbDrone.Core.IndexerSearch.Definitions
|
||||
public string SearchType { get; set; }
|
||||
public int? Limit { get; set; }
|
||||
public int? Offset { get; set; }
|
||||
public int? MinAge { get; set; }
|
||||
public int? MaxAge { get; set; }
|
||||
public long? MinSize { get; set; }
|
||||
public long? MaxSize { get; set; }
|
||||
public string Source { get; set; }
|
||||
public string Host { get; set; }
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
public string extended { get; set; }
|
||||
public int? limit { get; set; }
|
||||
public int? offset { get; set; }
|
||||
public int? minage { get; set; }
|
||||
public int? maxage { get; set; }
|
||||
public long? minsize { get; set; }
|
||||
public long? maxsize { get; set; }
|
||||
public int? rid { get; set; }
|
||||
public int? tvmazeid { get; set; }
|
||||
public int? traktid { get; set; }
|
||||
|
||||
@@ -108,6 +108,7 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
GetNabElement("files", r.Files, protocol),
|
||||
GetNabElement("grabs", r.Grabs, protocol),
|
||||
GetNabElement("peers", t.Peers, protocol),
|
||||
r.Year == 0 ? null : GetNabElement("year", r.Year, protocol),
|
||||
GetNabElement("author", RemoveInvalidXMLChars(r.Author), protocol),
|
||||
GetNabElement("booktitle", RemoveInvalidXMLChars(r.BookTitle), protocol),
|
||||
GetNabElement("artist", RemoveInvalidXMLChars(r.Artist), protocol),
|
||||
|
||||
@@ -14,12 +14,12 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.IndexerSearch
|
||||
{
|
||||
public interface ISearchForNzb
|
||||
public interface IReleaseSearchService
|
||||
{
|
||||
Task<NewznabResults> Search(NewznabRequest request, List<int> indexerIds, bool interactiveSearch);
|
||||
}
|
||||
|
||||
public class ReleaseSearchService : ISearchForNzb
|
||||
public class ReleaseSearchService : IReleaseSearchService
|
||||
{
|
||||
private readonly IIndexerLimitService _indexerLimitService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
@@ -67,7 +67,9 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
searchSpec.Year = request.year;
|
||||
searchSpec.Genre = request.genre;
|
||||
|
||||
return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) };
|
||||
var releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec);
|
||||
|
||||
return new NewznabResults { Releases = DeDupeReleases(releases) };
|
||||
}
|
||||
|
||||
private async Task<NewznabResults> MusicSearch(NewznabRequest request, List<int> indexerIds, bool interactiveSearch)
|
||||
@@ -81,7 +83,9 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
searchSpec.Track = request.track;
|
||||
searchSpec.Year = request.year;
|
||||
|
||||
return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) };
|
||||
var releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec);
|
||||
|
||||
return new NewznabResults { Releases = DeDupeReleases(releases) };
|
||||
}
|
||||
|
||||
private async Task<NewznabResults> TvSearch(NewznabRequest request, List<int> indexerIds, bool interactiveSearch)
|
||||
@@ -102,7 +106,9 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
searchSpec.Year = request.year;
|
||||
searchSpec.Genre = request.genre;
|
||||
|
||||
return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) };
|
||||
var releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec);
|
||||
|
||||
return new NewznabResults { Releases = DeDupeReleases(releases) };
|
||||
}
|
||||
|
||||
private async Task<NewznabResults> BookSearch(NewznabRequest request, List<int> indexerIds, bool interactiveSearch)
|
||||
@@ -115,14 +121,18 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
searchSpec.Year = request.year;
|
||||
searchSpec.Genre = request.genre;
|
||||
|
||||
return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) };
|
||||
var releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec);
|
||||
|
||||
return new NewznabResults { Releases = DeDupeReleases(releases) };
|
||||
}
|
||||
|
||||
private async Task<NewznabResults> BasicSearch(NewznabRequest request, List<int> indexerIds, bool interactiveSearch)
|
||||
{
|
||||
var searchSpec = Get<BasicSearchCriteria>(request, indexerIds, interactiveSearch);
|
||||
|
||||
return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) };
|
||||
var releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec);
|
||||
|
||||
return new NewznabResults { Releases = DeDupeReleases(releases) };
|
||||
}
|
||||
|
||||
private TSpec Get<TSpec>(NewznabRequest query, List<int> indexerIds, bool interactiveSearch)
|
||||
@@ -139,6 +149,10 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
spec.SearchType = query.t;
|
||||
spec.Limit = query.limit;
|
||||
spec.Offset = query.offset;
|
||||
spec.MinAge = query.minage;
|
||||
spec.MaxAge = query.maxage;
|
||||
spec.MinSize = query.minsize;
|
||||
spec.MaxSize = query.maxsize;
|
||||
spec.Source = query.source;
|
||||
spec.Host = query.host;
|
||||
|
||||
@@ -218,6 +232,34 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
}
|
||||
}
|
||||
|
||||
if (criteriaBase.MinAge is > 0)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.Subtract(TimeSpan.FromDays(criteriaBase.MinAge.Value));
|
||||
|
||||
releases = releases.Where(r => r.PublishDate <= cutoffDate).ToList();
|
||||
}
|
||||
|
||||
if (criteriaBase.MaxAge is > 0)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.Subtract(TimeSpan.FromDays(criteriaBase.MaxAge.Value));
|
||||
|
||||
releases = releases.Where(r => r.PublishDate >= cutoffDate).ToList();
|
||||
}
|
||||
|
||||
if (criteriaBase.MinSize is > 0)
|
||||
{
|
||||
var minSize = criteriaBase.MinSize.Value;
|
||||
|
||||
releases = releases.Where(r => r.Size >= minSize).ToList();
|
||||
}
|
||||
|
||||
if (criteriaBase.MaxSize is > 0)
|
||||
{
|
||||
var maxSize = criteriaBase.MaxSize.Value;
|
||||
|
||||
releases = releases.Where(r => r.Size <= maxSize).ToList();
|
||||
}
|
||||
|
||||
foreach (var query in indexerReports.Queries)
|
||||
{
|
||||
_eventAggregator.PublishEvent(new IndexerQueryEvent(indexer.Definition.Id, criteriaBase, query));
|
||||
@@ -233,5 +275,13 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
|
||||
return Array.Empty<ReleaseInfo>();
|
||||
}
|
||||
|
||||
private List<ReleaseInfo> DeDupeReleases(IList<ReleaseInfo> releases)
|
||||
{
|
||||
// De-dupe reports by guid so duplicate results aren't returned. Pick the one with the higher indexer priority.
|
||||
return releases.GroupBy(r => r.Guid)
|
||||
.Select(r => r.OrderBy(v => v.IndexerPriority).First())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace NzbDrone.Core.IndexerStats
|
||||
{
|
||||
public interface IIndexerStatisticsService
|
||||
{
|
||||
CombinedStatistics IndexerStatistics(DateTime start, DateTime end);
|
||||
CombinedStatistics IndexerStatistics(DateTime start, DateTime end, List<int> indexerIds);
|
||||
}
|
||||
|
||||
public class IndexerStatisticsService : IIndexerStatisticsService
|
||||
@@ -22,13 +22,15 @@ namespace NzbDrone.Core.IndexerStats
|
||||
_indexerFactory = indexerFactory;
|
||||
}
|
||||
|
||||
public CombinedStatistics IndexerStatistics(DateTime start, DateTime end)
|
||||
public CombinedStatistics IndexerStatistics(DateTime start, DateTime end, List<int> indexerIds)
|
||||
{
|
||||
var history = _historyService.Between(start, end);
|
||||
|
||||
var groupedByIndexer = history.GroupBy(h => h.IndexerId);
|
||||
var groupedByUserAgent = history.GroupBy(h => h.Data.GetValueOrDefault("source") ?? "");
|
||||
var groupedByHost = history.GroupBy(h => h.Data.GetValueOrDefault("host") ?? "");
|
||||
var filteredHistory = history.Where(h => indexerIds.Contains(h.IndexerId));
|
||||
|
||||
var groupedByIndexer = filteredHistory.GroupBy(h => h.IndexerId);
|
||||
var groupedByUserAgent = filteredHistory.GroupBy(h => h.Data.GetValueOrDefault("source") ?? "");
|
||||
var groupedByHost = filteredHistory.GroupBy(h => h.Data.GetValueOrDefault("host") ?? "");
|
||||
|
||||
var indexerStatsList = new List<IndexerStatistics>();
|
||||
var userAgentStatsList = new List<UserAgentStatistics>();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using NLog;
|
||||
@@ -5,6 +6,7 @@ using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.Indexers.Definitions.Gazelle;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
|
||||
namespace NzbDrone.Core.Indexers.Definitions;
|
||||
@@ -15,6 +17,9 @@ public class AlphaRatio : GazelleBase<AlphaRatioSettings>
|
||||
public override string[] IndexerUrls => new[] { "https://alpharatio.cc/" };
|
||||
public override string Description => "AlphaRatio(AR) is a Private Torrent Tracker for 0DAY / GENERAL";
|
||||
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
|
||||
public override bool SupportsPagination => true;
|
||||
public override int PageSize => 50;
|
||||
public override TimeSpan RateLimit => TimeSpan.FromSeconds(3);
|
||||
|
||||
public AlphaRatio(IIndexerHttpClient httpClient,
|
||||
IEventAggregator eventAggregator,
|
||||
@@ -39,6 +44,8 @@ public class AlphaRatio : GazelleBase<AlphaRatioSettings>
|
||||
{
|
||||
var caps = new IndexerCapabilities
|
||||
{
|
||||
LimitsDefault = PageSize,
|
||||
LimitsMax = PageSize,
|
||||
TvSearchParams = new List<TvSearchParam>
|
||||
{
|
||||
TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep
|
||||
@@ -98,9 +105,9 @@ public class AlphaRatioRequestGenerator : GazelleRequestGenerator
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
protected override NameValueCollection GetBasicSearchParameters(string term, int[] categories)
|
||||
protected override NameValueCollection GetBasicSearchParameters(SearchCriteriaBase searchCriteria, string term)
|
||||
{
|
||||
var parameters = base.GetBasicSearchParameters(term, categories);
|
||||
var parameters = base.GetBasicSearchParameters(searchCriteria, term);
|
||||
|
||||
if (_settings.FreeleechOnly)
|
||||
{
|
||||
@@ -112,6 +119,12 @@ public class AlphaRatioRequestGenerator : GazelleRequestGenerator
|
||||
parameters.Set("scene", "0");
|
||||
}
|
||||
|
||||
if (searchCriteria.Limit is > 0 && searchCriteria.Offset is > 0)
|
||||
{
|
||||
var page = (int)(searchCriteria.Offset / searchCriteria.Limit) + 1;
|
||||
parameters.Set("page", page.ToString());
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
var releaseInfos = new List<ReleaseInfo>();
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
using var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
|
||||
var rows = dom.QuerySelectorAll("div#content table > tbody > tr");
|
||||
foreach (var row in rows)
|
||||
|
||||
@@ -77,7 +77,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
else
|
||||
{
|
||||
var parser = new HtmlParser();
|
||||
var document = await parser.ParseDocumentAsync(response.Content);
|
||||
using var document = await parser.ParseDocumentAsync(response.Content);
|
||||
var errorMessage = document.QuerySelector("#content .berror .berror_c")?.TextContent.Trim();
|
||||
|
||||
throw new IndexerAuthException(errorMessage ?? "Unknown error message, please report.");
|
||||
@@ -433,7 +433,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
{
|
||||
var torrentInfos = new List<TorrentInfo>();
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
using var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
|
||||
foreach (var t in dom.QuerySelectorAll("#tabs .torrent_c > div"))
|
||||
{
|
||||
@@ -465,7 +465,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
var torrentInfos = new List<ReleaseInfo>();
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
using var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
|
||||
var links = dom.QuerySelectorAll(".searchitem > h3 > a[href], #dle-content > .story > .story_h > .lcol > h2 > a[href]");
|
||||
foreach (var link in links)
|
||||
|
||||
@@ -555,6 +555,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
MinimumRatio = 1,
|
||||
MinimumSeedTime = minimumSeedTime,
|
||||
Title = fileName,
|
||||
Year = year.GetValueOrDefault(),
|
||||
InfoUrl = details.AbsoluteUri,
|
||||
Guid = guid.AbsoluteUri,
|
||||
DownloadUrl = link.AbsoluteUri,
|
||||
@@ -571,8 +572,6 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
};
|
||||
|
||||
releaseInfos.Add(release);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +588,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
MinimumRatio = 1,
|
||||
MinimumSeedTime = minimumSeedTime,
|
||||
Title = releaseTitle.Trim(),
|
||||
Year = year.GetValueOrDefault(),
|
||||
InfoUrl = details.AbsoluteUri,
|
||||
Guid = guid.AbsoluteUri,
|
||||
DownloadUrl = link.AbsoluteUri,
|
||||
@@ -699,7 +699,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
[FieldDefinition(8, Label = "Enable Sonarr Compatibility", Type = FieldType.Checkbox, HelpText = "Makes Prowlarr try to add Season information into Release names, without this Sonarr can't match any Seasons, but it has a lot of false positives as well")]
|
||||
public bool EnableSonarrCompatibility { get; set; }
|
||||
|
||||
[FieldDefinition(9, Label = "Use Filenames for Single Episodes", Type = FieldType.Checkbox, HelpText = "Makes Prowlarr replace AnimeBytes release names with the actual filename, this currently only works for single episode releases")]
|
||||
[FieldDefinition(9, Label = "Use Filenames for Single Episodes", Type = FieldType.Checkbox, HelpText = "Add a release using the actual filename, this currently only works for single episode releases")]
|
||||
public bool UseFilenameForSingleEpisodes { get; set; }
|
||||
|
||||
[FieldDefinition(10, Label = "Add Japanese title as a synonym", Type = FieldType.Checkbox, HelpText = "Makes Prowlarr add Japanese titles as synonyms, i.e kanji/hiragana/katakana.")]
|
||||
|
||||
@@ -275,7 +275,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
var releaseInfos = new List<ReleaseInfo>();
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
using var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
|
||||
var rows = dom.QuerySelectorAll("table tr");
|
||||
foreach (var (row, index) in rows.Skip(1).Select((v, i) => (v, i)))
|
||||
|
||||
@@ -17,6 +17,7 @@ using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.Indexers.Definitions
|
||||
{
|
||||
[Obsolete("Site is unusable due to a mix of HTTP errors")]
|
||||
public class Animedia : TorrentIndexerBase<NoAuthTorrentBaseSettings>
|
||||
{
|
||||
public override string Name => "Animedia";
|
||||
@@ -252,7 +253,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
{
|
||||
var torrentInfos = new List<TorrentInfo>();
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
using var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
|
||||
foreach (var t in dom.QuerySelectorAll("ul.media__tabs__nav > li > a"))
|
||||
{
|
||||
@@ -290,7 +291,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
var torrentInfos = new List<ReleaseInfo>();
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
using var dom = parser.ParseDocument(indexerResponse.Content);
|
||||
|
||||
var links = dom.QuerySelectorAll("a.ads-list__item__title");
|
||||
foreach (var link in links)
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
if (CheckIfLoginNeeded(response))
|
||||
{
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(response.Content);
|
||||
using var dom = parser.ParseDocument(response.Content);
|
||||
var errorMessage = dom.QuerySelector("form#loginform")?.TextContent.Trim();
|
||||
|
||||
throw new IndexerAuthException(errorMessage ?? "Unknown error message, please report.");
|
||||
@@ -206,7 +206,7 @@ namespace NzbDrone.Core.Indexers.Definitions
|
||||
var torrentInfos = new List<ReleaseInfo>();
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var doc = parser.ParseDocument(indexerResponse.Content);
|
||||
using var doc = parser.ParseDocument(indexerResponse.Content);
|
||||
var rows = doc.QuerySelectorAll("table.torrent_table > tbody > tr.torrent");
|
||||
foreach (var row in rows)
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ public class AroLol : GazelleBase<AroLolSettings>
|
||||
if (response.Content.Contains("loginform"))
|
||||
{
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(response.Content);
|
||||
using var dom = parser.ParseDocument(response.Content);
|
||||
var errorMessage = dom.QuerySelector("#loginform > .warning")?.TextContent.Trim();
|
||||
|
||||
throw new IndexerAuthException(errorMessage ?? "Unknown error message, please report.");
|
||||
|
||||
@@ -88,7 +88,7 @@ public class AudioBookBay : TorrentIndexerBase<NoAuthTorrentBaseSettings>
|
||||
var response = await _httpClient.ExecuteProxiedAsync(request, Definition);
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var dom = parser.ParseDocument(response.Content);
|
||||
using var dom = parser.ParseDocument(response.Content);
|
||||
|
||||
var hash = dom.QuerySelector("td:contains(\"Info Hash:\") ~ td")?.TextContent.Trim();
|
||||
if (hash == null)
|
||||
@@ -269,7 +269,7 @@ public class AudioBookBayParser : IParseIndexerResponse
|
||||
{
|
||||
var releaseInfos = new List<ReleaseInfo>();
|
||||
|
||||
var doc = ParseHtmlDocument(indexerResponse.Content);
|
||||
using var doc = ParseHtmlDocument(indexerResponse.Content);
|
||||
|
||||
var rows = doc.QuerySelectorAll("div.post:has(div[class=\"postTitle\"])");
|
||||
foreach (var row in rows)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user