1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-03-27 17:54:34 -04:00

Compare commits

...

78 Commits

Author SHA1 Message Date
Bogdan
6ad9ebb19e Revert "Fixed: Ensure translations are fetched before loading app"
This reverts commit c61cca7952.
2023-07-16 03:57:28 +03:00
Bogdan
f8cbca7958 New: Show tooltips with Custom Formats in History and Queue
(cherry picked from commit d6aee683dc4e8a8c94993b30df21fe8f6c5106b3)

Closes #8819
2023-07-16 03:41:42 +03:00
jack-mil
f65835b874 New: Add Custom Format fields to Discord On Grab notifications
(cherry picked from commit 3a9182b6a6a322a03666592f8e08d4afde11ee86)

Closes #8790
2023-07-16 03:25:18 +03:00
Bogdan
59ea905e06 Move ApplyTags to own file
Closes #8808
2023-07-16 03:19:26 +03:00
Mark McDowall
d8eda4d089 Fixed: Order of Discord grab notification fields
(cherry picked from commit 35e171f7b120ba68f4c2f5a7ada5ba204caf7f45)

Closes #8820
2023-07-16 03:14:35 +03:00
Mark McDowall
e4eb8f63bb Use named keys for apply tags help text
(cherry picked from commit c1f8c7b17ba5775a0f6f76cebc3173e03124d000)

Closes #8816
2023-07-16 03:14:10 +03:00
Bogdan
d936591b66 Fix translation call for OrganizeConfirm 2023-07-16 03:08:33 +03:00
Mark McDowall
c61cca7952 Fixed: Ensure translations are fetched before loading app
(cherry picked from commit ad2721dc55f3233e4c299babe5744418bc530418)

