Compare commits

...

57 Commits

Author SHA1 Message Date
Stevie Robinson
2faef704b4 Fixed: Replacing 'appName' translation token
(cherry picked from commit 2e51b8792db0d3ec402672dc92c95f3cb886ef44)

Closes #3058
Fixes #3221
2024-01-16 23:50:14 +02:00
Bogdan
a566c3e21f Check Content-Type in FileList parser 2024-01-16 21:52:40 +02:00
Stevie Robinson
cc0d2a84ae Sort Custom Filters
(cherry picked from commit e4b5d559df2d5f3d55e16aae5922509e84f31e64)
2024-01-16 08:08:39 +02:00
Qstick
1c3d2ce4e5 Improved http timeout handling
(cherry picked from commit f87a66fcba6ca9ca972fa1c747a940b216e0e5e3)
2024-01-16 08:08:26 +02:00
Weblate
57f614f4cd Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Daniele Prevedello <dprevedello86@gmail.com>
Co-authored-by: DimitriDR <dimitridroeck@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: crayon3shawn <crayon3shawn@gmail.com>
Co-authored-by: hansaudun <hans@n5.no>
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/zh_TW/
Translation: Servarr/Readarr
2024-01-15 20:01:07 +02:00
Bogdan
9d2efe0944 Fixed: Cutoff unmet showing Unmonitored books 2024-01-15 16:43:12 +02:00
Bogdan
e032be48e0 Fixed: Wanted Missing showing Unmonitored books 2024-01-15 16:41:03 +02:00
Bogdan
cd66de1992 Bump version to 0.3.16 2024-01-14 07:12:55 +02:00
Bogdan
3066dd92d7 Ignore tests temporarily 2024-01-13 03:36:01 +02:00
Servarr
467a87baec Automated API Docs update 2024-01-13 02:04:59 +02:00
Bogdan
80fb077c94 Fix log typo in release/push 2024-01-13 01:28:08 +02:00
Bogdan
07433d69ca New: Resolve download client by name using 'downloadClient' for pushed releases
Closes #3053
2024-01-13 01:27:36 +02:00
Mark McDowall
3b3ebe463c Fixed: Pushed releases not being properly rejected
(cherry picked from commit 07f816ffb18ac34090c2f8ba25147737299b361d)

Closes #2943
2024-01-13 01:26:15 +02:00
Mark McDowall
03392ca635 New: Optional 'downloadClientId' for pushed releases
(cherry picked from commit fa5bfc3742c24c5730b77bf8178a423d98fdf50e)

Closes #2934
2024-01-13 01:23:11 +02:00
Bogdan
d23ce9ecc2 Allow to override download client
Towards #2331
2024-01-13 01:18:11 +02:00
Bogdan
e968fcaff6 Fixed: Filter history by multiple event types in PG 2024-01-12 22:10:48 +02:00
Gavin Mogan
31da559f89 Fixed: Database type when PG host contains ".db" (#3186)
Previously was looking for a ".db" in the connection string, which is
the typical sqlite filename. The problem is if your connection string has a
.db anywhere in it, such as postgres.db.internal it'll think its a
sqlite file

Solution borrowed from sonarr:

https://github.com/Sonarr/Sonarr/blob/develop/src/NzbDrone.Core/Datastore/Database.cs#L43
2024-01-12 13:29:06 +02:00
Servarr
a093290792 Automated API Docs update 2024-01-12 04:02:47 +02:00
Mark McDowall
9e3dfc510d Paging params in API docs
(cherry picked from commit bfaa7291e14a8d3847ef2154a52c363944560803)

Closes #2975
Closes #2991
2024-01-12 03:52:11 +02:00
Bogdan
9d27c172ac Fixed: Improve torrent blocklist matching
Closes #3184
2024-01-12 03:28:57 +02:00
Bogdan
518dbe53eb Fixed: Release source for release/push
Closes #3182
2024-01-12 03:27:39 +02:00
ilike2burnthing
f9ba00c9e7 Remove unsupported pagination for Nyaa
(cherry picked from commit fef525ddb8b5f91bb36b3c9e652663fccb098a00)

Closes #3180
2024-01-12 03:25:39 +02:00
ilike2burnthing
4aec7a0ea7 Remove dead Torznab API key whitelist
(cherry picked from commit 3454f1c9ed11fbb9aa66e16524a529e924e5a77e)

Closes #3179
2024-01-12 03:23:37 +02:00
Weblate
fc4cf8e81e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Watashi <drazy24@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fr/
Translation: Servarr/Readarr
2024-01-12 03:21:34 +02:00
Weblate
143de3b220 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Aleksandr <alyarmak@gmail.com>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Bradley BARBIER <bradley.barbier@outlook.fr>
Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: DimitriDR <dimitridroeck@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: HuaBing <admin@hbcraft.cn>
Co-authored-by: JJonttuu <oikeaihminen@protonmail.com>
Co-authored-by: Juan Lores <juan.lores@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Piotr Komborski <piotr+github@kombor.ski>
Co-authored-by: RicardoVelaC <ricardovelac@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: boan51204 <je.991707@gmail.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/zh_TW/
Translation: Servarr/Readarr
2024-01-10 21:05:13 +02:00
Servarr
e1a07d01b2 Automated API Docs update 2024-01-10 19:13:31 +02:00
Bogdan
27e498bb14 Fixed: Add ForeignEditionId to books endpoint 2024-01-10 19:07:29 +02:00
Bogdan
b9f1882a57 Fixed: Refresh book files after renaming 2024-01-09 19:27:16 +02:00
ta264
2392573c39 New: Freeleech tokens support for Gazelle 2024-01-09 16:14:27 +02:00
Mark McDowall
2351efd013 Fixed: Blocklisting torrents from indexers that do not provide torrent hash
(cherry picked from commit 3541cd7ba877fb785c7f97123745abf51162eb8e)

Closes #3082
2024-01-09 16:07:26 +02:00
Weblate
526429bde4 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: DimitriDR <dimitridroeck@gmail.com>
Co-authored-by: JJonttuu <oikeaihminen@protonmail.com>
Co-authored-by: RicardoVelaC <ricardovelac@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fr/
Translation: Servarr/Readarr
2024-01-09 15:48:13 +02:00
Servarr
abd44b59bc Automated API Docs update 2024-01-09 15:46:24 +02:00
Bogdan
9942457ffc Rename to books 2024-01-09 03:32:05 +02:00
Mark McDowall
073342ef39 New: Download client option for redownloading failed releases from Interactive Search
(cherry picked from commit 87e0a7983a437a4d166aa8b9c9eaf78ea5431969)

Closes #2987
2024-01-09 03:14:48 +02:00
Bogdan
b455708f2e Add release source for releases
Towards #2130
2024-01-09 03:10:52 +02:00
Bogdan
622b02c478 Use last history item in FailedDownloadService 2024-01-09 03:05:52 +02:00
Bogdan
8effba383d Bump version to 0.3.15 2024-01-07 11:11:32 +02:00
Bogdan
2749479283 Fix possible enumerations in TrackGroupingService 2024-01-06 19:41:17 +02:00
Bogdan
4cbafa76d8 New: Custom formats in book history
(cherry picked from commit cd2ce34f10007efacd8631d3ce3ac4f4a6212966)

Closes #2134
Closes #3163
2024-01-06 19:41:17 +02:00
Bogdan
73782cc233 Remove duplicated source title in history 2024-01-06 19:41:17 +02:00
Weblate
de396fe9be Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mario Rodriguez <mario2423@gmail.com>
Co-authored-by: Norbi <kovinor123@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/pt_BR/
Translation: Servarr/Readarr
2024-01-01 14:11:59 +02:00
Mark McDowall
71cb9e1dd7 Fixed: Disable SSL on start if certificate path is not set
(cherry picked from commit 4e19fec123900b8ba1252b640f26f2a4983683ff)
2024-01-01 06:58:14 +02:00
Mark McDowall
ee5ed57fcc New: Add qBittorrent option for Content Layout
(cherry picked from commit 4b22200708ca120cfdcf9cb796be92183adb95d1)

Closes #3140
2023-12-31 11:12:25 +02:00
Stevie Robinson
d20a049a5a Translate fields on the backend
(cherry picked from commit 48b12f5b00429a7cd218d23f0544641b0da62a06)
2023-12-31 11:10:45 +02:00
Stevie Robinson
a9f77ace37 Fixed: Fallback to English translations if invalid UI language in config
(cherry picked from commit 4c7201741276eccaea2fb1f33daecc31e8b2d54e)

Closes #2882
2023-12-31 11:07:58 +02:00
Mark McDowall
0341a2ec26 Initial support to use named tokens for backend translations
Towards #3003

(cherry picked from commit 11f96c31048c2d1aafca0c91736d439f7f9a95a8)
2023-12-31 11:03:44 +02:00
Stevie Robinson
d6796bbe1a New: Show Proper or Repack tag in interactive search
(cherry picked from commit efb000529b5dff42829df3ef151e4750a7b15cf6)

Closes #3141
2023-12-31 10:57:25 +02:00
Bogdan
9066f8558c New: Retry on failed downloads of torrent and nzb files
(cherry picked from commit bc20ef73bdd47b7cdad43d4c7d4b4bd534e49252)

Closes #3151
2023-12-31 10:52:37 +02:00
Weblate
c4e37528ee Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Koch Norbert <kochnorbert@icloud.com>
Co-authored-by: Nicola <nicola.neri@gmail.com>
Co-authored-by: SunStorm <me@sunstorm.rocks>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: chiral-lab <jan.eltner@googlemail.com>
Co-authored-by: chrizl <chrizl@gmail.com>
Co-authored-by: resi23 <x-resistant-x@gmx.de>
Co-authored-by: slammingdeath <sebastianbrudny97@gmail.com>
Co-authored-by: ube <ube@alienautopsy.net>
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/readarr/sv/
Translation: Servarr/Readarr
2023-12-31 06:25:13 +02:00
Bogdan
5937c952af Fixed: Ignore empty tags when adding items to Flood
(cherry picked from commit 0a5200766ea80fc1c97bfa497cdfed31b9af687f)
2023-12-31 06:24:43 +02:00
Stevie Robinson
0f4bd3c472 New: Add sorting to Manage Indexer and Download Client modals
(cherry picked from commit 91053ca51ded804739f94ee936c1376a755dbe11)
2023-12-31 06:24:21 +02:00
Qstick
cf415e61de New: Bulk Delete for Unmapped Files
(cherry picked from commit 71c1edd47c5377bcdeeb68e9cededf122a6ce6b4)
2023-12-27 03:17:41 +02:00
Bogdan
9865e92cea Add error message for invalid Root Folder in Ebook Tag Service 2023-12-25 01:53:21 +02:00
Bogdan
1cf956a9d9 Don't use empty file path from Calibre 2023-12-25 01:53:21 +02:00
Bogdan
8989c55c8c Bump version to 0.3.14 2023-12-24 09:12:40 +02:00
Bogdan
dc83e0127e Fixed: Minor UI improvements to author and book details 2023-12-24 09:05:46 +02:00
Bogdan
34eb312426 Fixed: File Count on Books page 2023-12-24 07:22:10 +02:00
168 changed files with 2475 additions and 1189 deletions

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '0.3.13'
majorVersion: '0.3.16'
minorVersion: $[counter('minorVersion', 1)]
readarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(readarrVersion)'

View File

@@ -94,7 +94,7 @@ class RemoveQueueItemsModal extends Component {
<ModalBody>
<div className={styles.message}>
{selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', selectedCount) : translate('RemoveSelectedItemQueueMessageText')}
{selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', { selectedCount }) : translate('RemoveSelectedItemQueueMessageText')}
</div>
{

View File

@@ -44,6 +44,10 @@
margin-top: 20px;
}
.filterIcon {
float: right;
}
.authorNavigationButtons {
position: absolute;
right: 0;

View File

@@ -6,6 +6,7 @@ interface CssExports {
'authorUpButton': string;
'contentContainer': string;
'errorMessage': string;
'filterIcon': string;
'innerContentBody': string;
'metadataMessage': string;
'selectedTab': string;

View File

@@ -239,9 +239,14 @@ class AuthorDetails extends Component {
saveError,
isDeleting,
deleteError,
statistics
statistics = {}
} = this.props;
const {
bookFileCount = 0,
totalBookCount = 0
} = statistics;
const {
isOrganizeModalOpen,
isRetagModalOpen,
@@ -435,7 +440,7 @@ class AuthorDetails extends Component {
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('BooksTotal', [statistics.totalBookCount])}
{translate('BooksTotal', [totalBookCount])}
</Tab>
<Tab
@@ -463,7 +468,7 @@ class AuthorDetails extends Component {
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('FilesTotal', [statistics.bookFileCount])}
{translate('FilesTotal', [bookFileCount])}
</Tab>
{

View File

@@ -155,7 +155,6 @@ function createMapStateToProps() {
const isRefreshing = isAuthorRefreshing || allAuthorRefreshing;
const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.AUTHOR_SEARCH, authorId: author.id }));
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, authorId: author.id }));
const isRenamingAuthorCommand = findCommand(commands, { name: commandNames.RENAME_AUTHOR });
const isRenamingAuthor = (
isCommandExecuting(isRenamingAuthorCommand) &&

View File

@@ -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 AuthorHistoryContentConnector from './AuthorHistoryContentConnector';
import AuthorHistoryModalContent from './AuthorHistoryModalContent';
@@ -14,6 +15,7 @@ function AuthorHistoryModal(props) {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose}
>
<AuthorHistoryContentConnector

View File

@@ -5,6 +5,7 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import translate from 'Utilities/String/translate';
import AuthorHistoryTableContent from './AuthorHistoryTableContent';
class AuthorHistoryModalContent extends Component {
@@ -20,7 +21,7 @@ class AuthorHistoryModalContent extends Component {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
History
{translate('History')}
</ModalHeader>
<ModalBody>
@@ -31,7 +32,7 @@ class AuthorHistoryModalContent extends Component {
<ModalFooter>
<Button onPress={onModalClose}>
Close
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -4,7 +4,6 @@
word-break: break-word;
}
.details,
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';

View File

@@ -2,7 +2,6 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'details': string;
'sourceTitle': string;
}
export const cssExports: CssExports;

View File

@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import BookFormats from 'Book/BookFormats';
import BookQuality from 'Book/BookQuality';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
@@ -11,6 +12,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './AuthorHistoryRow.css';
@@ -75,6 +77,8 @@ class AuthorHistoryRow extends Component {
sourceTitle,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore,
date,
data,
book
@@ -106,11 +110,19 @@ class AuthorHistoryRow extends Component {
/>
</TableRowCell>
<TableRowCell>
<BookFormats formats={customFormats} />
</TableRowCell>
<TableRowCell>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCellConnector
date={date}
/>
<TableRowCell className={styles.details}>
<TableRowCell className={styles.actions}>
<Popover
anchor={
<Icon
@@ -127,14 +139,13 @@ class AuthorHistoryRow extends Component {
}
position={tooltipPositions.LEFT}
/>
</TableRowCell>
<TableRowCell className={styles.actions}>
{
eventType === 'grabbed' &&
<IconButton
title={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={this.onMarkAsFailedPress}
/>
}
@@ -160,6 +171,8 @@ AuthorHistoryRow.propTypes = {
sourceTitle: PropTypes.string.isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
fullAuthor: PropTypes.bool.isRequired,

View File

@@ -0,0 +1,9 @@
.container {
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--inputBackgroundColor);
&:last-of-type {
margin-bottom: 0;
}
}

View File

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

View File

@@ -1,6 +1,7 @@
import React from 'react';
import AuthorHistoryContentConnector from 'Author/History/AuthorHistoryContentConnector';
import AuthorHistoryTableContent from 'Author/History/AuthorHistoryTableContent';
import styles from './AuthorHistoryTable.css';
function AuthorHistoryTable(props) {
const {
@@ -8,10 +9,12 @@ function AuthorHistoryTable(props) {
} = props;
return (
<AuthorHistoryContentConnector
component={AuthorHistoryTableContent}
{...otherProps}
/>
<div className={styles.container}>
<AuthorHistoryContentConnector
component={AuthorHistoryTableContent}
{...otherProps}
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
.blankpad {
padding-top: 10px;
padding-bottom: 10px;
padding-left: 2em;
}

View File

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

View File

@@ -1,12 +1,14 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { kinds } from 'Helpers/Props';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AuthorHistoryRowConnector from './AuthorHistoryRowConnector';
import styles from './AuthorHistoryTableContent.css';
const columns = [
{
@@ -15,32 +17,41 @@ const columns = [
},
{
name: 'book',
label: 'Book',
label: () => translate('Book'),
isVisible: true
},
{
name: 'sourceTitle',
label: 'Source Title',
label: () => translate( 'SourceTitle'),
isVisible: true
},
{
name: 'quality',
label: 'Quality',
label: () => translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'date',
label: 'Date',
isVisible: true
},
{
name: 'details',
label: 'Details',
label: () => translate('Date'),
isVisible: true
},
{
name: 'actions',
label: 'Actions',
isVisible: true
}
];
@@ -64,7 +75,7 @@ class AuthorHistoryTableContent extends Component {
const hasItems = !!items.length;
return (
<>
<div>
{
isFetching &&
<LoadingIndicator />
@@ -79,7 +90,7 @@ class AuthorHistoryTableContent extends Component {
{
isPopulated && !hasItems && !error &&
<div>
<div className={styles.blankpad}>
{translate('NoHistory')}
</div>
}
@@ -103,7 +114,7 @@ class AuthorHistoryTableContent extends Component {
</TableBody>
</Table>
}
</>
</div>
);
}
}

View File

@@ -3,6 +3,7 @@ import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
function getTooltip(title, quality, size, isMonitored, isCutoffNotMet) {
const revision = quality.revision;
@@ -28,6 +29,36 @@ function getTooltip(title, quality, size, isMonitored, isCutoffNotMet) {
return title;
}
function revisionLabel(className, quality, showRevision) {
if (!showRevision) {
return;
}
if (quality.revision.isRepack) {
return (
<Label
className={className}
kind={kinds.PRIMARY}
title={translate('Repack')}
>
R
</Label>
);
}
if (quality.revision.version && quality.revision.version > 1) {
return (
<Label
className={className}
kind={kinds.PRIMARY}
title={translate('Proper')}
>
P
</Label>
);
}
}
function BookQuality(props) {
const {
className,
@@ -35,7 +66,8 @@ function BookQuality(props) {
quality,
size,
isMonitored,
isCutoffNotMet
isCutoffNotMet,
showRevision
} = props;
let kind = kinds.DEFAULT;
@@ -50,13 +82,15 @@ function BookQuality(props) {
}
return (
<Label
className={className}
kind={kind}
title={getTooltip(title, quality, size, isMonitored, isCutoffNotMet)}
>
{quality.quality.name}
</Label>
<span>
<Label
className={className}
kind={kind}
title={getTooltip(title, quality, size, isMonitored, isCutoffNotMet)}
>
{quality.quality.name}
</Label>{revisionLabel(className, quality, showRevision)}
</span>
);
}
@@ -66,12 +100,14 @@ BookQuality.propTypes = {
quality: PropTypes.object.isRequired,
size: PropTypes.number,
isMonitored: PropTypes.bool,
isCutoffNotMet: PropTypes.bool
isCutoffNotMet: PropTypes.bool,
showRevision: PropTypes.bool
};
BookQuality.defaultProps = {
title: '',
isMonitored: true
isMonitored: true,
showRevision: false
};
export default BookQuality;

View File

@@ -99,9 +99,14 @@ class BookDetails extends Component {
nextBook,
isSearching,
onRefreshPress,
onSearchPress
onSearchPress,
statistics = {}
} = this.props;
const {
bookFileCount = 0
} = statistics;
const {
isOrganizeModalOpen,
isRetagModalOpen,
@@ -238,21 +243,21 @@ class BookDetails extends Component {
className={styles.tab}
selectedClassName={styles.selectedTab}
>
History
{translate('History')}
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Search
{translate('Search')}
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Files
{translate('FilesTotal', [bookFileCount])}
</Tab>
{
@@ -335,6 +340,7 @@ BookDetails.propTypes = {
ratings: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
links: PropTypes.arrayOf(PropTypes.object).isRequired,
statistics: PropTypes.object.isRequired,
monitored: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired,

View File

@@ -69,16 +69,21 @@ function createMapStateToProps() {
const previousBook = sortedBooks[bookIndex - 1] || _.last(sortedBooks);
const nextBook = sortedBooks[bookIndex + 1] || _.first(sortedBooks);
const isRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_BOOK });
const isRefreshing = (
isCommandExecuting(isRefreshingCommand) &&
isRefreshingCommand.body.bookId === book.id
);
const isSearchingCommand = findCommand(commands, { name: commandNames.BOOK_SEARCH });
const isSearching = (
isCommandExecuting(isSearchingCommand) &&
isSearchingCommand.body.bookIds.indexOf(book.id) > -1
);
const isRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_BOOK });
const isRefreshing = (
isCommandExecuting(isRefreshingCommand) &&
isRefreshingCommand.body.bookId === book.id
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, authorId: author.id }));
const isRenamingAuthorCommand = findCommand(commands, { name: commandNames.RENAME_AUTHOR });
const isRenamingAuthor = (
isCommandExecuting(isRenamingAuthorCommand) &&
isRenamingAuthorCommand.body.authorIds.indexOf(author.id) > -1
);
const isFetching = isBookFilesFetching || editions.isFetching;
@@ -90,6 +95,8 @@ function createMapStateToProps() {
author,
isRefreshing,
isSearching,
isRenamingFiles,
isRenamingAuthor,
isFetching,
isPopulated,
bookFilesError,
@@ -125,9 +132,27 @@ class BookDetailsConnector extends Component {
}
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id ||
const {
id,
anyReleaseOk,
isRenamingFiles,
isRenamingAuthor
} = this.props;
if (
(prevProps.isRenamingFiles && !isRenamingFiles) ||
(prevProps.isRenamingAuthor && !isRenamingAuthor) ||
!_.isEqual(getMonitoredEditions(prevProps), getMonitoredEditions(this.props)) ||
(prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) {
(prevProps.anyReleaseOk === false && anyReleaseOk === true)
) {
this.unpopulate();
this.populate();
}
// If the id has changed we need to clear the book
// files and fetch from the server.
if (prevProps.id !== id) {
this.unpopulate();
this.populate();
}
@@ -197,6 +222,8 @@ class BookDetailsConnector extends Component {
BookDetailsConnector.propTypes = {
id: PropTypes.number,
anyReleaseOk: PropTypes.bool,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingAuthor: PropTypes.bool.isRequired,
isBookFetching: PropTypes.bool,
isBookPopulated: PropTypes.bool,
titleSlug: PropTypes.string.isRequired,

View File

@@ -229,7 +229,6 @@ class BookIndexRow extends Component {
className={styles[name]}
>
{bookFileCount}
</VirtualTableRowCell>
);
}

View File

@@ -0,0 +1,9 @@
.container {
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--inputBackgroundColor);
&:last-of-type {
margin-bottom: 0;
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import React from 'react';
import BookFileEditorTableContentConnector from './BookFileEditorTableContentConnector';
import styles from './BookFileEditorTable.css';
function BookFileEditorTable(props) {
const {
@@ -7,9 +8,11 @@ function BookFileEditorTable(props) {
} = props;
return (
<BookFileEditorTableContentConnector
{...otherProps}
/>
<div className={styles.container}>
<BookFileEditorTableContentConnector
{...otherProps}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
.filesTable {
margin-bottom: 20px;
padding-top: 15px;
margin: 10px;
padding-top: 5px;
border: 1px solid var(--borderColor);
border-top: 1px solid var(--borderColor);
border-radius: 4px;
@@ -13,9 +13,15 @@
.actions {
display: flex;
margin-right: auto;
margin: 10px;
}
.selectInput {
margin-left: 10px;
}
.blankpad {
padding-top: 10px;
padding-bottom: 10px;
padding-left: 2em;
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'blankpad': string;
'filesTable': string;
'selectInput': string;
}

View File

@@ -1,6 +1,7 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import SelectInput from 'Components/Form/SelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -120,7 +121,7 @@ class BookFileEditorTableContent extends Component {
const hasSelectedFiles = this.getSelectedIds().length > 0;
return (
<>
<div>
{
isFetching && !isPopulated ?
<LoadingIndicator /> :
@@ -129,13 +130,13 @@ class BookFileEditorTableContent extends Component {
{
!isFetching && error ?
<div>{error}</div> :
<Alert kind={kinds.DANGER}>{error}</Alert> :
null
}
{
isPopulated && !items.length ?
<div>
<div className={styles.blankpad}>
No book files to manage.
</div> :
null
@@ -173,26 +174,30 @@ class BookFileEditorTableContent extends Component {
null
}
<div className={styles.actions}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!hasSelectedFiles}
onPress={this.onDeletePress}
>
Delete
</SpinnerButton>
{
isPopulated && items.length ? (
<div className={styles.actions}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!hasSelectedFiles}
onPress={this.onDeletePress}
>
{translate('Delete')}
</SpinnerButton>
<div className={styles.selectInput}>
<SelectInput
name="quality"
value="selectQuality"
values={qualityOptions}
isDisabled={!hasSelectedFiles}
onChange={this.onQualityChange}
/>
</div>
</div>
<div className={styles.selectInput}>
<SelectInput
name="quality"
value="selectQuality"
values={qualityOptions}
isDisabled={!hasSelectedFiles}
onChange={this.onQualityChange}
/>
</div>
</div>
) : null
}
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
@@ -203,7 +208,7 @@ class BookFileEditorTableContent extends Component {
onConfirm={this.onConfirmDelete}
onCancel={this.onConfirmDeleteModalClose}
/>
</>
</div>
);
}
}

View File

@@ -29,22 +29,24 @@ function CustomFiltersModalContent(props) {
<ModalBody>
{
customFilters.map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
})
customFilters
.sort((a, b) => a.label.localeCompare(b.label))
.map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
})
}
<div className={styles.addButtonContainer}>

View File

@@ -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 {

View File

@@ -39,18 +39,26 @@ class FilterMenuContent extends Component {
}
{
customFilters.map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
customFilters.length > 0 ?
<MenuItemSeparator /> :
null
}
{
customFilters
.sort((a, b) => a.label.localeCompare(b.label))
.map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
}
{

View File

@@ -28,6 +28,10 @@
text-align: center;
}
.quality {
white-space: nowrap;
}
.customFormatScore {
composes: cell from '~Components/Table/Cells/TableRowCell.css';

View File

@@ -178,7 +178,7 @@ class InteractiveSearchRow extends Component {
</TableRowCell>
<TableRowCell className={styles.quality}>
<BookQuality quality={quality} />
<BookQuality quality={quality} showRevision={true} />
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>

View File

@@ -14,9 +14,11 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
setManageDownloadClientsSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
@@ -80,6 +82,8 @@ const COLUMNS = [
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageDownloadClientsModalContent(
@@ -94,6 +98,8 @@ function ManageDownloadClientsModalContent(
isSaving,
error,
items,
sortKey,
sortDirection,
}: DownloadClientAppState = useSelector(
createClientSideCollectionSelector('settings.downloadClients')
);
@@ -114,6 +120,13 @@ function ManageDownloadClientsModalContent(
const selectedCount = selectedIds.length;
const onSortPress = useCallback(
(value: string) => {
dispatch(setManageDownloadClientsSort({ sortKey: value }));
},
[dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
@@ -219,6 +232,9 @@ function ManageDownloadClientsModalContent(
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{items.map((item) => {

View File

@@ -61,8 +61,12 @@ function DownloadClientOptions(props) {
legend={translate('FailedDownloadHandling')}
>
<Form>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('RedownloadFailed')}</FormLabel>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
@@ -72,7 +76,28 @@ function DownloadClientOptions(props) {
{...settings.autoRedownloadFailed}
/>
</FormGroup>
{
settings.autoRedownloadFailed.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailedFromInteractiveSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="autoRedownloadFailedFromInteractiveSearch"
helpText={translate('AutoRedownloadFailedFromInteractiveSearchHelpText')}
onChange={onInputChange}
{...settings.autoRedownloadFailedFromInteractiveSearch}
/>
</FormGroup> :
null
}
</Form>
<Alert kind={kinds.INFO}>
{translate('RemoveDownloadsAlert')}
</Alert>

View File

@@ -14,9 +14,11 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteIndexers,
bulkEditIndexers,
setManageIndexersSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
@@ -80,6 +82,8 @@ const COLUMNS = [
interface ManageIndexersModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
@@ -92,6 +96,8 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isSaving,
error,
items,
sortKey,
sortDirection,
}: IndexerAppState = useSelector(
createClientSideCollectionSelector('settings.indexers')
);
@@ -112,6 +118,13 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
const selectedCount = selectedIds.length;
const onSortPress = useCallback(
(value: string) => {
dispatch(setManageIndexersSort({ sortKey: value }));
},
[dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
@@ -214,6 +227,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{items.map((item) => {

View File

@@ -223,6 +223,13 @@ class UISettings extends Component {
helpTextWarning={translate('UILanguageHelpTextWarning')}
onChange={onInputChange}
{...settings.uiLanguage}
errors={
languages.some((language) => language.key === settings.uiLanguage.value) ?
settings.uiLanguage.errors :
[
...settings.uiLanguage.errors,
{ message: translate('InvalidUILanguage') }
]}
/>
</FormGroup>
</FieldSet>

View File

@@ -1,4 +1,5 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
@@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
@@ -33,6 +35,7 @@ export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestD
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
export const SET_MANAGE_DOWNLOAD_CLIENTS_SORT = 'settings/downloadClients/setManageDownloadClientsSort';
//
// Action Creators
@@ -49,6 +52,7 @@ export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT)
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
export const setManageDownloadClientsSort = createAction(SET_MANAGE_DOWNLOAD_CLIENTS_SORT);
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return {
@@ -88,7 +92,9 @@ export default {
isTesting: false,
isTestingAll: false,
items: [],
pendingChanges: {}
pendingChanges: {},
sortKey: 'name',
sortDirection: sortDirections.DESCENDING
},
//
@@ -121,7 +127,10 @@ export default {
return selectedSchema;
});
}
},
[SET_MANAGE_DOWNLOAD_CLIENTS_SORT]: createSetClientSideCollectionSortReducer(section)
}
};

View File

@@ -1,4 +1,5 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
@@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
@@ -36,6 +38,7 @@ export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers';
export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
export const SET_MANAGE_INDEXERS_SORT = 'settings/indexers/setManageIndexersSort';
//
// Action Creators
@@ -53,6 +56,7 @@ export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const setManageIndexersSort = createAction(SET_MANAGE_INDEXERS_SORT);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return {
@@ -92,7 +96,9 @@ export default {
isTesting: false,
isTestingAll: false,
items: [],
pendingChanges: {}
pendingChanges: {},
sortKey: 'name',
sortDirection: sortDirections.DESCENDING
},
//
@@ -151,7 +157,10 @@ export default {
};
return updateSectionState(state, section, newState);
}
},
[SET_MANAGE_INDEXERS_SORT]: createSetClientSideCollectionSortReducer(section)
}
};

View File

@@ -41,6 +41,14 @@ export const defaultState = {
},
columns: [
{
name: 'select',
columnLabel: 'Select',
isSortable: false,
isVisible: true,
isModifiable: false,
isHidden: true
},
{
name: 'path',
label: 'Path',

View File

@@ -158,7 +158,7 @@ export const defaultState = {
bookFileCount: function(item) {
const { statistics = {} } = item;
return statistics.bookCount || 0;
return statistics.bookFileCount || 0;
},
ratings: function(item) {

View File

@@ -84,11 +84,6 @@ export const defaultState = {
label: 'Source Title',
isVisible: false
},
{
name: 'sourceTitle',
label: 'Source Title',
isVisible: false
},
{
name: 'customFormatScore',
columnLabel: 'Custom Format Score',

View File

@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
@@ -9,8 +10,12 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTable from 'Components/Table/VirtualTable';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import { align, icons, sortDirections } from 'Helpers/Props';
import { align, icons, kinds, sortDirections } from 'Helpers/Props';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import UnmappedFilesTableHeader from './UnmappedFilesTableHeader';
import UnmappedFilesTableRow from './UnmappedFilesTableRow';
@@ -23,10 +28,43 @@ class UnmappedFilesTable extends Component {
super(props, context);
this.state = {
scroller: null
scroller: null,
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {}
};
}
componentDidMount() {
this.setSelectedState();
}
componentDidUpdate(prevProps) {
const {
items,
sortKey,
sortDirection,
isDeleting,
deleteError
} = this.props;
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items)
) {
this.setSelectedState();
}
const hasFinishedDeleting = prevProps.isDeleting &&
!isDeleting &&
!deleteError;
if (hasFinishedDeleting) {
this.onSelectAllChange({ value: false });
}
}
//
// Control
@@ -34,6 +72,68 @@ class UnmappedFilesTable extends Component {
this.setState({ scroller: ref });
};
getSelectedIds = () => {
if (this.state.allUnselected) {
return [];
}
return getSelectedIds(this.state.selectedState);
};
setSelectedState() {
const {
items
} = this.props;
const {
selectedState
} = this.state;
const newSelectedState = {};
items.forEach((file) => {
const isItemSelected = selectedState[file.id];
if (isItemSelected) {
newSelectedState[file.id] = isItemSelected;
} else {
newSelectedState[file.id] = false;
}
});
const selectedCount = getSelectedIds(newSelectedState).length;
const newStateCount = Object.keys(newSelectedState).length;
let isAllSelected = false;
let isAllUnselected = false;
if (selectedCount === 0) {
isAllUnselected = true;
} else if (selectedCount === newStateCount) {
isAllSelected = true;
}
this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected });
}
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectAllPress = () => {
this.onSelectAllChange({ value: !this.state.allSelected });
};
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
};
onDeleteUnmappedFilesPress = () => {
const selectedIds = this.getSelectedIds();
this.props.deleteUnmappedFiles(selectedIds);
};
rowRenderer = ({ key, rowIndex, style }) => {
const {
items,
@@ -41,6 +141,10 @@ class UnmappedFilesTable extends Component {
deleteUnmappedFile
} = this.props;
const {
selectedState
} = this.state;
const item = items[rowIndex];
return (
@@ -51,6 +155,8 @@ class UnmappedFilesTable extends Component {
<UnmappedFilesTableRow
key={item.id}
columns={columns}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
deleteUnmappedFile={deleteUnmappedFile}
{...item}
/>
@@ -63,6 +169,7 @@ class UnmappedFilesTable extends Component {
const {
isFetching,
isPopulated,
isDeleting,
error,
items,
columns,
@@ -72,13 +179,19 @@ class UnmappedFilesTable extends Component {
onSortPress,
isScanningFolders,
onAddMissingAuthorsPress,
deleteUnmappedFiles,
...otherProps
} = this.props;
const {
scroller
scroller,
allSelected,
allUnselected,
selectedState
} = this.state;
const selectedTrackFileIds = this.getSelectedIds();
return (
<PageContent title={translate('UnmappedFiles')}>
<PageToolbar>
@@ -90,6 +203,13 @@ class UnmappedFilesTable extends Component {
isSpinning={isScanningFolders}
onPress={onAddMissingAuthorsPress}
/>
<PageToolbarButton
label={translate('DeleteSelected')}
iconName={icons.DELETE}
isDisabled={selectedTrackFileIds.length === 0}
isSpinning={isDeleting}
onPress={this.onDeleteUnmappedFilesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
@@ -117,9 +237,9 @@ class UnmappedFilesTable extends Component {
{
isPopulated && !error && !items.length &&
<div>
<Alert kind={kinds.INFO}>
Success! My work is done, all files on disk are matched to known books.
</div>
</Alert>
}
{
@@ -138,8 +258,12 @@ class UnmappedFilesTable extends Component {
sortDirection={sortDirection}
onTableOptionChange={onTableOptionChange}
onSortPress={onSortPress}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={this.onSelectAllChange}
/>
}
selectedState={selectedState}
sortKey={sortKey}
sortDirection={sortDirection}
/>
@@ -153,6 +277,8 @@ class UnmappedFilesTable extends Component {
UnmappedFilesTable.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
@@ -161,6 +287,7 @@ UnmappedFilesTable.propTypes = {
onTableOptionChange: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
deleteUnmappedFile: PropTypes.func.isRequired,
deleteUnmappedFiles: PropTypes.func.isRequired,
isScanningFolders: PropTypes.bool.isRequired,
onAddMissingAuthorsPress: PropTypes.func.isRequired
};

View File

@@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import { deleteBookFile, fetchBookFiles, setBookFilesSort, setBookFilesTableOption } from 'Store/Actions/bookFileActions';
import { deleteBookFile, deleteBookFiles, fetchBookFiles, setBookFilesSort, setBookFilesTableOption } from 'Store/Actions/bookFileActions';
import { executeCommand } from 'Store/Actions/commandActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
@@ -28,7 +28,9 @@ function createMapStateToProps() {
items,
...otherProps
} = bookFiles;
const unmappedFiles = _.filter(items, { bookId: 0 });
return {
items: unmappedFiles,
...otherProps,
@@ -57,6 +59,10 @@ function createMapDispatchToProps(dispatch, props) {
dispatch(deleteBookFile({ id }));
},
deleteUnmappedFiles(bookFileIds) {
dispatch(deleteBookFiles({ bookFileIds }));
},
onAddMissingAuthorsPress() {
dispatch(executeCommand({
name: commandNames.RESCAN_FOLDERS,
@@ -106,7 +112,8 @@ UnmappedFilesTableConnector.propTypes = {
onSortPress: PropTypes.func.isRequired,
onTableOptionChange: PropTypes.func.isRequired,
fetchUnmappedFiles: PropTypes.func.isRequired,
deleteUnmappedFile: PropTypes.func.isRequired
deleteUnmappedFile: PropTypes.func.isRequired,
deleteUnmappedFiles: PropTypes.func.isRequired
};
export default withCurrentPage(

View File

@@ -4,6 +4,7 @@ import IconButton from 'Components/Link/IconButton';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import { icons } from 'Helpers/Props';
// import hasGrowableColumns from './hasGrowableColumns';
import styles from './UnmappedFilesTableHeader.css';
@@ -12,6 +13,9 @@ function UnmappedFilesTableHeader(props) {
const {
columns,
onTableOptionChange,
allSelected,
allUnselected,
onSelectAllChange,
...otherProps
} = props;
@@ -30,6 +34,17 @@ function UnmappedFilesTableHeader(props) {
return null;
}
if (name === 'select') {
return (
<VirtualTableSelectAllHeaderCell
key={name}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
);
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
@@ -71,6 +86,9 @@ function UnmappedFilesTableHeader(props) {
UnmappedFilesTableHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};

View File

@@ -20,3 +20,9 @@
flex: 0 0 100px;
}
.checkInput {
composes: input from '~Components/Form/CheckInput.css';
margin-top: 0;
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'checkInput': string;
'dateAdded': string;
'path': string;
'quality': string;

View File

@@ -6,6 +6,7 @@ import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import { icons, kinds } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import formatBytes from 'Utilities/Number/formatBytes';
@@ -69,7 +70,9 @@ class UnmappedFilesTableRow extends Component {
size,
dateAdded,
quality,
columns
columns,
isSelected,
onSelectedChange
} = this.props;
const folder = path.substring(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')));
@@ -93,6 +96,19 @@ class UnmappedFilesTableRow extends Component {
return null;
}
if (name === 'select') {
return (
<VirtualTableSelectCell
inputClassName={styles.checkInput}
id={id}
key={name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell
@@ -208,6 +224,8 @@ UnmappedFilesTableRow.propTypes = {
quality: PropTypes.object.isRequired,
dateAdded: PropTypes.string.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
deleteUnmappedFile: PropTypes.func.isRequired
};

View File

@@ -25,20 +25,18 @@ export async function fetchTranslations(): Promise<boolean> {
export default function translate(
key: string,
tokens: Record<string, string | number | boolean> = { appName: 'Readarr' }
tokens: Record<string, string | number | boolean> = {}
) {
const translation = translations[key] || key;
if (tokens) {
// Fallback to the old behaviour for translations not yet updated to use named tokens
Object.values(tokens).forEach((value, index) => {
tokens[index] = value;
});
tokens.appName = 'Readarr';
return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) =>
String(tokens[tokenMatch] ?? match)
);
}
// Fallback to the old behaviour for translations not yet updated to use named tokens
Object.values(tokens).forEach((value, index) => {
tokens[index] = value;
});
return translation;
return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) =>
String(tokens[tokenMatch] ?? match)
);
}

View File

@@ -8,6 +8,7 @@
<PackageVersion Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageVersion Include="Equ" Version="2.3.0" />
<PackageVersion Include="FluentAssertions" Version="5.10.3" />
<PackageVersion Include="Polly" Version="8.2.0" />
<PackageVersion Include="Servarr.FluentMigrator.Runner" Version="3.3.2.9" />
<PackageVersion Include="Servarr.FluentMigrator.Runner.SQLite" Version="3.3.2.9" />
<PackageVersion Include="Servarr.FluentMigrator.Runner.Postgres" Version="3.3.2.9" />

View File

@@ -1,6 +1,9 @@
using System.Collections.Generic;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Localization;
using NzbDrone.Test.Common;
using Readarr.Http.ClientSchema;
@@ -9,6 +12,16 @@ namespace NzbDrone.Api.Test.ClientSchemaTests
[TestFixture]
public class SchemaBuilderFixture : TestBase
{
[SetUp]
public void Setup()
{
Mocker.GetMock<ILocalizationService>()
.Setup(s => s.GetLocalizedString(It.IsAny<string>(), It.IsAny<Dictionary<string, object>>()))
.Returns<string, Dictionary<string, object>>((s, d) => s);
SchemaBuilder.Initialize(Mocker.Container);
}
[Test]
public void should_return_field_for_every_property()
{

View File

@@ -124,6 +124,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 async Task should_execute_https_get()
{

View File

@@ -103,31 +103,38 @@ namespace NzbDrone.Common.Http.Dispatchers
var httpClient = GetClient(request.Url);
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);
if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK)
{
await responseMessage.Content.CopyToAsync(request.ResponseStream, null, cts.Token);
}
else
{
data = await responseMessage.Content.ReadAsByteArrayAsync(cts.Token);
}
}
else
catch (Exception ex)
{
data = await responseMessage.Content.ReadAsByteArrayAsync(cts.Token);
throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null);
}
var headers = responseMessage.Headers.ToNameValueCollection();
headers.Add(responseMessage.Content.Headers.ToNameValueCollection());
return new HttpResponse(request, new HttpHeader(headers), data, responseMessage.StatusCode, responseMessage.Version);
}
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());
return new HttpResponse(request, new HttpHeader(headers), data, responseMessage.StatusCode, responseMessage.Version);
}
catch (OperationCanceledException ex) when (cts.IsCancellationRequested)
{
throw new WebException("Http request timed out", ex.InnerException, WebExceptionStatus.Timeout, null);
}
}

View File

@@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteBook));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>(), null), Times.Once());
}
[Test]
@@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteBook));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>(), null), Times.Once());
}
[Test]
@@ -101,7 +101,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteBook2));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>(), null), Times.Once());
}
[Test]
@@ -172,7 +172,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteBook));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteBook>())).Throws(new Exception());
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteBook>(), null)).Throws(new Exception());
var result = await Subject.ProcessDecisions(decisions);
@@ -201,7 +201,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteBook, new Rejection("Failure!", RejectionType.Temporary)));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>()), Times.Never());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>(), null), Times.Never());
}
[Test]
@@ -242,11 +242,11 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteBook));
decisions.Add(new DownloadDecision(remoteBook));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteBook>()))
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.IsAny<RemoteBook>(), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>()), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.IsAny<RemoteBook>(), null), Times.Once());
}
[Test]
@@ -260,12 +260,12 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteBook));
decisions.Add(new DownloadDecision(remoteBook2));
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteBook>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)))
Mocker.GetMock<IDownloadService>().Setup(s => s.DownloadReport(It.Is<RemoteBook>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null))
.Throws(new DownloadClientUnavailableException("Download client failed"));
await Subject.ProcessDecisions(decisions);
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteBook>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet)), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteBook>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent)), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteBook>(r => r.Release.DownloadProtocol == DownloadProtocol.Usenet), null), Times.Once());
Mocker.GetMock<IDownloadService>().Verify(v => v.DownloadReport(It.Is<RemoteBook>(r => r.Release.DownloadProtocol == DownloadProtocol.Torrent), null), Times.Once());
}
[Test]
@@ -278,7 +278,7 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests
decisions.Add(new DownloadDecision(remoteBook));
Mocker.GetMock<IDownloadService>()
.Setup(s => s.DownloadReport(It.IsAny<RemoteBook>()))
.Setup(s => s.DownloadReport(It.IsAny<RemoteBook>(), null))
.Throws(new ReleaseUnavailableException(remoteBook.Release, "That 404 Error is not just a Quirk"));
var result = await Subject.ProcessDecisions(decisions);