Closes #8814
Closes #8817
2023-07-16 03:03:57 +03:00
Ricardo Christmann
f38077aac7 New: Add pushsafer notification service (#8770) 2023-07-14 21:22:08 -05:00
Weblate
3055ed5336 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: 無情天 <kofzhanganguo@126.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2023-07-14 05:22:54 +03:00
Servarr
164625a0b2 Automated API Docs update 2023-07-11 18:42:00 +03:00
Qstick
09ca0a1c0a New: Download Client Tags
(cherry picked from commit f6ae9fd6c5173cbf1540341fa99d2f120be1d28e)
2023-07-11 18:34:02 +03:00
Weblate
bef881a9e2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translation: Servarr/Radarr
2023-07-11 11:04:23 +03:00
Servarr
f7e36581e1 Automated API Docs update 2023-07-11 11:04:00 +03:00
jack-mil
20a8f1cbe7 New: Custom Format Score column in queue
(cherry picked from commit a6f2db9139c4a6b01d162ccf8884fc02c874b4cf)

Closes #8788
2023-07-11 10:56:56 +03:00
Qstick
3da8396b7e Fix circular dependency with ScriptImportDecider, TagService 2023-07-11 00:11:33 -05:00
bakerboy448
d61f914bd7 Fixed: Improve Kodi Library Update logging 2023-07-10 23:30:44 -05:00
Bakerboy448
25837adfc7 More Import List Logging Improvements 2023-07-10 23:29:54 -05:00
Qstick
5516d7e3cd Fixed: Fade out on hover of collection movies in dark mode 2023-07-10 23:29:16 -05:00
Qstick
e2647deea3 Fixed: Default new lists to 'Released' minimum availability in UI 2023-07-10 23:18:59 -05:00
Jendrik Weise
8c34946134 New: Additional info passed in for Script Import and Custom Script
(cherry picked from commit 3f0268b79f6f5953191da4a765ba24159e15974d)
2023-07-10 23:04:47 -05:00
Qstick
4a66a832b3 New: View, Sort, Filter by Movie popularity on Index and Discover
Fixes #1947
2023-07-10 22:54:32 -05:00
Qstick
2d18e4f89e Fixed: Multiple settings not working on Movie Overview view 2023-07-10 21:57:26 -05:00
Qstick
d6c1721f51 Sonarr to Radarr in Freebox download client settings 2023-07-10 21:34:58 -05:00
Qstick
99709d6445 Add missing HistoryTable css type file 2023-07-10 21:34:07 -05:00
Qstick
916d43d70d New: Simkl List Support
(cherry picked from commit 4a740acb801a04bc2ead45d272d493f4ec46f7e8)
2023-07-10 21:34:07 -05:00
Bogdan
bc004b3b5b Improve look of no history message 2023-07-11 03:32:53 +03:00
Servarr
7a222dcd9f Automated API Docs update 2023-07-10 23:42:02 +03:00
Bogdan
48b9c1e8b9 Fixed: Minor improvements and translations for managing bulk indexers, lists and clients 2023-07-10 23:34:01 +03:00
Bogdan
7dde88387a Fix build removing trailing spaces 2023-07-10 20:57:51 +03:00
bakerboy448
0eddf76622 Fixed: Further clarification to Quality Profile Cut Offs 2023-07-10 19:52:44 +03:00
Bogdan
f69a847d9a Apply suggestions from code review 2023-07-10 19:51:57 +03:00
Bogdan
97ed820575 Apply suggestions from code review 2023-07-10 19:51:57 +03:00
Bogdan
0ee94a4624 Update src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs 2023-07-10 19:51:57 +03:00
bakerboy448
3b7914f63b New: Import List Logging Improvements 2023-07-10 19:51:57 +03:00
Weblate
0005fa57ac Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translation: Servarr/Radarr
2023-07-10 19:24:14 +03:00
Qstick
bbde1dc7a6 Avoid using GetAllMovies in MediaFileDeletionService 2023-07-09 23:32:53 -05:00
Qstick
1c99ce8876 Avoid using GetAllMovies in DeleteBadMediaCovers 2023-07-09 23:32:53 -05:00
Qstick
7a5ae56a96 Fixed: Skip move when source and destination are the same
Co-Authored-By: Colin Hebert <makkhdyn@gmail.com>
2023-07-09 23:08:05 -05:00
Qstick
ae8820178d Avoid db call for naming config on every movie during single collection call 2023-07-09 23:05:59 -05:00
ricci2511
c214a6b67b Fixed: Handle client state of collection missingMovies 2023-07-09 11:15:51 -05:00
Bogdan
b3f6774820 Bump version to 4.7.1 2023-07-09 15:00:20 +03:00
Bogdan
8fd4e41c85 Add translations for tags in manage indexers 2023-07-08 00:38:49 +03:00
Weblate
8984fd735b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Kevin Orel Edry <techg9@gmail.com>
Co-authored-by: Tacit <1750630216@qq.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: 阿卡林刘 <ScottLiu_NonWin@outlook.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2023-07-08 00:10:34 +03:00
Bogdan
3321123043 Fix translation to Apply Changes 2023-07-08 00:04:17 +03:00
Bogdan
23a13b5c23 Use SetProviderCharacteristics in bulk endpoint 2023-07-08 00:00:08 +03:00
Bogdan
304a07e23f Add translation to manage indexers 2023-07-07 20:19:32 +03:00
Bogdan
1e0ec4aefb Fix tests in ImportListSyncServiceFixture 2023-07-05 09:22:32 +03:00
Bogdan
5c46c75ce7 Fix cleaning library in ImportListSyncService 2023-07-05 09:01:53 +03:00
Bogdan
1c26dd4aca Disable library cleanup temporarily 2023-07-05 08:31:41 +03:00
Weblate
6fae00f51c Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translation: Servarr/Radarr
2023-07-05 08:11:15 +03:00
Servarr
653ef0a501 Automated API Docs update 2023-07-04 13:03:49 -05:00
Qstick
e606ff05a4 Add package to Sentry release to ensure apps don't mix 2023-07-04 12:12:44 -05:00
Agneev Mukherjee
dd3ac26604 Set frontend theme-color to sidebar color (#6891) 2023-07-04 11:32:42 -05:00
Bakerboy448
122d0056ea Fixed: Quality Parsing Regex Improvements
Fixed: Parse Blu.Ray as Bluray
2023-07-04 11:20:51 -05:00
Ricardo Christmann
fe41aada06 New: Add tags field to MovieCollection (#8736)
* New: Add tags field to MovieCollection
* Added Tag input prepopulation for new collection movies
* Handle editting of collection tags
* Revert changes to CollectionController.cs
2023-07-04 11:18:44 -05:00
Bogdan
bd1844030d New: Rework List sync interval logic
* New: Rework List sync interval logic

(cherry picked from commit c522cd120d08757e7e43c2348be4d7f05a254fac)

* Minor alignment with Sonarr

* Remove ListUpdateInterval

---------

Co-authored-by: Qstick <qstick@gmail.com>
2023-07-04 19:17:28 +03:00
Qstick
148ee5983d Bump version to 4.7.0 2023-07-03 22:29:48 -05:00
Qstick
372d15ecf3 New DSN for 4.6 2023-07-03 11:16:06 -05:00
Bogdan
b95431500d Fixed: Sorting queue by year
Fixes #8765
2023-07-03 17:00:14 +03:00
Bogdan
3da72f54ef Add ContentSummary to HDBits requests
(cherry picked from commit b6f3bcb3093267c483a57e10ac425e871f1f7ba8)

Closes #8757
2023-07-03 11:22:43 +03:00
Bogdan
7cfff20cad Create overload for ToJson() with Formatting param
(cherry picked from commit aa2b0031671b6846eaa75e0914cd03ae7bbb0da7)
2023-07-03 11:20:02 +03:00
Qstick
d3895dec8f Fixed: Allow restore to process backups up to ~500MB
(cherry picked from commit 551edb9e655d2a541a2232f85e79a5e3f7b433aa)
2023-07-03 08:55:11 +01:00
Weblate
690bab3264 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: BeardedWatermelon <periklis.karantonis@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translation: Servarr/Radarr
2023-06-30 13:55:34 +03:00
Bogdan
986128e100 Removed suspicious URL in the default definitions
Co-authored-by: Rumplin <35449956+rumplin@users.noreply.github.com>
2023-06-29 18:06:04 +03:00
Weblate
dda0885f91 Translated using Weblate (Portuguese (Brazil)) [skip ci]
Currently translated at 100.0% (1185 of 1185 strings)

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translation: Servarr/Radarr
2023-06-28 13:55:21 +03:00
Mark McDowall
7e218a886d Handle null for parent name with clean path is empty
(cherry picked from commit bd3816dac9b540d2f95960a254bb21ab3c91421e)
2023-06-28 08:27:19 +03:00
Mark McDowall
77cde138dc Fixed: Don't handle images in metadata folder as Roksbox images
(cherry picked from commit 95a8f59a32d55550d5367f08a964859a97a0df44)
2023-06-28 08:27:19 +03:00
Bogdan
239109e3dd New: Improve empty list messaging
(cherry picked from commit ee843259bca2d9764a3919b123524a51a3a16cce)

Closes #8738
2023-06-27 06:00:03 +03:00
Bogdan
4804eb0769 Fixed: Invalid image URL if Movie is missing background image
(cherry picked from commit dd096e0fda71b3afa9b09c2900abbf226d8a5204)

Closes #8742
2023-06-27 05:46:30 +03:00
Bogdan
60a55dfdac Allow array of string as value in EnhancedSelectInput
(cherry picked from commit 6816767fad9e4e839e77c5fe40aece97033cd052)
2023-06-26 23:49:23 +01:00
Servarr
cd82865303 Automated API Docs update 2023-06-26 02:25:31 +03:00
Servarr
439adb4ac6 Translated using Weblate (Hungarian) [skip ci] (#8734)
Currently translated at 97.6% (1156 of 1184 strings)


Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translation: Servarr/Radarr

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: sutoramon <sutoramon@gmail.com>
2023-06-26 02:25:08 +03:00
Bogdan
fd0ff78791 Add translation to movie collection filters 2023-06-26 02:23:26 +03:00
Ricardo Christmann
cbae355402 New: Added filter and sort options to Collections (#8731)
* New: Added filter and sort options to Collections

* Add AllMovieWithCollectionsTmdbIds method to MovieService and MovieRepository
2023-06-25 10:04:57 -04:00
Qstick
fed98a648f Bump version to 4.6.4 2023-06-25 01:10:05 -04:00
bakerboy448
e1c5656cff Add more trace logs related info to bug_report.yml [skip ci] [common] 2023-06-22 16:38:09 +03:00
Weblate
e3f88e1711 Translated using Weblate (Portuguese (Brazil)) [skip ci]
Currently translated at 100.0% (1184 of 1184 strings)

Translated using Weblate (Chinese (Traditional) (zh_TW)) [skip ci]

Currently translated at 9.2% (109 of 1184 strings)

Translated using Weblate (Portuguese (Brazil)) [skip ci]

Currently translated at 100.0% (1184 of 1184 strings)

Translated using Weblate (Vietnamese) [skip ci]

Currently translated at 87.6% (1038 of 1184 strings)

Translated using Weblate (German) [skip ci]

Currently translated at 100.0% (1184 of 1184 strings)

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: KHng0284 <giakhang021109@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: ted09080037 <ted09080037@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2023-06-21 06:53:25 +03:00
215 changed files with 2680 additions and 687 deletions

View File

@@ -76,7 +76,7 @@ body:
- type: checkboxes
attributes:
label: Trace Logs have been provided as applicable. Reports may be closed if the required logs are not provided.
description: Trace logs are generally required for all bug reports
description: Trace logs are generally required for all bug reports and contain `trace`. Info logs are invalid for bug reports and do not contain `debug` nor `trace`
options:
- label: I have followed the steps in the wiki link above and provided the required trace logs that are relevant and show this issue.
- label: I have read and followed the steps in the wiki link above and provided the required trace logs - the logs contain `trace` - that are relevant and show this issue.
required: true

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '4.6.3'
majorVersion: '4.7.1'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
@@ -363,7 +363,7 @@ stages:
- bash: |
echo "Uploading source maps to sentry"
curl -sL https://sentry.io/get-cli/ | bash
RELEASENAME="${RADARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
RELEASENAME="Radarr@${RADARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
sentry-cli releases new --finalize -p radarr -p radarr-ui -p radarr-update "${RELEASENAME}"
sentry-cli releases -p radarr-ui files "${RELEASENAME}" upload-sourcemaps _output/UI/ --rewrite
sentry-cli releases set-commits --auto "${RELEASENAME}"

View File

@@ -4,12 +4,13 @@ import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, tooltipPositions } from 'Helpers/Props';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import MovieTitleLink from 'Movie/MovieTitleLink';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import HistoryDetailsModal from './Details/HistoryDetailsModal';
import HistoryEventTypeCell from './HistoryEventTypeCell';
import styles from './HistoryRow.css';
@@ -176,7 +177,14 @@ class HistoryRow extends Component {
key={name}
className={styles.customFormatScore}
>
{formatCustomFormatScore(customFormatScore)}
<Tooltip
anchor={formatPreferredWordScore(
customFormatScore,
customFormats.length
)}
tooltip={<MovieFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
@@ -257,4 +265,8 @@ HistoryRow.propTypes = {
onMarkAsFailedPress: PropTypes.func.isRequired
};
HistoryRow.defaultProps = {
customFormats: []
};
export default HistoryRow;

View File

@@ -16,6 +16,12 @@
width: 150px;
}
.customFormatScore {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 55px;
}
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';

View File

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

View File

@@ -8,13 +8,15 @@ import ProgressBar from 'Components/ProgressBar';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import MovieFormats from 'Movie/MovieFormats';
import MovieLanguage from 'Movie/MovieLanguage';
import MovieQuality from 'Movie/MovieQuality';
import MovieTitleLink from 'Movie/MovieTitleLink';
import formatBytes from 'Utilities/Number/formatBytes';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import translate from 'Utilities/String/translate';
import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal from './RemoveQueueItemModal';
@@ -88,6 +90,7 @@ class QueueRow extends Component {
movie,
quality,
customFormats,
customFormatScore,
languages,
protocol,
indexer,
@@ -201,6 +204,24 @@ class QueueRow extends Component {
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
<Tooltip
anchor={formatPreferredWordScore(
customFormatScore,
customFormats.length
)}
tooltip={<MovieFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
@@ -365,6 +386,7 @@ QueueRow.propTypes = {
movie: PropTypes.object,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
@@ -390,6 +412,7 @@ QueueRow.propTypes = {
};
QueueRow.defaultProps = {
customFormats: [],
isGrabbing: false,
isRemoving: false
};

View File

@@ -28,7 +28,7 @@ function createMapStateToProps() {
qualityProfileId: collection.qualityProfileId,
minimumAvailability: collection.minimumAvailability,
searchForMovie: collection.searchOnAdd,
tags: []
tags: collection.tags || []
};
const {

View File

@@ -2,17 +2,12 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createCollectionSelector from 'Store/Selectors/createCollectionSelector';
function createMapStateToProps() {
return createSelector(
createCollectionSelector(),
createAllMoviesSelector(),
(
collection,
allMovies
) => {
(collection) => {
// If a movie is deleted this selector may fire before the parent
// selecors, which will result in an undefined movie, if that happens
// we want to return early here and again in the render function to avoid
@@ -22,21 +17,11 @@ function createMapStateToProps() {
return {};
}
let allGenres = [];
let libraryMovies = 0;
collection.movies.forEach((movie) => {
allGenres = allGenres.concat(movie.genres);
if (allMovies.find((libraryMovie) => libraryMovie.tmdbId === movie.tmdbId)) {
libraryMovies++;
}
});
const allGenres = collection.movies.flatMap((movie) => movie.genres);
return {
...collection,
genres: Array.from(new Set(allGenres)).slice(0, 3),
missingMovies: collection.movies.length - libraryMovies
genres: Array.from(new Set(allGenres)).slice(0, 3)
};
}
);

View File

@@ -50,6 +50,7 @@ class EditCollectionModalContent extends Component {
minimumAvailability,
// Id,
rootFolderPath,
tags,
searchOnAdd
} = item;
@@ -126,6 +127,17 @@ class EditCollectionModalContent extends Component {
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SearchOnAdd')}</FormLabel>

View File

@@ -42,6 +42,7 @@ function createMapStateToProps() {
qualityProfileId: collection.qualityProfileId,
minimumAvailability: collection.minimumAvailability,
rootFolderPath: collection.rootFolderPath,
tags: collection.tags,
searchOnAdd: collection.searchOnAdd
};

View File

@@ -28,6 +28,14 @@ function CollectionSortMenu(props) {
>
{translate('Title')}
</SortMenuItem>
<SortMenuItem
name="missingMovies"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Missing')}
</SortMenuItem>
</MenuContent>
</SortMenu>
);

View File

@@ -102,7 +102,7 @@ $hoverScale: 1.05;
position: relative;
display: block;
background-color: var(--defaultColor);
background-color: var(--black);
}
.monitorToggleButton {

View File

@@ -580,7 +580,7 @@ EnhancedSelectInput.propTypes = {
className: PropTypes.string,
disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,

View File

@@ -39,6 +39,12 @@
flex: 0 0 90px;
}
.popularity {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 100px;
}
.lists {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';

View File

@@ -11,6 +11,7 @@ interface CssExports {
'lists': string;
'originalLanguage': string;
'physicalRelease': string;
'popularity': string;
'ratings': string;
'runtime': string;
'sortTitle': string;

View File

@@ -64,6 +64,12 @@
flex: 0 0 90px;
}
.popularity {
composes: cell;
flex: 0 0 100px;
}
.lists {
composes: cell;

View File

@@ -16,6 +16,7 @@ interface CssExports {
'lists': string;
'originalLanguage': string;
'physicalRelease': string;
'popularity': string;
'ratings': string;
'runtime': string;
'sortTitle': string;

View File

@@ -13,6 +13,7 @@ import AddNewDiscoverMovieModal from 'DiscoverMovie/AddNewDiscoverMovieModal';
import ExcludeMovieModal from 'DiscoverMovie/Exclusion/ExcludeMovieModal';
import { icons } from 'Helpers/Props';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import MoviePopularityIndex from 'Movie/MoviePopularityIndex';
import formatRuntime from 'Utilities/Date/formatRuntime';
import translate from 'Utilities/String/translate';
import ListMovieStatusCell from './ListMovieStatusCell';
@@ -73,6 +74,7 @@ class DiscoverMovieRow extends Component {
images,
genres,
ratings,
popularity,
certification,
collection,
columns,
@@ -261,6 +263,14 @@ class DiscoverMovieRow extends Component {
);
}
if (name === 'popularity') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<MoviePopularityIndex popularity={popularity} />
</VirtualTableRowCell>
);
}
if (name === 'certification') {
return (
<VirtualTableRowCell
@@ -384,6 +394,7 @@ DiscoverMovieRow.propTypes = {
runtime: PropTypes.number,
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
ratings: PropTypes.object.isRequired,
popularity: PropTypes.number.isRequired,
certification: PropTypes.string,
collection: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,

View File

@@ -61,6 +61,7 @@ import {
faFileInvoice as farFileInvoice,
faFilm as fasFilm,
faFilter as fasFilter,
faFire as fasFire,
faFlag as fasFlag,
faFolderOpen as fasFolderOpen,
faForward as fasForward,
@@ -192,6 +193,7 @@ export const PAUSED = fasPause;
export const PENDING = farClock;
export const PLAY = fasPlay;
export const PROFILE = fasUser;
export const POPULAR = fasFire;
export const POSTER = fasTh;
export const QUEUED = fasCloud;
export const QUICK = fasRocket;

View File

@@ -31,6 +31,7 @@ function createMapDispatchToProps(dispatch, props) {
dispatch(
deleteMovie({
id: props.movieId,
collectionTmdbId: this.collection?.tmdbId,
deleteFiles,
addImportExclusion
})

View File

@@ -303,6 +303,7 @@ class MovieDetails extends Component {
selectedTabIndex
} = this.state;
const fanartUrl = getFanartUrl(images);
const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150);
return (
@@ -361,9 +362,11 @@ class MovieDetails extends Component {
<div className={styles.header}>
<div
className={styles.backdrop}
style={{
backgroundImage: `url(${getFanartUrl(images)})`
}}
style={
fanartUrl ?
{ backgroundImage: `url(${fanartUrl})` } :
null
}
>
<div className={styles.backdropOverlay} />
</div>

View File

@@ -0,0 +1,10 @@
.container {
margin-top: 20px;
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 MovieHistoryTableContentConnector from './MovieHistoryTableContentConnector';
import styles from './MovieHistoryTable.css';
function MovieHistoryTable(props) {
const {
@@ -7,9 +8,11 @@ function MovieHistoryTable(props) {
} = props;
return (
<MovieHistoryTableContentConnector
{...otherProps}
/>
<div className={styles.container}>
<MovieHistoryTableContentConnector
{...otherProps}
/>
</div>
);
}

View File

@@ -118,6 +118,15 @@ function MovieIndexSortMenu(props: MovieIndexSortMenuProps) {
{translate('TmdbRating')}
</SortMenuItem>
<SortMenuItem
name="popularity"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Popularity')}
</SortMenuItem>
<SortMenuItem
name="path"
sortKey={sortKey}

View File

@@ -71,6 +71,9 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
isAvailable,
tmdbId,
imdbId,
studio,
sizeOnDisk,
added,
youTubeTrailerId,
} = movie;
@@ -219,6 +222,9 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
height={overviewHeight}
monitored={monitored}
qualityProfile={qualityProfile}
studio={studio}
sizeOnDisk={sizeOnDisk}
added={added}
path={path}
sortKey={sortKey}
{...overviewOptions}

View File

@@ -56,7 +56,7 @@ const rows = [
{
name: 'qualityProfileId',
showProp: 'showQualityProfile',
valueProp: 'qualityProfileId',
valueProp: 'qualityProfile',
},
{
name: 'added',

View File

@@ -203,7 +203,7 @@ function EditMoviesModalContent(props: EditMoviesModalContentProps) {
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={onSavePressWrapper}>
{translate('Apply Changes')}
{translate('ApplyChanges')}
</Button>
</div>
</ModalFooter>

View File

@@ -65,7 +65,7 @@ function OrganizeMoviesModalContent(props: OrganizeMoviesModalContentProps) {
</Alert>
<div className={styles.message}>
{translate('OrganizeConfirm', movieTitles.length)}
{translate('OrganizeConfirm', [movieTitles.length])}
</div>
<ul>

View File

@@ -48,6 +48,7 @@
}
.added,
.popularity,
.runtime {
composes: cell;

View File

@@ -18,6 +18,7 @@ interface CssExports {
'originalTitle': string;
'path': string;
'physicalRelease': string;
'popularity': string;
'qualityProfileId': string;
'rottenTomatoesRating': string;
'runtime': string;

View File

@@ -19,6 +19,7 @@ import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import createMovieIndexItemSelector from 'Movie/Index/createMovieIndexItemSelector';
import MoviePopularityIndex from 'Movie/MoviePopularityIndex';
import MovieTitleLink from 'Movie/MovieTitleLink';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
@@ -69,6 +70,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
sizeOnDisk,
genres = [],
ratings,
popularity,
certification,
tags = [],
tmdbId,
@@ -362,6 +364,14 @@ function MovieIndexRow(props: MovieIndexRowProps) {
);
}
if (name === 'popularity') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<MoviePopularityIndex popularity={popularity} />
</VirtualTableRowCell>
);
}
if (name === 'certification') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>

View File

@@ -41,6 +41,7 @@
}
.added,
.popularity,
.runtime {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';

View File

@@ -15,6 +15,7 @@ interface CssExports {
'originalTitle': string;
'path': string;
'physicalRelease': string;
'popularity': string;
'qualityProfileId': string;
'rottenTomatoesRating': string;
'runtime': string;

View File

@@ -49,6 +49,7 @@ interface Movie extends ModelBase {
sizeOnDisk: number;
genres: string[];
ratings: Ratings;
popularity: number;
certification: string;
tags: number[];
images: Image[];

View File

@@ -0,0 +1,5 @@
.popularityIcon {
margin-right: 2px;
width: 12px !important;
text-align: center;
}

View File

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

View File

@@ -0,0 +1,23 @@
import React from 'react';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './MoviePopularityIndex.css';
interface MoviePopularityIndexProps {
popularity: number;
}
function MoviePopularityIndex(props: MoviePopularityIndexProps) {
const { popularity } = props;
return (
<Label kind={kinds.INVERSE} title={translate('PopularityIndex')}>
<Icon className={styles.popularityIcon} name={icons.POPULAR} size={11} />
{popularity.toFixed()}
</Label>
);
}
export default MoviePopularityIndex;

View File

@@ -86,7 +86,7 @@ class DownloadClientSettings extends Component {
/>
<PageToolbarButton
label="Manage Clients"
label={translate('ManageClients')}
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>

View File

@@ -3,6 +3,7 @@ import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
@@ -56,7 +57,9 @@ class DownloadClient extends Component {
id,
name,
enable,
priority
priority,
tags,
tagList
} = this.props;
return (
@@ -94,6 +97,11 @@ class DownloadClient extends Component {
}
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<EditDownloadClientModalConnector
id={id}
isOpen={this.state.isEditDownloadClientModalOpen}
@@ -120,6 +128,8 @@ DownloadClient.propTypes = {
name: PropTypes.string.isRequired,
enable: PropTypes.bool.isRequired,
priority: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteDownloadClient: PropTypes.func.isRequired
};

View File

@@ -50,6 +50,7 @@ class DownloadClients extends Component {
const {
items,
onConfirmDeleteDownloadClient,
tagList,
...otherProps
} = this.props;
@@ -71,6 +72,7 @@ class DownloadClients extends Component {
<DownloadClient
key={item.id}
{...item}
tagList={tagList}
onConfirmDeleteDownloadClient={onConfirmDeleteDownloadClient}
/>
);
@@ -109,6 +111,7 @@ DownloadClients.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteDownloadClient: PropTypes.func.isRequired
};

View File

@@ -4,13 +4,20 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByName from 'Utilities/Array/sortByName';
import DownloadClients from './DownloadClients';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.downloadClients', sortByName),
(downloadClients) => downloadClients
createTagsSelector(),
(downloadClients, tagList) => {
return {
...downloadClients,
tagList
};
}
);
}

View File

@@ -51,6 +51,7 @@ class EditDownloadClientModalContent extends Component {
removeCompletedDownloads,
removeFailedDownloads,
fields,
tags,
message
} = item;
@@ -140,6 +141,18 @@ class EditDownloadClientModalContent extends Component {
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('DownloadClientTagHelpText')}
{...tags}
onChange={onInputChange}
/>
</FormGroup>
<FieldSet
size={sizes.SMALL}
legend={translate('CompletedDownloadHandling')}

View File

@@ -27,9 +27,9 @@ interface ManageDownloadClientsEditModalContentProps {
const NO_CHANGE = 'noChange';
const enableOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'enabled', value: 'Enabled' },
{ key: 'disabled', value: 'Disabled' },
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageDownloadClientsEditModalContent(
@@ -97,7 +97,9 @@ function ManageDownloadClientsEditModalContent(
setRemoveFailedDownloads(value);
break;
default:
console.warn('EditDownloadClientsModalContent Unknown Input');
console.warn(
`EditDownloadClientsModalContent Unknown Input: '${name}'`
);
}
},
[]
@@ -162,15 +164,13 @@ function ManageDownloadClientsEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} download clients selected', {
count: selectedCount,
})}
{translate('CountDownloadClientsSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('Apply Changes')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -20,9 +21,11 @@ import {
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal';
import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow';
import TagsModal from './Tags/TagsModal';
import styles from './ManageDownloadClientsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
@@ -33,37 +36,43 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: 'Name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'enable',
label: 'Enabled',
label: translate('Enabled'),
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: 'Priority',
label: translate('Priority'),
isSortable: true,
isVisible: true,
},
{
name: 'removeCompletedDownloads',
label: 'Remove Completed',
label: translate('RemoveCompleted'),
isSortable: true,
isVisible: true,
},
{
name: 'removeFailedDownloads',
label: 'Remove Failed',
label: translate('RemoveFailed'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
isSortable: true,
isVisible: true,
},
@@ -92,6 +101,8 @@ function ManageDownloadClientsModalContent(
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, setSelectState] = useSelectState();
@@ -138,6 +149,30 @@ function ManageDownloadClientsModalContent(
[selectedIds, dispatch]
);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
bulkEditDownloadClients({
ids: selectedIds,
tags,
applyTags,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
@@ -158,17 +193,24 @@ function ManageDownloadClientsModalContent(
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load import lists.');
const errorMessage = getErrorMessage(
error,
'Unable to load download clients.'
);
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Manage Import Lists</ModalHeader>
<ModalHeader>{translate('ManageDownloadClients')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert>
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
@@ -203,7 +245,7 @@ function ManageDownloadClientsModalContent(
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
@@ -211,11 +253,19 @@ function ManageDownloadClientsModalContent(
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected}
onPress={onTagsPress}
>
{translate('SetTags')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>Close</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageDownloadClientsEditModal
@@ -225,12 +275,21 @@ function ManageDownloadClientsModalContent(
downloadClientIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Download Clients(s)"
message={`Are you sure you want to delete ${selectedIds.length} download clients(s)?`}
confirmLabel="Delete"
title={translate('DeleteSelectedDownloadClients')}
message={translate('DeleteSelectedDownloadClientsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>

View File

@@ -1,9 +1,13 @@
import React, { useCallback } from 'react';
import Label from 'Components/Label';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import TagListConnector from 'Components/TagListConnector';
import { kinds } from 'Helpers/Props';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import styles from './ManageDownloadClientsModalRow.css';
interface ManageDownloadClientsModalRowProps {
@@ -14,6 +18,7 @@ interface ManageDownloadClientsModalRowProps {
removeCompletedDownloads: boolean;
removeFailedDownloads: boolean;
implementation: string;
tags: number[];
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
@@ -31,6 +36,7 @@ function ManageDownloadClientsModalRow(
removeCompletedDownloads,
removeFailedDownloads,
implementation,
tags,
onSelectedChange,
} = props;
@@ -58,17 +64,23 @@ function ManageDownloadClientsModalRow(
</TableRowCell>
<TableRowCell className={styles.enable}>
{enable ? 'Yes' : 'No'}
<Label kind={enable ? kinds.SUCCESS : kinds.DISABLED} outline={!enable}>
{enable ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
<TableRowCell className={styles.removeCompletedDownloads}>
{removeCompletedDownloads ? 'Yes' : 'No'}
{removeCompletedDownloads ? translate('Yes') : translate('No')}
</TableRowCell>
<TableRowCell className={styles.removeFailedDownloads}>
{removeFailedDownloads ? 'Yes' : 'No'}
{removeFailedDownloads ? translate('Yes') : translate('No')}
</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContent from './TagsModalContent';
interface TagsModalProps {
isOpen: boolean;
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModal(props: TagsModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<TagsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagsModal;

View File

@@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}

View File

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

View File

@@ -0,0 +1,185 @@
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
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 { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import DownloadClient from 'typings/DownloadClient';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModalContent(props: TagsModalContentProps) {
const { ids, onModalClose, onApplyTagsPress } = props;
const allDownloadClients: DownloadClientAppState = useSelector(
(state: AppState) => state.settings.downloadClients
);
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const downloadClientsTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allDownloadClients.items.find(
(s: DownloadClient) => s.id === id
);
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [ids, allDownloadClients]);
const onTagsChange = useCallback(
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
);
const onApplyPress = useCallback(() => {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={onTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
translate('ApplyTagsHelpTextHowToApplyDownloadClients'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{downloadClientsTags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
const removeTag =
(applyTags === 'remove' && tags.indexOf(id) > -1) ||
(applyTags === 'replace' && tags.indexOf(id) === -1);
return (
<Label
key={tag.id}
title={
removeTag
? translate('RemovingTag')
: translate('ExistingTag')
}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
{(applyTags === 'add' || applyTags === 'replace') &&
tags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
if (downloadClientsTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

View File

@@ -86,7 +86,7 @@ class ImportListSettings extends Component {
/>
<PageToolbarButton
label="Manage Lists"
label={translate('ManageLists')}
iconName={icons.MANAGE}
onPress={this.onManageImportListsPress}
/>

View File

@@ -3,3 +3,9 @@
margin-right: auto;
}
.message {
composes: alert from '~Components/Alert.css';
margin-bottom: 30px;
}

View File

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

View File

@@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
import translate from 'Utilities/String/translate';
import styles from './EditImportListModalContent.css';
@@ -42,6 +43,7 @@ function EditImportListModalContent(props) {
name,
enabled,
enableAuto,
minRefreshInterval,
monitor,
minimumAvailability,
qualityProfileId,
@@ -85,6 +87,14 @@ function EditImportListModalContent(props) {
{message.value.message}
</Alert>
}
<Alert
kind={kinds.INFO}
className={styles.message}
>
{translate('ListWillRefreshEveryInterp', [formatShortTimeSpan(minRefreshInterval.value)])}
</Alert>
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>

View File

@@ -4,6 +4,7 @@ import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { kinds } from 'Helpers/Props';
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
import translate from 'Utilities/String/translate';
import EditImportListModalConnector from './EditImportListModalConnector';
import styles from './ImportList.css';
@@ -56,7 +57,8 @@ class ImportList extends Component {
id,
name,
enabled,
enableAuto
enableAuto,
minRefreshInterval
} = this.props;
return (
@@ -96,6 +98,12 @@ class ImportList extends Component {
}
</div>
<div className={styles.enabled}>
<Label kind={kinds.INFO} title='List Refresh Interval'>
{`${translate('Refresh')}: ${formatShortTimeSpan(minRefreshInterval)}`}
</Label>
</div>
<EditImportListModalConnector
id={id}
isOpen={this.state.isEditImportListModalOpen}
@@ -122,6 +130,7 @@ ImportList.propTypes = {
name: PropTypes.string.isRequired,
enabled: PropTypes.bool.isRequired,
enableAuto: PropTypes.bool.isRequired,
minRefreshInterval: PropTypes.string.isRequired,
onConfirmDeleteImportList: PropTypes.func.isRequired
};

View File

@@ -26,9 +26,9 @@ interface ManageImportListsEditModalContentProps {
const NO_CHANGE = 'noChange';
const autoAddOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'enabled', value: 'Enabled' },
{ key: 'disabled', value: 'Disabled' },
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageImportListsEditModalContent(
@@ -36,7 +36,7 @@ function ManageImportListsEditModalContent(
) {
const { importListIds, onSavePress, onModalClose } = props;
const [enableAuto, setenableAuto] = useState(NO_CHANGE);
const [enableAuto, setEnableAuto] = useState(NO_CHANGE);
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
NO_CHANGE
);
@@ -72,7 +72,7 @@ function ManageImportListsEditModalContent(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enableAuto':
setenableAuto(value);
setEnableAuto(value);
break;
case 'qualityProfileId':
setQualityProfileId(value);
@@ -81,7 +81,7 @@ function ManageImportListsEditModalContent(
setRootFolderPath(value);
break;
default:
console.warn('EditImportListModalContent Unknown Input');
console.warn(`EditImportListModalContent Unknown Input: '${name}'`);
}
},
[]
@@ -136,7 +136,7 @@ function ManageImportListsEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} import lists selected', { count: selectedCount })}
{translate('CountImportListsSelected', [selectedCount])}
</div>
<div>

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ImportListAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -20,6 +21,7 @@ import {
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageImportListsEditModal from './Edit/ManageImportListsEditModal';
import ManageImportListsModalRow from './ManageImportListsModalRow';
@@ -34,37 +36,37 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: 'Name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityProfileId',
label: 'Quality Profile',
label: translate('QualityProfile'),
isSortable: true,
isVisible: true,
},
{
name: 'rootFolderPath',
label: 'Root Folder',
label: translate('RootFolder'),
isSortable: true,
isVisible: true,
},
{
name: 'enableAuto',
label: 'Auto Add',
label: translate('AutomaticAdd'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
@@ -190,12 +192,16 @@ function ManageImportListsModalContent(
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Manage Import Lists</ModalHeader>
<ModalHeader>{translate('ManageImportLists')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoImportListsFound')}</Alert>
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
@@ -230,7 +236,7 @@ function ManageImportListsModalContent(
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
@@ -238,7 +244,7 @@ function ManageImportListsModalContent(
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
@@ -246,11 +252,11 @@ function ManageImportListsModalContent(
isDisabled={!anySelected}
onPress={onTagsPress}
>
Set Tags
{translate('SetTags')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>Close</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageImportListsEditModal
@@ -270,9 +276,11 @@ function ManageImportListsModalContent(
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Import List(s)"
message={`Are you sure you want to delete ${selectedIds.length} import list(s)?`}
confirmLabel="Delete"
title={translate('DeleteSelectedImportLists')}
message={translate('DeleteSelectedImportListsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>

View File

@@ -17,6 +17,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import ImportList from 'typings/ImportList';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
@@ -36,7 +37,7 @@ function TagsModalContent(props: TagsModalContentProps) {
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const seriesTags = useMemo(() => {
const importListsTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allImportLists.items.find((s: ImportList) => s.id === id);
@@ -69,19 +70,19 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Tags</ModalHeader>
<ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
@@ -92,7 +93,7 @@ function TagsModalContent(props: TagsModalContentProps) {
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@@ -100,20 +101,20 @@ function TagsModalContent(props: TagsModalContentProps) {
value={applyTags}
values={applyTagsOptions}
helpTexts={[
'How to apply tags to the selected list',
'Add: Add the tags the existing list of tags',
'Remove: Remove the entered tags',
'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)',
translate('ApplyTagsHelpTextHowToApplyImportLists'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Result</FormLabel>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{seriesTags.map((id) => {
{importListsTags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
@@ -127,7 +128,11 @@ function TagsModalContent(props: TagsModalContentProps) {
return (
<Label
key={tag.id}
title={removeTag ? 'Removing tag' : 'Existing tag'}
title={
removeTag
? translate('RemovingTag')
: translate('ExistingTag')
}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
@@ -144,14 +149,14 @@ function TagsModalContent(props: TagsModalContentProps) {
return null;
}
if (seriesTags.indexOf(id) > -1) {
if (importListsTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={'Adding tag'}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
@@ -165,10 +170,10 @@ function TagsModalContent(props: TagsModalContentProps) {
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
Apply
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -46,23 +46,6 @@ function ImportListOptions(props) {
{
hasSettings && !isFetching && !error &&
<Form>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('ListUpdateInterval')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="importListSyncInterval"
min={6}
unit="hours"
helpText={translate('ImportListSyncIntervalHelpText')}
onChange={onInputChange}
{...settings.importListSyncInterval}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}

View File

@@ -86,7 +86,7 @@ class IndexerSettings extends Component {
/>
<PageToolbarButton
label="Manage Indexers"
label={translate('ManageIndexers')}
iconName={icons.MANAGE}
onPress={this.onManageIndexersPress}
/>

View File

@@ -27,9 +27,9 @@ interface ManageIndexersEditModalContentProps {
const NO_CHANGE = 'noChange';
const enableOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'enabled', value: 'Enabled' },
{ key: 'disabled', value: 'Disabled' },
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageIndexersEditModalContent(
@@ -97,7 +97,7 @@ function ManageIndexersEditModalContent(
setPriority(value);
break;
default:
console.warn('EditIndexersModalContent Unknown Input');
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
}
},
[]
@@ -111,7 +111,7 @@ function ManageIndexersEditModalContent(
<ModalBody>
<FormGroup>
<FormLabel>{translate('EnableRss')}</FormLabel>
<FormLabel>{translate('EnableRSS')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@@ -162,13 +162,13 @@ function ManageIndexersEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} indexers selected', { count: selectedCount })}
{translate('CountIndexersSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('Apply Changes')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IndexerAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -20,6 +21,7 @@ import {
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageIndexersEditModal from './Edit/ManageIndexersEditModal';
import ManageIndexersModalRow from './ManageIndexersModalRow';
@@ -34,43 +36,43 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: 'Name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'enableRss',
label: 'Enable RSS',
label: translate('EnableRSS'),
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticSearch',
label: 'Enable Automatic Search',
label: translate('EnableAutomaticSearch'),
isSortable: true,
isVisible: true,
},
{
name: 'enableInteractiveSearch',
label: 'Enable Interactive Search',
label: translate('EnableInteractiveSearch'),
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: 'Priority',
label: translate('Priority'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
@@ -189,17 +191,21 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load import lists.');
const errorMessage = getErrorMessage(error, 'Unable to load indexers.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Manage Import Lists</ModalHeader>
<ModalHeader>{translate('ManageIndexers')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert>
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
@@ -234,7 +240,7 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
@@ -242,7 +248,7 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
@@ -250,11 +256,11 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isDisabled={!anySelected}
onPress={onTagsPress}
>
Set Tags
{translate('SetTags')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>Close</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageIndexersEditModal
@@ -274,9 +280,11 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Import List(s)"
message={`Are you sure you want to delete ${selectedIds.length} import list(s)?`}
confirmLabel="Delete"
title={translate('DeleteSelectedIndexers')}
message={translate('DeleteSelectedIndexersMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>

View File

@@ -1,10 +1,13 @@
import React, { useCallback } from 'react';
import Label from 'Components/Label';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import TagListConnector from 'Components/TagListConnector';
import { kinds } from 'Helpers/Props';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import styles from './ManageIndexersModalRow.css';
interface ManageIndexersModalRowProps {
@@ -59,15 +62,30 @@ function ManageIndexersModalRow(props: ManageIndexersModalRowProps) {
</TableRowCell>
<TableRowCell className={styles.enableRss}>
{enableRss ? 'Yes' : 'No'}
<Label
kind={enableRss ? kinds.SUCCESS : kinds.DISABLED}
outline={!enableRss}
>
{enableRss ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.enableAutomaticSearch}>
{enableAutomaticSearch ? 'Yes' : 'No'}
<Label
kind={enableAutomaticSearch ? kinds.SUCCESS : kinds.DISABLED}
outline={!enableAutomaticSearch}
>
{enableAutomaticSearch ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.enableInteractiveSearch}>
{enableInteractiveSearch ? 'Yes' : 'No'}
<Label
kind={enableInteractiveSearch ? kinds.SUCCESS : kinds.DISABLED}
outline={!enableInteractiveSearch}
>
{enableInteractiveSearch ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.priority}>{priority}</TableRowCell>

View File

@@ -17,6 +17,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import Indexer from 'typings/Indexer';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
@@ -36,7 +37,7 @@ function TagsModalContent(props: TagsModalContentProps) {
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const seriesTags = useMemo(() => {
const indexersTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allIndexers.items.find((s: Indexer) => s.id === id);
@@ -69,19 +70,19 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Tags</ModalHeader>
<ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
@@ -92,7 +93,7 @@ function TagsModalContent(props: TagsModalContentProps) {
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@@ -100,20 +101,20 @@ function TagsModalContent(props: TagsModalContentProps) {
value={applyTags}
values={applyTagsOptions}
helpTexts={[
'How to apply tags to the selected indexer(s)',
'Add: Add the tags the existing list of tags',
'Remove: Remove the entered tags',
'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)',
translate('ApplyTagsHelpTextHowToApplyIndexers'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Result</FormLabel>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{seriesTags.map((id) => {
{indexersTags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
@@ -127,7 +128,11 @@ function TagsModalContent(props: TagsModalContentProps) {
return (
<Label
key={tag.id}
title={removeTag ? 'Removing tag' : 'Existing tag'}
title={
removeTag
? translate('RemovingTag')
: translate('ExistingTag')
}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
@@ -144,14 +149,14 @@ function TagsModalContent(props: TagsModalContentProps) {
return null;
}
if (seriesTags.indexOf(id) > -1) {
if (indexersTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={'Adding tag'}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
@@ -165,10 +170,10 @@ function TagsModalContent(props: TagsModalContentProps) {
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
Apply
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -23,6 +23,7 @@ function TagDetailsModalContent(props) {
restrictions,
importLists,
indexers,
downloadClients,
onModalClose,
onDeleteTagPress
} = props;
@@ -167,7 +168,7 @@ function TagDetailsModalContent(props) {
}
{
!!importLists.length &&
importLists.length ?
<FieldSet legend={translate('Lists')}>
{
importLists.map((item) => {
@@ -178,7 +179,24 @@ function TagDetailsModalContent(props) {
);
})
}
</FieldSet>
</FieldSet> :
null
}
{
downloadClients.length ?
<FieldSet legend={translate('DownloadClients')}>
{
downloadClients.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
</ModalBody>
@@ -214,6 +232,7 @@ TagDetailsModalContent.propTypes = {
restrictions: PropTypes.arrayOf(PropTypes.object).isRequired,
importLists: PropTypes.arrayOf(PropTypes.object).isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteTagPress: PropTypes.func.isRequired
};

View File

@@ -77,6 +77,14 @@ function createMatchingIndexersSelector() {
);
}
function createMatchingDownloadClientsSelector() {
return createSelector(
(state, { downloadClientIds }) => downloadClientIds,
(state) => state.settings.downloadClients.items,
findMatchingItems
);
}
function createMapStateToProps() {
return createSelector(
createMatchingMoviesSelector(),
@@ -85,14 +93,16 @@ function createMapStateToProps() {
createMatchingRestrictionsSelector(),
createMatchingImportListsSelector(),
createMatchingIndexersSelector(),
(movies, delayProfiles, notifications, restrictions, importLists, indexers) => {
createMatchingDownloadClientsSelector(),
(movies, delayProfiles, notifications, restrictions, importLists, indexers, downloadClients) => {
return {
movies,
delayProfiles,
notifications,
restrictions,
importLists,
indexers
indexers,
downloadClients
};
}
);

View File

@@ -58,7 +58,8 @@ class Tag extends Component {
restrictionIds,
importListIds,
movieIds,
indexerIds
indexerIds,
downloadClientIds
} = this.props;
const {
@@ -72,7 +73,8 @@ class Tag extends Component {
restrictionIds.length ||
importListIds.length ||
movieIds.length ||
indexerIds.length
indexerIds.length ||
downloadClientIds.length
);
return (
@@ -130,6 +132,14 @@ class Tag extends Component {
</div> :
null
}
{
downloadClientIds.length ?
<div>
{downloadClientIds.length} download client{indexerIds.length > 1 && 's'}
</div> :
null
}
</div>
}
@@ -149,6 +159,7 @@ class Tag extends Component {
restrictionIds={restrictionIds}
importListIds={importListIds}
indexerIds={indexerIds}
downloadClientIds={downloadClientIds}
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
onDeleteTagPress={this.onDeleteTagPress}
@@ -177,6 +188,7 @@ Tag.propTypes = {
importListIds: PropTypes.arrayOf(PropTypes.number).isRequired,
movieIds: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onConfirmDeleteTag: PropTypes.func.isRequired
};
@@ -186,7 +198,8 @@ Tag.defaultProps = {
restrictionIds: [],
importListIds: [],
movieIds: [],
indexerIds: []
indexerIds: [],
downloadClientIds: []
};
export default Tag;

View File

@@ -1,7 +1,9 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import TagConnector from './TagConnector';
import styles from './Tags.css';
@@ -14,9 +16,9 @@ function Tags(props) {
if (!items.length) {
return (
<div>
<Alert kind={kinds.INFO}>
{translate('NoTagsHaveBeenAddedYet')}
</div>
</Alert>
);
}

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDelayProfiles, fetchImportLists, fetchIndexers, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions';
import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, fetchNotifications, fetchRestrictions } from 'Store/Actions/settingsActions';
import { fetchTagDetails } from 'Store/Actions/tagActions';
import Tags from './Tags';
@@ -30,7 +30,8 @@ const mapDispatchToProps = {
dispatchFetchNotifications: fetchNotifications,
dispatchFetchRestrictions: fetchRestrictions,
dispatchFetchImportLists: fetchImportLists,
dispatchFetchIndexers: fetchIndexers
dispatchFetchIndexers: fetchIndexers,
dispatchFetchDownloadClients: fetchDownloadClients
};
class MetadatasConnector extends Component {
@@ -45,7 +46,8 @@ class MetadatasConnector extends Component {
dispatchFetchNotifications,
dispatchFetchRestrictions,
dispatchFetchImportLists,
dispatchFetchIndexers
dispatchFetchIndexers,
dispatchFetchDownloadClients
} = this.props;
dispatchFetchTagDetails();
@@ -54,6 +56,7 @@ class MetadatasConnector extends Component {
dispatchFetchRestrictions();
dispatchFetchImportLists();
dispatchFetchIndexers();
dispatchFetchDownloadClients();
}
//
@@ -74,7 +77,8 @@ MetadatasConnector.propTypes = {
dispatchFetchNotifications: PropTypes.func.isRequired,
dispatchFetchRestrictions: PropTypes.func.isRequired,
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchIndexers: PropTypes.func.isRequired
dispatchFetchIndexers: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);

View File

@@ -120,6 +120,7 @@ export default {
[SELECT_IMPORT_LIST_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
selectedSchema.minimumAvailability = 'released';
return selectedSchema;
});

View File

@@ -131,8 +131,7 @@ export const actionHandlers = handleThunks({
promise.done((data) => {
const updatedItem = _.cloneDeep(data);
updatedItem.id = updatedItem.tmdbId;
dispatch(batchActions([
const actions = [
updateItem({ section: 'movies', ...data }),
updateItem({ section: 'addMovie', ...updatedItem }),
@@ -142,7 +141,21 @@ export const actionHandlers = handleThunks({
isAdded: true,
addError: null
})
]));
];
if (!newMovie.collection) {
dispatch(batchActions(actions));
return;
}
const collectionToUpdate = getState().movieCollections.items.find((collection) => collection.tmdbId === newMovie.collection.tmdbId);
if (collectionToUpdate) {
const collectionData = { ...collectionToUpdate, missingMovies: Math.max(0, collectionToUpdate.missingMovies - 1 ) };
actions.push(updateItem({ section: 'movieCollections', ...collectionData }));
}
dispatch(batchActions(actions));
});
promise.fail((xhr) => {

View File

@@ -148,6 +148,12 @@ export const defaultState = {
isSortable: true,
isVisible: false
},
{
name: 'popularity',
label: translate('Popularity'),
isSortable: true,
isVisible: false
},
{
name: 'certification',
label: translate('Certification'),
@@ -380,6 +386,11 @@ export const defaultState = {
label: translate('ImdbVotes'),
type: filterBuilderTypes.NUMBER
},
{
name: 'popularity',
label: translate('Popularity'),
type: filterBuilderTypes.NUMBER
},
{
name: 'certification',
label: 'Certification',

View File

@@ -347,7 +347,27 @@ export const actionHandlers = handleThunks({
[FETCH_MOVIES]: createFetchHandler(section, '/movie'),
[SAVE_MOVIE]: createSaveProviderHandler(section, '/movie', { getAjaxOptions: getSaveAjaxOptions }),
[DELETE_MOVIE]: createRemoveItemHandler(section, '/movie'),
[DELETE_MOVIE]: (getState, payload, dispatch) => {
createRemoveItemHandler(section, '/movie')(getState, payload, dispatch);
if (!payload.collectionTmdbId) {
return;
}
const collectionToUpdate = getState().movieCollections.items.find((collection) => collection.tmdbId === payload.collectionTmdbId);
// Skip updating if the last movie in the collection is being deleted
if (collectionToUpdate.movies.length - collectionToUpdate.missingMovies === 1) {
return;
}
const collectionData = { ...collectionToUpdate, missingMovies: collectionToUpdate.missingMovies + 1 };
dispatch(updateItem({
section: 'movieCollections',
...collectionData
}));
},
[TOGGLE_MOVIE_MONITORED]: (getState, payload, dispatch) => {
const {

View File

@@ -1,7 +1,7 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, sortDirections } from 'Helpers/Props';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import sortByName from 'Utilities/Array/sortByName';
import createAjaxRequest from 'Utilities/createAjaxRequest';
@@ -60,8 +60,30 @@ export const defaultState = {
filters: [
{
key: 'all',
label: 'All',
label: translate('All'),
filters: []
},
{
key: 'missing',
label: translate('Missing'),
filters: [
{
key: 'missingMovies',
value: 0,
type: filterTypes.GREATER_THAN
}
]
},
{
key: 'complete',
label: translate('Complete'),
filters: [
{
key: 'missingMovies',
value: 0,
type: filterTypes.EQUAL
}
]
}
],
@@ -250,6 +272,9 @@ export const actionHandlers = handleThunks({
}).request;
promise.done((data) => {
const collectionToUpdate = getState().movieCollections.items.find((collection) => collection.tmdbId === data.collection.tmdbId);
const collectionData = { ...collectionToUpdate, missingMovies: Math.max(0, collectionToUpdate.missingMovies - 1 ) };
dispatch(batchActions([
updateItem({ section: 'movies', ...data }),
@@ -258,7 +283,9 @@ export const actionHandlers = handleThunks({
isAdding: false,
isAdded: true,
addError: null
})
}),
updateItem({ section, ...collectionData })
]));
});

View File

@@ -191,6 +191,12 @@ export const defaultState = {
isSortable: true,
isVisible: false
},
{
name: 'popularity',
label: translate('Popularity'),
isSortable: true,
isVisible: false
},
{
name: 'certification',
label: translate('Certification'),
@@ -443,6 +449,11 @@ export const defaultState = {
label: translate('ImdbVotes'),
type: filterBuilderTypes.NUMBER
},
{
name: 'popularity',
label: translate('Popularity'),
type: filterBuilderTypes.NUMBER
},
{
name: 'certification',
label: translate('Certification'),

View File

@@ -1,8 +1,10 @@
import _ from 'lodash';
import moment from 'moment';
import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { sortDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import { icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@@ -88,6 +90,15 @@ export const defaultState = {
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
columnLabel: translate( 'CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: translate( 'CustomFormatScore')
}),
isVisible: false
},
{
name: 'protocol',
label: translate('Protocol'),

View File

@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
@@ -11,7 +12,7 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import { align, icons } from 'Helpers/Props';
import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import LogsTableRow from './LogsTableRow';
@@ -82,9 +83,9 @@ function LogsTable(props) {
{
isPopulated && !error && !items.length &&
<div>
<Alert kind={kinds.INFO}>
{translate('NoEventsFound')}
</div>
</Alert>
}
{

View File

@@ -11,7 +11,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons } from 'Helpers/Props';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import LogsNavMenu from '../LogsNavMenu';
import LogFilesTableRow from './LogFilesTableRow';
@@ -118,9 +118,9 @@ class LogFiles extends Component {
{
!isFetching && !items.length &&
<div>
<Alert kind={kinds.INFO}>
{translate('NoLogFiles')}
</div>
</Alert>
}
</PageContentBody>
</PageContent>

View File

@@ -1,6 +1,7 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import SpinnerButton from 'Components/Link/SpinnerButton';
@@ -61,9 +62,9 @@ class Updates extends Component {
{
noUpdates &&
<div>
<Alert kind={kinds.INFO}>
{translate('NoUpdatesAreAvailable')}
</div>
</Alert>
}
{

View File

@@ -0,0 +1,25 @@
import moment from 'moment';
function formatShortTimeSpan(timeSpan) {
if (!timeSpan) {
return '';
}
const duration = moment.duration(timeSpan);
const hours = Math.floor(duration.asHours());
const minutes = Math.floor(duration.asMinutes());
const seconds = Math.floor(duration.asSeconds());
if (hours > 0) {
return `${hours} hour(s)`;
}
if (minutes > 0) {
return `${minutes} minute(s)`;
}
return `${seconds} second(s)`;
}
export default formatShortTimeSpan;

View File

@@ -0,0 +1,16 @@
function formatPreferredWordScore(input, customFormatsLength = 0) {
const score = Number(input);
if (score > 0) {
return `+${score}`;
}
if (score < 0) {
return score;
}
return customFormatsLength > 0 ? '+0' : '';
}
export default formatPreferredWordScore;

View File

@@ -6,8 +6,8 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- Chrome, Opera, and Firefox OS -->
<meta name="theme-color" content="#3a3f51" />
<!-- Chrome, Safari, Opera, and Firefox OS -->
<meta name="theme-color" content="#595959" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#3a3f51" />

View File

@@ -6,8 +6,8 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- Chrome, Opera, and Firefox OS -->
<meta name="theme-color" content="#464b51" />
<!-- Chrome, Safari, Opera, and Firefox OS -->
<meta name="theme-color" content="#595959" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#464b51" />

View File

@@ -158,6 +158,25 @@ namespace NzbDrone.Common.Test
path.GetParentPath().Should().Be(parentPath);
}
[TestCase(@"C:\Test\mydir", "Test")]
[TestCase(@"C:\Test\", @"C:\")]
[TestCase(@"C:\", null)]
[TestCase(@"\\server\share", null)]
[TestCase(@"\\server\share\test", @"\\server\share")]
public void path_should_return_parent_name_windows(string path, string parentPath)
{
WindowsOnly();
path.GetParentName().Should().Be(parentPath);
}
[TestCase(@"/", null)]
[TestCase(@"/test", "/")]
public void path_should_return_parent_name_mono(string path, string parentPath)
{
PosixOnly();
path.GetParentName().Should().Be(parentPath);
}
[Test]
public void path_should_return_parent_for_oversized_path()
{

View File

@@ -87,9 +87,7 @@ namespace NzbDrone.Common.Extensions
public static string GetParentPath(this string childPath)
{
var cleanPath = OsInfo.IsWindows
? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "")
: childPath.TrimEnd(Path.DirectorySeparatorChar);
var cleanPath = childPath.GetCleanPath();
if (cleanPath.IsNullOrWhiteSpace())
{
@@ -99,6 +97,18 @@ namespace NzbDrone.Common.Extensions
return Directory.GetParent(cleanPath)?.FullName;
}
public static string GetParentName(this string childPath)
{
var cleanPath = childPath.GetCleanPath();
if (cleanPath.IsNullOrWhiteSpace())
{
return null;
}
return Directory.GetParent(cleanPath)?.Name;
}
public static string GetCleanPath(this string path)
{
var cleanPath = OsInfo.IsWindows

View File

@@ -73,7 +73,7 @@ namespace NzbDrone.Common.Instrumentation
else
{
dsn = RuntimeInfo.IsProduction
? "https://26668106d708406b9ddf5a2bda34fcbb@sentry.servarr.com/9"
? "https://40f1288c1b4d495cbafdb5c89f7f01be@sentry.servarr.com/9"
: "https://998b4673d4c849ccb5277b5966ed5bc2@sentry.servarr.com/10";
}

View File

@@ -106,7 +106,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
o.Dsn = dsn;
o.AttachStacktrace = true;
o.MaxBreadcrumbs = 200;
o.Release = BuildInfo.Release;
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
o.Environment = BuildInfo.Branch;

View File

@@ -121,6 +121,11 @@ namespace NzbDrone.Common.Serializer
return JsonConvert.SerializeObject(obj, SerializerSettings);
}
public static string ToJson(this object obj, Formatting formatting)
{
return JsonConvert.SerializeObject(obj, formatting, SerializerSettings);
}
public static void Serialize<TModel>(TModel model, TextWriter outputStream)
{
var jsonTextWriter = new JsonTextWriter(outputStream);

View File

@@ -0,0 +1,64 @@
using System;
using System.Linq;
using Dapper;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class remove_invalid_roksbox_metadata_imagesFixture : MigrationTest<remove_invalid_roksbox_metadata_images>
{
[Test]
public void should_remove_incorrect_roksbox_metadata_images()
{
var db = WithDapperMigrationTestDb(c =>
{
c.Insert.IntoTable("MetadataFiles").Row(new
{
MovieId = 1,
Consumer = "RoksboxMetadata",
Type = 5,
RelativePath = @"metadata\Movie Title (2023).jpg",
LastUpdated = "2023-01-21 00:00:00.000",
Added = "2023-01-21 00:00:00.000",
MovieFileId = 1,
Extension = ".jpg"
});
c.Insert.IntoTable("MetadataFiles").Row(new
{
MovieId = 1,
Consumer = "RoksboxMetadata",
Type = 5,
RelativePath = @"Movie Title (2023).jpg",
LastUpdated = "2023-01-21 00:00:00.000",
MovieFileId = 1,
Added = "2023-01-21 00:00:00.000",
Extension = ".jpg"
});
});
var metadataFiles = db.Query<MetadataFile223>("SELECT * FROM \"MetadataFiles\"");
metadataFiles.Should().HaveCount(1);
metadataFiles.First().RelativePath.Should().NotContain("metadata");
}
}
public class MetadataFile223
{
public int Id { get; set; }
public int MovieId { get; set; }
public int? MovieFileId { get; set; }
public string RelativePath { get; set; }
public DateTime Added { get; set; }
public DateTime LastUpdated { get; set; }
public string Extension { get; set; }
public string Hash { get; set; }
public string Consumer { get; set; }
public int Type { get; set; }
}
}

View File

@@ -34,7 +34,7 @@ namespace NzbDrone.Core.Test.Download
.Returns(_blockedProviders);
}
private Mock<IDownloadClient> WithUsenetClient(int priority = 0)
private Mock<IDownloadClient> WithUsenetClient(int priority = 0, HashSet<int> tags = null)
{
var mock = new Mock<IDownloadClient>(MockBehavior.Default);
mock.SetupGet(s => s.Definition)
@@ -42,6 +42,7 @@ namespace NzbDrone.Core.Test.Download
.CreateNew()
.With(v => v.Id = _nextId++)
.With(v => v.Priority = priority)
.With(v => v.Tags = tags ?? new HashSet<int>())
.Build());
_downloadClients.Add(mock.Object);
@@ -51,7 +52,7 @@ namespace NzbDrone.Core.Test.Download
return mock;
}
private Mock<IDownloadClient> WithTorrentClient(int priority = 0)
private Mock<IDownloadClient> WithTorrentClient(int priority = 0, HashSet<int> tags = null)
{
var mock = new Mock<IDownloadClient>(MockBehavior.Default);
mock.SetupGet(s => s.Definition)
@@ -59,6 +60,7 @@ namespace NzbDrone.Core.Test.Download
.CreateNew()
.With(v => v.Id = _nextId++)
.With(v => v.Priority = priority)
.With(v => v.Tags = tags ?? new HashSet<int>())
.Build());
_downloadClients.Add(mock.Object);
@@ -148,6 +150,69 @@ namespace NzbDrone.Core.Test.Download
client4.Definition.Id.Should().Be(2);
}
[Test]
public void should_roundrobin_over_clients_with_matching_tags()
{
var seriesTags = new HashSet<int> { 1 };
var clientTags = new HashSet<int> { 1 };
WithTorrentClient();
WithTorrentClient(0, clientTags);
WithTorrentClient();
WithTorrentClient(0, clientTags);
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
client1.Definition.Id.Should().Be(2);
client2.Definition.Id.Should().Be(4);
client3.Definition.Id.Should().Be(2);
client4.Definition.Id.Should().Be(4);
}
[Test]
public void should_roundrobin_over_non_tagged_when_no_matching_tags()
{
var seriesTags = new HashSet<int> { 2 };
var clientTags = new HashSet<int> { 1 };
WithTorrentClient();
WithTorrentClient(0, clientTags);
WithTorrentClient();
WithTorrentClient(0, clientTags);
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
client1.Definition.Id.Should().Be(1);
client2.Definition.Id.Should().Be(3);
client3.Definition.Id.Should().Be(1);
client4.Definition.Id.Should().Be(3);
}
[Test]
public void should_fail_to_choose_when_clients_have_tags_but_no_match()
{
var seriesTags = new HashSet<int> { 2 };
var clientTags = new HashSet<int> { 1 };
WithTorrentClient(0, clientTags);
WithTorrentClient(0, clientTags);
WithTorrentClient(0, clientTags);
WithTorrentClient(0, clientTags);
var client1 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags);
Subject.GetDownloadClient(DownloadProtocol.Torrent, 0, false, seriesTags).Should().BeNull();
}
[Test]
public void should_skip_blocked_torrent_client()
{
@@ -162,7 +227,6 @@ namespace NzbDrone.Core.Test.Download
var client2 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client3 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client4 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
var client5 = Subject.GetDownloadClient(DownloadProtocol.Torrent);
client1.Definition.Id.Should().Be(2);
client2.Definition.Id.Should().Be(4);

View File

@@ -31,8 +31,8 @@ namespace NzbDrone.Core.Test.Download
.Returns(_downloadClients);
Mocker.GetMock<IProvideDownloadClient>()
.Setup(v => v.GetDownloadClient(It.IsAny<DownloadProtocol>(), It.IsAny<int>(), It.IsAny<bool>()))
.Returns<DownloadProtocol, int, bool>((v, i, f) => _downloadClients.FirstOrDefault(d => d.Protocol == v));
.Setup(v => v.GetDownloadClient(It.IsAny<DownloadProtocol>(), It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<HashSet<int>>()))
.Returns<DownloadProtocol, int, bool, HashSet<int>>((v, i, f, t) => _downloadClients.FirstOrDefault(d => d.Protocol == v));
var releaseInfo = Builder<ReleaseInfo>.CreateNew()
.With(v => v.DownloadProtocol = DownloadProtocol.Usenet)

View File

@@ -20,25 +20,25 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
public class DeleteBadMediaCoversFixture : CoreTest<DeleteBadMediaCovers>
{
private List<MetadataFile> _metadata;
private List<Movie> _movies;
private Dictionary<int, string> _movies;
[SetUp]
public void Setup()
{
_movies = Builder<Movie>.CreateListOfSize(1)
.All()
.With(c => c.Path = "C:\\Movie\\".AsOsAgnostic())
.Build().ToList();
_movies = new Dictionary<int, string>
{
{ 1, "C:\\Movie\\".AsOsAgnostic() }
};
_metadata = Builder<MetadataFile>.CreateListOfSize(1)
.Build().ToList();
Mocker.GetMock<IMovieService>()
.Setup(c => c.GetAllMovies())
.Setup(c => c.AllMoviePaths())
.Returns(_movies);
Mocker.GetMock<IMetadataFileService>()
.Setup(c => c.GetFilesByMovie(_movies.First().Id))
.Setup(c => c.GetFilesByMovie(_movies.First().Key))
.Returns(_metadata);
Mocker.GetMock<IConfigService>().SetupGet(c => c.CleanupMetadataImages).Returns(true);
@@ -73,7 +73,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
Subject.Clean();
Mocker.GetMock<IConfigService>().VerifySet(c => c.CleanupMetadataImages = true, Times.Never());
Mocker.GetMock<IMovieService>().Verify(c => c.GetAllMovies(), Times.Never());
Mocker.GetMock<IMovieService>().Verify(c => c.AllMoviePaths(), Times.Never());
AssertImageWasNotRemoved();
}

View File

@@ -27,7 +27,7 @@ namespace NzbDrone.Core.Test.ImportListTests
_blockedLists = new List<ImportListStatus>();
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.Enabled())
.Setup(v => v.Enabled(It.IsAny<bool>()))
.Returns(_importLists);
Mocker.GetMock<IImportListStatusService>()
@@ -183,7 +183,7 @@ namespace NzbDrone.Core.Test.ImportListTests
var listResult = Subject.Fetch();
listResult.AnyFailure.Should().BeFalse();
listResult.Movies.Count.Should().Be(10);
listResult.Movies.Count.Should().Be(5);
}
}
}

View File

@@ -72,7 +72,7 @@ namespace NzbDrone.Core.Test.ImportList
};
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.Enabled())
.Setup(v => v.Enabled(It.IsAny<bool>()))
.Returns(_importLists);
Mocker.GetMock<IImportExclusionsService>()
@@ -156,6 +156,10 @@ namespace NzbDrone.Core.Test.ImportList
.Setup(v => v.GetAllMovies())
.Returns(_existingMovies);
Mocker.GetMock<IImportListMovieService>()
.Setup(v => v.GetAllListMovies())
.Returns(_list1Movies);
Subject.Execute(_commandAll);
Mocker.GetMock<IMovieService>()
@@ -179,6 +183,10 @@ namespace NzbDrone.Core.Test.ImportList
.Setup(v => v.GetAllMovies())
.Returns(_existingMovies);
Mocker.GetMock<IImportListMovieService>()
.Setup(v => v.GetAllListMovies())
.Returns(_list1Movies);
Subject.Execute(_commandAll);
Mocker.GetMock<IMovieService>()
@@ -204,6 +212,10 @@ namespace NzbDrone.Core.Test.ImportList
.Setup(v => v.GetAllMovies())
.Returns(_existingMovies);
Mocker.GetMock<IImportListMovieService>()
.Setup(v => v.GetAllListMovies())
.Returns(_list1Movies);
Subject.Execute(_commandAll);
Mocker.GetMock<IMovieService>()
@@ -224,6 +236,10 @@ namespace NzbDrone.Core.Test.ImportList
.Setup(v => v.GetAllMovies())
.Returns(_existingMovies);
Mocker.GetMock<IImportListMovieService>()
.Setup(v => v.GetAllListMovies())
.Returns(_list1Movies);
Subject.Execute(_commandAll);
Mocker.GetMock<IMovieService>()
@@ -241,6 +257,10 @@ namespace NzbDrone.Core.Test.ImportList
.Setup(v => v.GetAllMovies())
.Returns(_existingMovies);
Mocker.GetMock<IImportListMovieService>()
.Setup(v => v.GetAllListMovies())
.Returns(_list1Movies);
Subject.Execute(_commandAll);
Mocker.GetMock<IMovieService>()
@@ -267,6 +287,10 @@ namespace NzbDrone.Core.Test.ImportList
.Setup(v => v.GetAllMovies())
.Returns(_existingMovies);
Mocker.GetMock<IImportListMovieService>()
.Setup(v => v.GetAllListMovies())
.Returns(_list1Movies);
Subject.Execute(_commandAll);
Mocker.GetMock<IMovieService>()
@@ -347,7 +371,7 @@ namespace NzbDrone.Core.Test.ImportList
}
[Test]
public void should_not_add_duplicate_movies_from_seperate_lists()
public void should_not_add_duplicate_movies_from_separate_lists()
{
_list2Movies.ForEach(m => m.ListId = 2);
_importListFetch.Movies.ForEach(m => m.ListId = 1);
@@ -384,7 +408,7 @@ namespace NzbDrone.Core.Test.ImportList
Subject.Execute(_commandAll);
Mocker.GetMock<IAddMovieService>()
.Verify(v => v.AddMovies(It.Is<List<Movie>>(s => s.Count == 7 && !s.Any(m => m.TmdbId == _existingMovies[0].TmdbId)), true), Times.Once());
.Verify(v => v.AddMovies(It.Is<List<Movie>>(s => s.Count == 7 && s.All(m => m.TmdbId != _existingMovies[0].TmdbId)), true), Times.Once());
}
[Test]
@@ -406,7 +430,7 @@ namespace NzbDrone.Core.Test.ImportList
Subject.Execute(_commandAll);
Mocker.GetMock<IAddMovieService>()
.Verify(v => v.AddMovies(It.Is<List<Movie>>(s => s.Count == 7 && !s.Any(m => m.TmdbId == _existingMovies[0].TmdbId)), true), Times.Once());
.Verify(v => v.AddMovies(It.Is<List<Movie>>(s => s.Count == 7 && s.All(m => m.TmdbId != _existingMovies[0].TmdbId)), true), Times.Once());
}
}
}

View File

@@ -361,6 +361,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("The German 2021 Bluray AVC")]
[TestCase("German.Only.Movie.2021.French.1080p.BluRay.AVC-UNTAVC")]
[TestCase("Movie.Title.2008.US.Directors.Cut.UHD.BD66.Blu-ray")]
[TestCase("Movie.2009.Blu.ray.AVC.DTS.HD.MA.5.1")]
public void should_parse_brdisk_1080p_quality(string title)
{
ParseAndVerifyQuality(title, Source.BLURAY, false, Resolution.R1080p, Modifier.BRDISK);

View File

@@ -116,13 +116,6 @@ namespace NzbDrone.Core.Configuration
set { SetValue("AvailabilityDelay", value); }
}
public int ImportListSyncInterval
{
get { return GetValueInt("ImportListSyncInterval", 24); }
set { SetValue("ImportListSyncInterval", value); }
}
public string ListSyncLevel
{
get { return GetValue("ListSyncLevel", "disabled"); }

View File

@@ -60,7 +60,6 @@ namespace NzbDrone.Core.Configuration
bool AllowHardcodedSubs { get; set; }
string WhitelistedHardcodedSubs { get; set; }
int ImportListSyncInterval { get; set; }
string ListSyncLevel { get; set; }
string ImportExclusions { get; set; }

View File

@@ -0,0 +1,15 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(223)]
public class remove_invalid_roksbox_metadata_images : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
IfDatabase("sqlite").Execute.Sql("DELETE FROM \"MetadataFiles\" WHERE \"Consumer\" = 'RoksboxMetadata' AND \"Type\" = 5 AND (\"RelativePath\" LIKE 'metadata/%' OR \"RelativePath\" LIKE 'metadata\\%')");
IfDatabase("postgresql").Execute.Sql("DELETE FROM \"MetadataFiles\" WHERE \"Consumer\" = 'RoksboxMetadata' AND \"Type\" = 5 AND (\"RelativePath\" LIKE 'metadata/%' OR \"RelativePath\" LIKE 'metadata\\\\%')");
}
}
}

View File

@@ -0,0 +1,18 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(224)]
public class list_sync_time : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Delete.Column("LastSyncListInfo").FromTable("ImportListStatus");
Alter.Table("ImportListStatus").AddColumn("LastInfoSync").AsDateTimeOffset().Nullable();
Delete.FromTable("Config").Row(new { Key = "importlistsyncinterval" });
}
}
}

View File

@@ -0,0 +1,14 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(225)]
public class add_tags_to_collections : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("Collections").AddColumn("Tags").AsString().Nullable();
}
}
}

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