View File

@@ -83,7 +83,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()));
await Subject.DownloadReport(_parseResult);
await Subject.DownloadReport(_parseResult, null);
VerifyEventPublished<BookGrabbedEvent>();
}
@@ -94,7 +94,7 @@ namespace NzbDrone.Core.Test.Download
var mock = WithUsenetClient();
mock.Setup(s => s.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()));
await Subject.DownloadReport(_parseResult);
await Subject.DownloadReport(_parseResult, null);
mock.Verify(s => s.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()), Times.Once());
}
@@ -106,7 +106,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()))
.Throws(new WebException());
Assert.ThrowsAsync<WebException>(async () => await Subject.DownloadReport(_parseResult));
Assert.ThrowsAsync<WebException>(async () => await Subject.DownloadReport(_parseResult, null));
VerifyEventNotPublished<BookGrabbedEvent>();
}
@@ -121,7 +121,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new WebException());
});
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult));
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Once());
@@ -141,7 +141,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
});
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult));
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), TimeSpan.FromMinutes(5.0)), Times.Once());
@@ -161,7 +161,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response));
});
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult));
Assert.ThrowsAsync<ReleaseDownloadException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(),
@@ -175,7 +175,7 @@ namespace NzbDrone.Core.Test.Download
mock.Setup(s => s.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()))
.Throws(new DownloadClientException("Some Error"));
Assert.ThrowsAsync<DownloadClientException>(async () => await Subject.DownloadReport(_parseResult));
Assert.ThrowsAsync<DownloadClientException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@@ -191,7 +191,7 @@ namespace NzbDrone.Core.Test.Download
throw new ReleaseUnavailableException(v.Release, "Error", new WebException());
});
Assert.ThrowsAsync<ReleaseUnavailableException>(async () => await Subject.DownloadReport(_parseResult));
Assert.ThrowsAsync<ReleaseUnavailableException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IIndexerStatusService>()
.Verify(v => v.RecordFailure(It.IsAny<int>(), It.IsAny<TimeSpan>()), Times.Never());
@@ -200,7 +200,7 @@ namespace NzbDrone.Core.Test.Download
[Test]
public void should_not_attempt_download_if_client_isnt_configured()
{
Assert.ThrowsAsync<DownloadClientUnavailableException>(async () => await Subject.DownloadReport(_parseResult));
Assert.ThrowsAsync<DownloadClientUnavailableException>(async () => await Subject.DownloadReport(_parseResult, null));
Mocker.GetMock<IDownloadClient>().Verify(c => c.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()), Times.Never());
VerifyEventNotPublished<BookGrabbedEvent>();
@@ -222,7 +222,7 @@ namespace NzbDrone.Core.Test.Download
}
});
await Subject.DownloadReport(_parseResult);
await Subject.DownloadReport(_parseResult, null);
Mocker.GetMock<IDownloadClientStatusService>().Verify(c => c.GetBlockedProviders(), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()), Times.Once());
@@ -235,7 +235,7 @@ namespace NzbDrone.Core.Test.Download
var mockTorrent = WithTorrentClient();
var mockUsenet = WithUsenetClient();
await Subject.DownloadReport(_parseResult);
await Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()), Times.Never());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()), Times.Once());
@@ -249,7 +249,7 @@ namespace NzbDrone.Core.Test.Download
_parseResult.Release.DownloadProtocol = DownloadProtocol.Torrent;
await Subject.DownloadReport(_parseResult);
await Subject.DownloadReport(_parseResult, null);
mockTorrent.Verify(c => c.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()), Times.Once());
mockUsenet.Verify(c => c.Download(It.IsAny<RemoteBook>(), It.IsAny<IIndexer>()), Times.Never());

View File

@@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
results.Should().NotBeEmpty();
Mocker.GetMock<IMakeDownloadDecision>()
.Verify(v => v.GetRssDecision(It.Is<List<ReleaseInfo>>(d => d.Count == 0)), Times.Never());
.Verify(v => v.GetRssDecision(It.Is<List<ReleaseInfo>>(d => d.Count == 0), It.IsAny<bool>()), Times.Never());
}
[Test]

View File

@@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.IndexerTests.FileListTests
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.Get)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), recentFeed)));
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader { { "Content-Type", "application/json" } }, recentFeed)));
var releases = await Subject.FetchRecent();

View File

@@ -0,0 +1,81 @@
using System;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Localization;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.Localization
{
[TestFixture]
public class LocalizationServiceFixture : CoreTest<LocalizationService>
{
[SetUp]
public void Setup()
{
Mocker.GetMock<IConfigService>().Setup(m => m.UILanguage).Returns((int)Language.English);
Mocker.GetMock<IAppFolderInfo>().Setup(m => m.StartUpFolder).Returns(TestContext.CurrentContext.TestDirectory);
}
[Test]
public void should_get_string_in_dictionary_if_lang_exists_and_string_exists()
{
var localizedString = Subject.GetLocalizedString("UiLanguage");
localizedString.Should().Be("UI Language");
}
[Test]
public void should_get_string_in_french()
{
Mocker.GetMock<IConfigService>().Setup(m => m.UILanguage).Returns((int)Language.French);
var localizedString = Subject.GetLocalizedString("UiLanguage");
localizedString.Should().Be("Langue de l'IU");
ExceptionVerification.ExpectedErrors(1);
}
[Test]
public void should_get_string_in_default_dictionary_if_unknown_language_and_string_exists()
{
Mocker.GetMock<IConfigService>().Setup(m => m.UILanguage).Returns(0);
var localizedString = Subject.GetLocalizedString("UiLanguage");
localizedString.Should().Be("UI Language");
}
[Test]
public void should_return_argument_if_string_doesnt_exists()
{
var localizedString = Subject.GetLocalizedString("badString");
localizedString.Should().Be("badString");
}
[Test]
public void should_return_argument_if_string_doesnt_exists_default_lang()
{
var localizedString = Subject.GetLocalizedString("badString");
localizedString.Should().Be("badString");
}
[Test]
public void should_throw_if_empty_string_passed()
{
Assert.Throws<ArgumentNullException>(() => Subject.GetLocalizedString(""));
}
[Test]
public void should_throw_if_null_string_passed()
{
Assert.Throws<ArgumentNullException>(() => Subject.GetLocalizedString(null));
}
}
}

View File

@@ -13,7 +13,7 @@ using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{
[TestFixture]
[Ignore("Waiting for metadata to be back again", Until = "2023-12-31 00:00:00Z")]
[Ignore("Waiting for metadata to be back again", Until = "2024-01-31 00:00:00Z")]
public class BookInfoProxyFixture : CoreTest<BookInfoProxy>
{
private MetadataProfile _metadataProfile;

View File

@@ -15,7 +15,7 @@ using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{
[TestFixture]
[Ignore("Waiting for metadata to be back again", Until = "2023-12-31 00:00:00Z")]
[Ignore("Waiting for metadata to be back again", Until = "2024-01-31 00:00:00Z")]
public class BookInfoProxySearchFixture : CoreTest<BookInfoProxy>
{
[SetUp]

View File

@@ -166,6 +166,7 @@ namespace NzbDrone.Core.Test.MusicTests.BookRepositoryTests
private EquivalencyAssertionOptions<Book> BookComparerOptions(EquivalencyAssertionOptions<Book> opts) => opts.ComparingByMembers<Book>()
.Excluding(ctx => ctx.SelectedMemberInfo.MemberType.IsGenericType && ctx.SelectedMemberInfo.MemberType.GetGenericTypeDefinition() == typeof(LazyLoaded<>))
.Excluding(x => x.AuthorId);
.Excluding(x => x.AuthorId)
.Excluding(x => x.ForeignEditionId);
}
}

View File

@@ -41,6 +41,23 @@ namespace NzbDrone.Core.Annotations
public string Hint { get; set; }
}
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class FieldTokenAttribute : Attribute
{
public FieldTokenAttribute(TokenField field, string label = "", string token = "", object value = null)
{
Label = label;
Field = field;
Token = token;
Value = value?.ToString();
}
public string Label { get; set; }
public TokenField Field { get; set; }
public string Token { get; set; }
public string Value { get; set; }
}
public class FieldSelectOption
{
public int Value { get; set; }
@@ -83,4 +100,11 @@ namespace NzbDrone.Core.Annotations
ApiKey,
UserName
}
public enum TokenField
{
Label,
HelpText,
HelpTextWarning
}
}

View File

@@ -15,6 +15,7 @@ namespace NzbDrone.Core.Blocklisting
public interface IBlocklistService
{
bool Blocklisted(int authorId, ReleaseInfo release);
bool BlocklistedTorrentHash(int authorId, string hash);
PagingSpec<Blocklist> Paged(PagingSpec<Blocklist> pagingSpec);
void Block(RemoteBook remoteEpisode, string message);
void Delete(int id);
@@ -36,30 +37,34 @@ namespace NzbDrone.Core.Blocklisting
public bool Blocklisted(int authorId, ReleaseInfo release)
{
var blocklistedByTitle = _blocklistRepository.BlocklistedByTitle(authorId, release.Title);
if (release.DownloadProtocol == DownloadProtocol.Torrent)
{
var torrentInfo = release as TorrentInfo;
if (torrentInfo == null)
if (release is not TorrentInfo torrentInfo)
{
return false;
}
if (torrentInfo.InfoHash.IsNullOrWhiteSpace())
if (torrentInfo.InfoHash.IsNotNullOrWhiteSpace())
{
return blocklistedByTitle.Where(b => b.Protocol == DownloadProtocol.Torrent)
.Any(b => SameTorrent(b, torrentInfo));
var blocklistedByTorrentInfohash = _blocklistRepository.BlocklistedByTorrentInfoHash(authorId, torrentInfo.InfoHash);
return blocklistedByTorrentInfohash.Any(b => SameTorrent(b, torrentInfo));
}
var blocklistedByTorrentInfohash = _blocklistRepository.BlocklistedByTorrentInfoHash(authorId, torrentInfo.InfoHash);
return blocklistedByTorrentInfohash.Any(b => SameTorrent(b, torrentInfo));
return _blocklistRepository.BlocklistedByTitle(authorId, release.Title)
.Where(b => b.Protocol == DownloadProtocol.Torrent)
.Any(b => SameTorrent(b, torrentInfo));
}
return blocklistedByTitle.Where(b => b.Protocol == DownloadProtocol.Usenet)
.Any(b => SameNzb(b, release));
return _blocklistRepository.BlocklistedByTitle(authorId, release.Title)
.Where(b => b.Protocol == DownloadProtocol.Usenet)
.Any(b => SameNzb(b, release));
}
public bool BlocklistedTorrentHash(int authorId, string hash)
{
return _blocklistRepository.BlocklistedByTorrentInfoHash(authorId, hash).Any(b =>
b.TorrentInfoHash.Equals(hash, StringComparison.InvariantCultureIgnoreCase));
}
public PagingSpec<Blocklist> Paged(PagingSpec<Blocklist> pagingSpec)

View File

@@ -219,11 +219,11 @@ namespace NzbDrone.Core.Books.Calibre
double? seriesIndex = null;
if (double.TryParse(serieslink?.Position, out var index))
{
_logger.Trace($"Parsed {serieslink?.Position} as {index}");
_logger.Trace("Parsed '{0}' as '{1}'", serieslink.Position, index);
seriesIndex = index;
}
_logger.Trace($"Book: {book} Series: {series?.Title}, Position: {seriesIndex}");
_logger.Trace("Book: {0} Series: {1}, Position: {2}", book, series?.Title, seriesIndex);
var cover = edition.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
string image = null;
@@ -276,7 +276,9 @@ namespace NzbDrone.Core.Books.Calibre
var updatedPath = GetOriginalFormat(updated.Formats);
if (updatedPath != null && updatedPath != file.Path)
_logger.Trace("File path from Calibre: '{0}'", updatedPath);
if (updatedPath.IsNotNullOrWhiteSpace() && updatedPath != file.Path)
{
_rootFolderWatchingService.ReportFileSystemChangeBeginning(updatedPath);
file.Path = updatedPath;

View File

@@ -26,6 +26,7 @@ namespace NzbDrone.Core.Books
// These are metadata entries
public int AuthorMetadataId { get; set; }
public string ForeignBookId { get; set; }
public string ForeignEditionId { get; set; }
public string TitleSlug { get; set; }
public string Title { get; set; }
public DateTime? ReleaseDate { get; set; }
@@ -71,6 +72,7 @@ namespace NzbDrone.Core.Books
public override void UseMetadataFrom(Book other)
{
ForeignBookId = other.ForeignBookId;
ForeignEditionId = other.ForeignEditionId;
TitleSlug = other.TitleSlug;
Title = other.Title;
ReleaseDate = other.ReleaseDate;
@@ -95,6 +97,7 @@ namespace NzbDrone.Core.Books
public override void ApplyChanges(Book other)
{
ForeignBookId = other.ForeignBookId;
ForeignEditionId = other.ForeignEditionId;
AddOptions = other.AddOptions;
Monitored = other.Monitored;
AnyEditionOk = other.AnyEditionOk;

View File

@@ -329,8 +329,8 @@ namespace NzbDrone.Core.Configuration
return;
}
// If SSL is enabled and a cert hash is still in the config file disable SSL
if (EnableSsl && GetValue("SslCertHash", null).IsNotNullOrWhiteSpace())
// If SSL is enabled and a cert hash is still in the config file or cert path is empty disable SSL
if (EnableSsl && (GetValue("SslCertHash", null).IsNotNullOrWhiteSpace() || SslCertPath.IsNullOrWhiteSpace()))
{
SetValue("EnableSsl", false);
}

View File

@@ -144,6 +144,13 @@ namespace NzbDrone.Core.Configuration
set { SetValue("AutoRedownloadFailed", value); }
}
public bool AutoRedownloadFailedFromInteractiveSearch
{
get { return GetValueBoolean("AutoRedownloadFailedFromInteractiveSearch", true); }
set { SetValue("AutoRedownloadFailedFromInteractiveSearch", value); }
}
public bool CreateEmptyAuthorFolders
{
get { return GetValueBoolean("CreateEmptyAuthorFolders", false); }

View File

@@ -19,6 +19,7 @@ namespace NzbDrone.Core.Configuration
//Completed/Failed Download Handling (Download client)
bool EnableCompletedDownloadHandling { get; set; }
bool AutoRedownloadFailed { get; set; }
bool AutoRedownloadFailedFromInteractiveSearch { get; set; }
//Media Management
bool AutoUnmonitorPreviouslyDownloadedBooks { get; set; }

View File

@@ -1,5 +1,6 @@
using System;
using System;
using System.Data;
using System.Data.SQLite;
using System.Text.RegularExpressions;
using Dapper;
using NLog;
@@ -40,14 +41,7 @@ namespace NzbDrone.Core.Datastore
{
using (var db = _datamapperFactory())
{
if (db.ConnectionString.Contains(".db"))
{
return DatabaseType.SQLite;
}
else
{
return DatabaseType.PostgreSQL;
}
return db is SQLiteConnection ? DatabaseType.SQLite : DatabaseType.PostgreSQL;
}
}
}

View File

@@ -135,6 +135,7 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<Book>("Books").RegisterModel()
.Ignore(x => x.AuthorId)
.Ignore(x => x.ForeignEditionId)
.HasOne(r => r.AuthorMetadata, r => r.AuthorMetadataId)
.LazyLoad(x => x.BookFiles,
(db, book) => db.Query<BookFile>(new SqlBuilder(db.DatabaseType)

View File

@@ -17,7 +17,7 @@ namespace NzbDrone.Core.DecisionEngine
{
public interface IMakeDownloadDecision
{
List<DownloadDecision> GetRssDecision(List<ReleaseInfo> reports);
List<DownloadDecision> GetRssDecision(List<ReleaseInfo> reports, bool pushedRelease = false);
List<DownloadDecision> GetSearchDecision(List<ReleaseInfo> reports, SearchCriteriaBase searchCriteriaBase);
}
@@ -42,17 +42,17 @@ namespace NzbDrone.Core.DecisionEngine
_logger = logger;
}
public List<DownloadDecision> GetRssDecision(List<ReleaseInfo> reports)
public List<DownloadDecision> GetRssDecision(List<ReleaseInfo> reports, bool pushedRelease = false)
{
return GetBookDecisions(reports).ToList();
}
public List<DownloadDecision> GetSearchDecision(List<ReleaseInfo> reports, SearchCriteriaBase searchCriteriaBase)
{
return GetBookDecisions(reports, searchCriteriaBase).ToList();
return GetBookDecisions(reports, false, searchCriteriaBase).ToList();
}
private IEnumerable<DownloadDecision> GetBookDecisions(List<ReleaseInfo> reports, SearchCriteriaBase searchCriteria = null)
private IEnumerable<DownloadDecision> GetBookDecisions(List<ReleaseInfo> reports, bool pushedRelease = false, SearchCriteriaBase searchCriteria = null)
{
if (reports.Any())
{
@@ -206,6 +206,26 @@ namespace NzbDrone.Core.DecisionEngine
if (decision != null)
{
var source = pushedRelease ? ReleaseSourceType.ReleasePush : ReleaseSourceType.Rss;
if (searchCriteria != null)
{
if (searchCriteria.InteractiveSearch)
{
source = ReleaseSourceType.InteractiveSearch;
}
else if (searchCriteria.UserInvokedSearch)
{
source = ReleaseSourceType.UserInvokedSearch;
}
else
{
source = ReleaseSourceType.Search;
}
}
decision.RemoteBook.ReleaseSource = source;
if (decision.Rejections.Any())
{
_logger.Debug("Release rejected for the following reasons: {0}", string.Join(", ", decision.Rejections));

View File

@@ -7,6 +7,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Parser.Model;
@@ -27,8 +28,9 @@ namespace NzbDrone.Core.Download.Clients.Aria2
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxy = proxy;
}

View File

@@ -7,6 +7,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Organizer;
@@ -29,8 +30,9 @@ namespace NzbDrone.Core.Download.Clients.Blackhole
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_scanWatchFolder = scanWatchFolder;

View File

@@ -7,6 +7,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Parser.Model;
@@ -25,8 +26,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxy = proxy;
}

View File

@@ -8,6 +8,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Download.Clients.DownloadStation.Proxies;
using NzbDrone.Core.MediaFiles.TorrentInfo;
@@ -36,8 +37,9 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_dsInfoProxy = dsInfoProxy;
_dsTaskProxySelector = dsTaskProxySelector;

View File

@@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Download.Clients.Flood.Models;
using NzbDrone.Core.MediaFiles.TorrentInfo;
@@ -27,8 +28,9 @@ namespace NzbDrone.Core.Download.Clients.Flood
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxy = proxy;
_downloadSeedConfigProvider = downloadSeedConfigProvider;
@@ -70,7 +72,7 @@ namespace NzbDrone.Core.Download.Clients.Flood
}
}
return result;
return result.Where(t => t.IsNotNullOrWhiteSpace());
}
public override string Name => "Flood";

View File

@@ -5,6 +5,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Download.Clients.Hadouken.Models;
using NzbDrone.Core.MediaFiles.TorrentInfo;
@@ -24,8 +25,9 @@ namespace NzbDrone.Core.Download.Clients.Hadouken
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxy = proxy;
}

View File

@@ -8,6 +8,7 @@ using NzbDrone.Common.Cache;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Parser.Model;
@@ -34,8 +35,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
ICacheManager cacheManager,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxySelector = proxySelector;

View File

@@ -0,0 +1,9 @@
namespace NzbDrone.Core.Download.Clients.QBittorrent
{
public enum QBittorrentContentLayout
{
Default = 0,
Original = 1,
Subfolder = 2
}
}

View File

@@ -265,6 +265,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
{
request.AddFormParameter("firstLastPiecePrio", true);
}
if ((QBittorrentContentLayout)settings.ContentLayout == QBittorrentContentLayout.Original)
{
request.AddFormParameter("contentLayout", "Original");
}
else if ((QBittorrentContentLayout)settings.ContentLayout == QBittorrentContentLayout.Subfolder)
{
request.AddFormParameter("contentLayout", "Subfolder");
}
}
public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings)

View File

@@ -69,6 +69,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
[FieldDefinition(12, Label = "First and Last First", Type = FieldType.Checkbox, HelpText = "Download first and last pieces first (qBittorrent 4.1.0+)")]
public bool FirstAndLast { get; set; }
[FieldDefinition(13, Label = "DownloadClientQbittorrentSettingsContentLayout", Type = FieldType.Select, SelectOptions = typeof(QBittorrentContentLayout), HelpText = "DownloadClientQbittorrentSettingsContentLayoutHelpText")]
public int ContentLayout { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));

View File

@@ -1,9 +1,10 @@
using System;
using System;
using System.Text.RegularExpressions;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.RemotePathMappings;
@@ -18,8 +19,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(proxy, torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(proxy, torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
}

View File

@@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Parser.Model;
@@ -24,8 +25,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxy = proxy;
}

View File

@@ -2,6 +2,7 @@ using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Download.Clients.Transmission;
using NzbDrone.Core.MediaFiles.TorrentInfo;
@@ -19,8 +20,9 @@ namespace NzbDrone.Core.Download.Clients.Vuze
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(proxy, torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(proxy, torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
}

View File

@@ -8,6 +8,7 @@ using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Download.Clients.rTorrent;
using NzbDrone.Core.Exceptions;
@@ -34,8 +35,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent
IRemotePathMappingService remotePathMappingService,
IDownloadSeedConfigProvider downloadSeedConfigProvider,
IRTorrentDirectoryValidator rTorrentDirectoryValidator,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxy = proxy;
_rTorrentDirectoryValidator = rTorrentDirectoryValidator;

View File

@@ -8,6 +8,7 @@ using NzbDrone.Common.Cache;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Parser.Model;
@@ -28,8 +29,9 @@ namespace NzbDrone.Core.Download.Clients.UTorrent
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, blocklistService, logger)
{
_proxy = proxy;

View File

@@ -1,15 +1,19 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation;
using Polly;
using Polly.Retry;
namespace NzbDrone.Core.Download
{
@@ -21,6 +25,37 @@ namespace NzbDrone.Core.Download
protected readonly IRemotePathMappingService _remotePathMappingService;
protected readonly Logger _logger;
protected ResiliencePipeline<HttpResponse> RetryStrategy => new ResiliencePipelineBuilder<HttpResponse>()
.AddRetry(new RetryStrategyOptions<HttpResponse>
{
ShouldHandle = static args => args.Outcome switch
{
{ Result.HasHttpServerError: true } => PredicateResult.True(),
{ Result.StatusCode: HttpStatusCode.RequestTimeout } => PredicateResult.True(),
_ => PredicateResult.False()
},
Delay = TimeSpan.FromSeconds(3),
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
OnRetry = args =>
{
var exception = args.Outcome.Exception;
if (exception is not null)
{
_logger.Info(exception, "Request for {0} failed with exception '{1}'. Retrying in {2}s.", Definition.Name, exception.Message, args.RetryDelay.TotalSeconds);
}
else
{
_logger.Info("Request for {0} failed with status {1}. Retrying in {2}s.", Definition.Name, args.Outcome.Result?.StatusCode, args.RetryDelay.TotalSeconds);
}
return default;
}
})
.Build();
public abstract string Name { get; }
public Type ConfigContract => typeof(TSettings);
@@ -54,10 +89,7 @@ namespace NzbDrone.Core.Download
return GetType().Name;
}
public abstract DownloadProtocol Protocol
{
get;
}
public abstract DownloadProtocol Protocol { get; }
public abstract Task<string> Download(RemoteBook remoteBook, IIndexer indexer);
public abstract IEnumerable<DownloadClientItem> GetItems();

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using NzbDrone.Common.Messaging;
using NzbDrone.Core.Download.TrackedDownloads;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Core.Download
@@ -22,5 +23,6 @@ namespace NzbDrone.Core.Download
public Dictionary<string, string> Data { get; set; }
public TrackedDownload TrackedDownload { get; set; }
public bool SkipRedownload { get; set; }
public ReleaseSourceType ReleaseSource { get; set; }
}
}

View File

@@ -17,7 +17,7 @@ namespace NzbDrone.Core.Download
{
public interface IDownloadService
{
Task DownloadReport(RemoteBook remoteBook);
Task DownloadReport(RemoteBook remoteBook, int? downloadClientId);
}
public class DownloadService : IDownloadService
@@ -50,13 +50,15 @@ namespace NzbDrone.Core.Download
_logger = logger;
}
public async Task DownloadReport(RemoteBook remoteBook)
public async Task DownloadReport(RemoteBook remoteBook, int? downloadClientId)
{
var filterBlockedClients = remoteBook.Release.PendingReleaseReason == PendingReleaseReason.DownloadClientUnavailable;
var tags = remoteBook.Author?.Tags;
var downloadClient = _downloadClientProvider.GetDownloadClient(remoteBook.Release.DownloadProtocol, remoteBook.Release.IndexerId, filterBlockedClients, tags);
var downloadClient = downloadClientId.HasValue
? _downloadClientProvider.Get(downloadClientId.Value)
: _downloadClientProvider.GetDownloadClient(remoteBook.Release.DownloadProtocol, remoteBook.Release.IndexerId, filterBlockedClients, tags);
await DownloadReport(remoteBook, downloadClient);
}
@@ -102,6 +104,11 @@ namespace NzbDrone.Core.Download
_logger.Trace("Release {0} no longer available on indexer.", remoteBook);
throw;
}
catch (ReleaseBlockedException)
{
_logger.Trace("Release {0} previously added to blocklist, not sending to download client again.", remoteBook);
throw;
}
catch (DownloadClientRejectedReleaseException)
{
_logger.Trace("Release {0} rejected by download client, possible duplicate.", remoteBook);
@@ -126,7 +133,7 @@ namespace NzbDrone.Core.Download
bookGrabbedEvent.DownloadClientId = downloadClient.Definition.Id;
bookGrabbedEvent.DownloadClientName = downloadClient.Definition.Name;
if (!string.IsNullOrWhiteSpace(downloadClientId))
if (downloadClientId.IsNotNullOrWhiteSpace())
{
bookGrabbedEvent.DownloadId = downloadClientId;
}

View File

@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Download.TrackedDownloads;
using NzbDrone.Core.History;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.Download
{
@@ -116,7 +118,8 @@ namespace NzbDrone.Core.Download
private void PublishDownloadFailedEvent(List<EntityHistory> historyItems, string message, TrackedDownload trackedDownload = null, bool skipRedownload = false)
{
var historyItem = historyItems.First();
var historyItem = historyItems.Last();
Enum.TryParse(historyItem.Data.GetValueOrDefault(EntityHistory.RELEASE_SOURCE, ReleaseSourceType.Unknown.ToString()), out ReleaseSourceType releaseSource);
var downloadFailedEvent = new DownloadFailedEvent
{
@@ -129,7 +132,8 @@ namespace NzbDrone.Core.Download
Message = message,
Data = historyItem.Data,
TrackedDownload = trackedDownload,
SkipRedownload = skipRedownload
SkipRedownload = skipRedownload,
ReleaseSource = releaseSource
};
_eventAggregator.PublishEvent(downloadFailedEvent);

View File

@@ -12,8 +12,14 @@ namespace NzbDrone.Core.Download.Pending
public ParsedBookInfo ParsedBookInfo { get; set; }
public ReleaseInfo Release { get; set; }
public PendingReleaseReason Reason { get; set; }
public PendingReleaseAdditionalInfo AdditionalInfo { get; set; }
//Not persisted
public RemoteBook RemoteBook { get; set; }
}
public class PendingReleaseAdditionalInfo
{
public ReleaseSourceType ReleaseSource { get; set; }
}
}

View File

@@ -320,6 +320,7 @@ namespace NzbDrone.Core.Download.Pending
{
Author = author,
Books = books,
ReleaseSource = release.AdditionalInfo?.ReleaseSource ?? ReleaseSourceType.Unknown,
ParsedBookInfo = release.ParsedBookInfo,
Release = release.Release
};
@@ -342,7 +343,11 @@ namespace NzbDrone.Core.Download.Pending
Release = decision.RemoteBook.Release,
Title = decision.RemoteBook.Release.Title,
Added = DateTime.UtcNow,
Reason = reason
Reason = reason,
AdditionalInfo = new PendingReleaseAdditionalInfo
{
ReleaseSource = decision.RemoteBook.ReleaseSource
}
});
_eventAggregator.PublishEvent(new PendingReleasesUpdatedEvent());

View File

@@ -14,6 +14,7 @@ namespace NzbDrone.Core.Download
public interface IProcessDownloadDecisions
{
Task<ProcessedDecisions> ProcessDecisions(List<DownloadDecision> decisions);
Task<ProcessedDecisionResult> ProcessDecision(DownloadDecision decision, int? downloadClientId);
}
public class ProcessDownloadDecisions : IProcessDownloadDecisions
@@ -40,8 +41,6 @@ namespace NzbDrone.Core.Download
var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(qualifiedReports);
var grabbed = new List<DownloadDecision>();
var pending = new List<DownloadDecision>();
//var failed = new List<DownloadDecision>();
var rejected = decisions.Where(d => d.Rejected).ToList();
var pendingAddQueue = new List<Tuple<DownloadDecision, PendingReleaseReason>>();
@@ -51,7 +50,6 @@ namespace NzbDrone.Core.Download
foreach (var report in prioritizedDecisions)
{
var remoteBook = report.RemoteBook;
var downloadProtocol = report.RemoteBook.Release.DownloadProtocol;
//Skip if already grabbed
@@ -73,37 +71,48 @@ namespace NzbDrone.Core.Download
continue;
}
try
{
_logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteBook.Release.Indexer, remoteBook.Release.IndexerPriority);
await _downloadService.DownloadReport(remoteBook);
grabbed.Add(report);
}
catch (ReleaseUnavailableException)
{
_logger.Warn("Failed to download release from indexer, no longer available. " + remoteBook);
rejected.Add(report);
}
catch (Exception ex)
{
if (ex is DownloadClientUnavailableException || ex is DownloadClientAuthenticationException)
{
_logger.Debug(ex, "Failed to send release to download client, storing until later. " + remoteBook);
PreparePending(pendingAddQueue, grabbed, pending, report, PendingReleaseReason.DownloadClientUnavailable);
var result = await ProcessDecisionInternal(report);
if (downloadProtocol == DownloadProtocol.Usenet)
switch (result)
{
case ProcessedDecisionResult.Grabbed:
{
usenetFailed = true;
grabbed.Add(report);
break;
}
else if (downloadProtocol == DownloadProtocol.Torrent)
case ProcessedDecisionResult.Pending:
{
torrentFailed = true;
PreparePending(pendingAddQueue, grabbed, pending, report, PendingReleaseReason.Delay);
break;
}
case ProcessedDecisionResult.Rejected:
{
rejected.Add(report);
break;
}
case ProcessedDecisionResult.Failed:
{
PreparePending(pendingAddQueue, grabbed, pending, report, PendingReleaseReason.DownloadClientUnavailable);
if (downloadProtocol == DownloadProtocol.Usenet)
{
usenetFailed = true;
}
else if (downloadProtocol == DownloadProtocol.Torrent)
{
torrentFailed = true;
}
break;
}
case ProcessedDecisionResult.Skipped:
{
break;
}
}
else
{
_logger.Warn(ex, "Couldn't add report to download queue. " + remoteBook);
}
}
}
@@ -115,10 +124,44 @@ namespace NzbDrone.Core.Download
return new ProcessedDecisions(grabbed, pending, rejected);
}
public async Task<ProcessedDecisionResult> ProcessDecision(DownloadDecision decision, int? downloadClientId)
{
if (decision == null)
{
return ProcessedDecisionResult.Skipped;
}
if (!IsQualifiedReport(decision))
{
return ProcessedDecisionResult.Rejected;
}
if (decision.TemporarilyRejected)
{
_pendingReleaseService.Add(decision, PendingReleaseReason.Delay);
return ProcessedDecisionResult.Pending;
}
var result = await ProcessDecisionInternal(decision, downloadClientId);
if (result == ProcessedDecisionResult.Failed)
{
_pendingReleaseService.Add(decision, PendingReleaseReason.DownloadClientUnavailable);
}
return result;
}
internal List<DownloadDecision> GetQualifiedReports(IEnumerable<DownloadDecision> decisions)
{
//Process both approved and temporarily rejected
return decisions.Where(c => (c.Approved || c.TemporarilyRejected) && c.RemoteBook.Books.Any()).ToList();
return decisions.Where(IsQualifiedReport).ToList();
}
internal bool IsQualifiedReport(DownloadDecision decision)
{
// Process both approved and temporarily rejected
return (decision.Approved || decision.TemporarilyRejected) && decision.RemoteBook.Books.Any();
}
private bool IsBookProcessed(List<DownloadDecision> decisions, DownloadDecision report)
@@ -148,5 +191,38 @@ namespace NzbDrone.Core.Download
queue.Add(Tuple.Create(report, reason));
pending.Add(report);
}
private async Task<ProcessedDecisionResult> ProcessDecisionInternal(DownloadDecision decision, int? downloadClientId = null)
{
var remoteBook = decision.RemoteBook;
try
{
_logger.Trace("Grabbing from Indexer {0} at priority {1}.", remoteBook.Release.Indexer, remoteBook.Release.IndexerPriority);
await _downloadService.DownloadReport(remoteBook, downloadClientId);
return ProcessedDecisionResult.Grabbed;
}
catch (ReleaseUnavailableException)
{
_logger.Warn("Failed to download release from indexer, no longer available. " + remoteBook);
return ProcessedDecisionResult.Rejected;
}
catch (Exception ex)
{
if (ex is DownloadClientUnavailableException || ex is DownloadClientAuthenticationException)
{
_logger.Debug(ex,
"Failed to send release to download client, storing until later. " + remoteBook);
return ProcessedDecisionResult.Failed;
}
else
{
_logger.Warn(ex, "Couldn't add report to download queue. " + remoteBook);
return ProcessedDecisionResult.Skipped;
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
namespace NzbDrone.Core.Download
{
public enum ProcessedDecisionResult
{
Grabbed,
Pending,
Rejected,
Failed,
Skipped
}
}

View File

@@ -5,6 +5,7 @@ using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Messaging;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.Download
{
@@ -41,6 +42,12 @@ namespace NzbDrone.Core.Download
return;
}
if (message.ReleaseSource == ReleaseSourceType.InteractiveSearch && !_configService.AutoRedownloadFailedFromInteractiveSearch)
{
_logger.Debug("Auto redownloading failed books from interactive search is disabled");
return;
}
if (message.BookIds.Count == 1)
{
_logger.Debug("Failed download only contains one book, searching again");

View File

@@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Indexers;
@@ -21,17 +22,20 @@ namespace NzbDrone.Core.Download
where TSettings : IProviderConfig, new()
{
protected readonly IHttpClient _httpClient;
private readonly IBlocklistService _blocklistService;
protected readonly ITorrentFileInfoReader _torrentFileInfoReader;
protected TorrentClientBase(ITorrentFileInfoReader torrentFileInfoReader,
IHttpClient httpClient,
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
Logger logger)
IHttpClient httpClient,
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
IBlocklistService blocklistService,
Logger logger)
: base(configService, diskProvider, remotePathMappingService, logger)
{
_httpClient = httpClient;
_blocklistService = blocklistService;
_torrentFileInfoReader = torrentFileInfoReader;
}
@@ -86,7 +90,7 @@ namespace NzbDrone.Core.Download
{
try
{
return DownloadFromMagnetUrl(remoteBook, magnetUrl);
return DownloadFromMagnetUrl(remoteBook, indexer, magnetUrl);
}
catch (NotSupportedException ex)
{
@@ -100,7 +104,7 @@ namespace NzbDrone.Core.Download
{
try
{
return DownloadFromMagnetUrl(remoteBook, magnetUrl);
return DownloadFromMagnetUrl(remoteBook, indexer, magnetUrl);
}
catch (NotSupportedException ex)
{
@@ -133,7 +137,9 @@ namespace NzbDrone.Core.Download
request.Headers.Accept = "application/x-bittorrent";
request.AllowAutoRedirect = false;
var response = await _httpClient.GetAsync(request);
var response = await RetryStrategy
.ExecuteAsync(static async (state, _) => await state._httpClient.GetAsync(state.request), (_httpClient, request))
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.MovedPermanently ||
response.StatusCode == HttpStatusCode.Found ||
@@ -147,7 +153,7 @@ namespace NzbDrone.Core.Download
{
if (locationHeader.StartsWith("magnet:"))
{
return DownloadFromMagnetUrl(remoteBook, locationHeader);
return DownloadFromMagnetUrl(remoteBook, indexer, locationHeader);
}
request.Url += new HttpUri(locationHeader);
@@ -190,6 +196,9 @@ namespace NzbDrone.Core.Download
var filename = string.Format("{0}.torrent", FileNameBuilder.CleanFileName(remoteBook.Release.Title));
var hash = _torrentFileInfoReader.GetHashFromTorrentFile(torrentFile);
EnsureReleaseIsNotBlocklisted(remoteBook, indexer, hash);
var actualHash = AddFromTorrentFile(remoteBook, hash, filename, torrentFile);
if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash)
@@ -203,7 +212,7 @@ namespace NzbDrone.Core.Download
return actualHash;
}
private string DownloadFromMagnetUrl(RemoteBook remoteBook, string magnetUrl)
private string DownloadFromMagnetUrl(RemoteBook remoteBook, IIndexer indexer, string magnetUrl)
{
string hash = null;
string actualHash = null;
@@ -221,6 +230,8 @@ namespace NzbDrone.Core.Download
if (hash != null)
{
EnsureReleaseIsNotBlocklisted(remoteBook, indexer, hash);
actualHash = AddFromMagnetLink(remoteBook, hash, magnetUrl);
}
@@ -234,5 +245,29 @@ namespace NzbDrone.Core.Download
return actualHash;
}
private void EnsureReleaseIsNotBlocklisted(RemoteBook remoteBook, IIndexer indexer, string hash)
{
var indexerSettings = indexer?.Definition?.Settings as ITorrentIndexerSettings;
var torrentInfo = remoteBook.Release as TorrentInfo;
var torrentInfoHash = torrentInfo?.InfoHash;
// If the release didn't come from an interactive search,
// the hash wasn't known during processing and the
// indexer is configured to reject blocklisted releases
// during grab check if it's already been blocklisted.
if (torrentInfo != null && torrentInfoHash.IsNullOrWhiteSpace())
{
// If the hash isn't known from parsing we set it here so it can be used for blocklisting.
torrentInfo.InfoHash = hash;
if (remoteBook.ReleaseSource != ReleaseSourceType.InteractiveSearch &&
indexerSettings?.RejectBlocklistedTorrentHashesWhileGrabbing == true &&
_blocklistService.BlocklistedTorrentHash(remoteBook.Author.Id, hash))
{
throw new ReleaseBlockedException(remoteBook.Release, "Release previously added to blocklist");
}
}
}
}
}

View File

@@ -47,7 +47,9 @@ namespace NzbDrone.Core.Download
var request = indexer?.GetDownloadRequest(url) ?? new HttpRequest(url);
request.RateLimitKey = remoteBook?.Release?.IndexerId.ToString();
var response = await _httpClient.GetAsync(request);
var response = await RetryStrategy
.ExecuteAsync(static async (state, _) => await state._httpClient.GetAsync(state.request), (_httpClient, request))
.ConfigureAwait(false);
nzbData = response.ResponseData;

View File

@@ -0,0 +1,28 @@
using System;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.Exceptions
{
public class ReleaseBlockedException : ReleaseDownloadException
{
public ReleaseBlockedException(ReleaseInfo release, string message, params object[] args)
: base(release, message, args)
{
}
public ReleaseBlockedException(ReleaseInfo release, string message)
: base(release, message)
{
}
public ReleaseBlockedException(ReleaseInfo release, string message, Exception innerException, params object[] args)
: base(release, message, innerException, args)
{
}
public ReleaseBlockedException(ReleaseInfo release, string message, Exception innerException)
: base(release, message, innerException)
{
}
}
}

View File

@@ -9,6 +9,7 @@ namespace NzbDrone.Core.History
public class EntityHistory : ModelBase
{
public const string DOWNLOAD_CLIENT = "downloadClient";
public const string RELEASE_SOURCE = "releaseSource";
public EntityHistory()
{

View File

@@ -162,15 +162,15 @@ namespace NzbDrone.Core.History
history.Data.Add("Guid", message.Book.Release.Guid);
history.Data.Add("Protocol", ((int)message.Book.Release.DownloadProtocol).ToString());
history.Data.Add("DownloadForced", (!message.Book.DownloadAllowed).ToString());
history.Data.Add("CustomFormatScore", message.Book.CustomFormatScore.ToString());
history.Data.Add("ReleaseSource", message.Book.ReleaseSource.ToString());
if (!message.Book.ParsedBookInfo.ReleaseHash.IsNullOrWhiteSpace())
{
history.Data.Add("ReleaseHash", message.Book.ParsedBookInfo.ReleaseHash);
}
var torrentRelease = message.Book.Release as TorrentInfo;
if (torrentRelease != null)
if (message.Book.Release is TorrentInfo torrentRelease)
{
history.Data.Add("TorrentInfoHash", torrentRelease.InfoHash);
}

View File

@@ -28,6 +28,7 @@ namespace NzbDrone.Core.ImportLists.Readarr
{
public string Title { get; set; }
public string ForeignBookId { get; set; }
public string ForeignEditionId { get; set; }
public string Overview { get; set; }
public List<MediaCover.MediaCover> Images { get; set; }
public bool Monitored { get; set; }

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