Merge branch 'develop' of https://github.com/Sonarr/Sonarr into issue-5148

This commit is contained in:
iceypotato 2023-07-19 14:33:43 -07:00
commit cbf4c9f9cb
191 changed files with 3056 additions and 616 deletions

View File

@ -268,7 +268,7 @@ dotnet_diagnostic.CA5397.severity = suggestion
dotnet_diagnostic.SYSLIB0006.severity = none
[*.{js,html,js,hbs,less,css}]
[*.{js,html,hbs,less,css,ts,tsx}]
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@ -11,6 +11,7 @@ on:
- ".github/workflows/api_docs.yml"
- "docs.sh"
- "src/Sonarr.Api.*/**"
- "src/Sonarr.Http/**"
- "src/**/*.csproj"
- "src/*"

View File

@ -36,7 +36,7 @@ module.exports = (env) => {
},
entry: {
index: 'index.js'
index: 'index.ts'
},
resolve: {
@ -67,23 +67,23 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
filename: '[name].js',
filename: '[name]-[contenthash].js',
sourceMapFilename: '[file].map'
},
optimization: {
moduleIds: 'deterministic',
chunkIds: 'named',
splitChunks: {
chunks: 'initial',
name: 'vendors'
}
chunkIds: isProduction ? 'deterministic' : 'named'
},
performance: {
hints: false
},
experiments: {
topLevelAwait: true
},
plugins: [
new webpack.DefinePlugin({
__DEV__: !isProduction,
@ -97,7 +97,8 @@ module.exports = (env) => {
new HtmlWebpackPlugin({
template: 'frontend/src/index.ejs',
filename: 'index.html',
publicPath: '/'
publicPath: '/',
inject: false
}),
new FileManagerPlugin({

View File

@ -4,13 +4,14 @@ 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 Tooltip from 'Components/Tooltip/Tooltip';
import episodeEntities from 'Episode/episodeEntities';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import { icons } from 'Helpers/Props';
import { icons, tooltipPositions } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import HistoryDetailsModal from './Details/HistoryDetailsModal';
@ -210,7 +211,14 @@ class HistoryRow extends Component {
key={name}
className={styles.customFormatScore}
>
{formatPreferredWordScore(customFormatScore)}
<Tooltip
anchor={formatPreferredWordScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
@ -294,4 +302,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,15 +8,17 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import { icons, kinds } from 'Helpers/Props';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import formatBytes from 'Utilities/Number/formatBytes';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal from './RemoveQueueItemModal';
import TimeleftCell from './TimeleftCell';
@ -91,6 +93,7 @@ class QueueRow extends Component {
languages,
quality,
customFormats,
customFormatScore,
protocol,
indexer,
outputPath,
@ -259,6 +262,24 @@ class QueueRow extends Component {
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
<Tooltip
anchor={formatPreferredWordScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
@ -413,6 +434,7 @@ QueueRow.propTypes = {
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
outputPath: PropTypes.string,
@ -436,6 +458,7 @@ QueueRow.propTypes = {
};
QueueRow.defaultProps = {
customFormats: [],
isGrabbing: false,
isRemoving: false
};

View File

@ -11,7 +11,7 @@ import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesIndex from 'Series/Index/SeriesIndex';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
@ -179,7 +179,7 @@ function AppRoutes(props) {
<Route
path="/settings/customformats"
component={CustomFormatSettingsConnector}
component={CustomFormatSettingsPage}
/>
<Route

View File

@ -2,6 +2,7 @@ import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import CalendarAppState from './CalendarAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import ParseAppState from './ParseAppState';
import QueueAppState from './QueueAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
@ -44,6 +45,7 @@ interface AppState {
episodesSelection: EpisodesAppState;
episodeFiles: EpisodeFilesAppState;
interactiveImport: InteractiveImportAppState;
parse: ParseAppState;
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState;
series: SeriesAppState;

View File

@ -0,0 +1,54 @@
import ModelBase from 'App/ModelBase';
import { AppSectionItemState } from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import CustomFormat from 'typings/CustomFormat';
export interface SeriesTitleInfo {
title: string;
titleWithoutYear: string;
year: number;
allTitles: string[];
}
export interface ParsedEpisodeInfo {
releaseTitle: string;
seriesTitle: string;
seriesTitleInfo: SeriesTitleInfo;
quality: QualityModel;
seasonNumber: number;
episodeNumbers: number[];
absoluteEpisodeNumbers: number[];
specialAbsoluteEpisodeNumbers: number[];
languages: Language[];
fullSeason: boolean;
isPartialSeason: boolean;
isMultiSeason: boolean;
isSeasonExtra: boolean;
special: boolean;
releaseHash: string;
seasonPart: number;
releaseGroup?: string;
releaseTokens: string;
airDate?: string;
isDaily: boolean;
isAbsoluteNumbering: boolean;
isPossibleSpecialEpisode: boolean;
isPossibleSceneSeasonSpecial: boolean;
}
export interface ParseModel extends ModelBase {
title: string;
parsedEpisodeInfo: ParsedEpisodeInfo;
series?: Series;
episodes: Episode[];
languages?: Language[];
customFormats?: CustomFormat[];
customFormatScore?: number;
}
type ParseAppState = AppSectionItemState<ParseModel>;
export default ParseAppState;

View File

@ -578,7 +578,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

@ -69,7 +69,7 @@ class QualityProfileSelectInputConnector extends Component {
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) });
this.props.onChange({ name, value: value === 'noChange' ? value : parseInt(value) });
};
//

View File

@ -7,6 +7,7 @@ function ErrorPage(props) {
const {
version,
isLocalStorageSupported,
translationsError,
seriesError,
customFiltersError,
tagsError,
@ -19,6 +20,8 @@ function ErrorPage(props) {
if (!isLocalStorageSupported) {
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
} else if (translationsError) {
errorMessage = getErrorMessage(translationsError, 'Failed to load translations from API');
} else if (seriesError) {
errorMessage = getErrorMessage(seriesError, 'Failed to load series from API');
} else if (customFiltersError) {
@ -49,6 +52,7 @@ function ErrorPage(props) {
ErrorPage.propTypes = {
version: PropTypes.string.isRequired,
isLocalStorageSupported: PropTypes.bool.isRequired,
translationsError: PropTypes.object,
seriesError: PropTypes.object,
customFiltersError: PropTypes.object,
tagsError: PropTypes.object,

View File

@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { createSelector } from 'reselect';
import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchImportLists, fetchLanguages, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions';
@ -52,6 +52,7 @@ const selectIsPopulated = createSelector(
(state) => state.settings.languages.isPopulated,
(state) => state.settings.importLists.isPopulated,
(state) => state.system.status.isPopulated,
(state) => state.app.translations.isPopulated,
(
seriesIsPopulated,
customFiltersIsPopulated,
@ -60,7 +61,8 @@ const selectIsPopulated = createSelector(
qualityProfilesIsPopulated,
languagesIsPopulated,
importListsIsPopulated,
systemStatusIsPopulated
systemStatusIsPopulated,
translationsIsPopulated
) => {
return (
seriesIsPopulated &&
@ -70,7 +72,8 @@ const selectIsPopulated = createSelector(
qualityProfilesIsPopulated &&
languagesIsPopulated &&
importListsIsPopulated &&
systemStatusIsPopulated
systemStatusIsPopulated &&
translationsIsPopulated
);
}
);
@ -84,6 +87,7 @@ const selectErrors = createSelector(
(state) => state.settings.languages.error,
(state) => state.settings.importLists.error,
(state) => state.system.status.error,
(state) => state.app.translations.error,
(
seriesError,
customFiltersError,
@ -92,7 +96,8 @@ const selectErrors = createSelector(
qualityProfilesError,
languagesError,
importListsError,
systemStatusError
systemStatusError,
translationsError
) => {
const hasError = !!(
seriesError ||
@ -102,7 +107,8 @@ const selectErrors = createSelector(
qualityProfilesError ||
languagesError ||
importListsError ||
systemStatusError
systemStatusError ||
translationsError
);
return {
@ -114,7 +120,8 @@ const selectErrors = createSelector(
qualityProfilesError,
languagesError,
importListsError,
systemStatusError
systemStatusError,
translationsError
};
}
);
@ -173,6 +180,9 @@ function createMapDispatchToProps(dispatch, props) {
dispatchFetchStatus() {
dispatch(fetchStatus());
},
dispatchFetchTranslations() {
dispatch(fetchTranslations());
},
onResize(dimensions) {
dispatch(saveDimensions(dimensions));
},
@ -205,6 +215,7 @@ class PageConnector extends Component {
this.props.dispatchFetchImportLists();
this.props.dispatchFetchUISettings();
this.props.dispatchFetchStatus();
this.props.dispatchFetchTranslations();
}
}
@ -229,6 +240,7 @@ class PageConnector extends Component {
dispatchFetchImportLists,
dispatchFetchUISettings,
dispatchFetchStatus,
dispatchFetchTranslations,
...otherProps
} = this.props;
@ -268,6 +280,7 @@ PageConnector.propTypes = {
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchUISettings: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired,
dispatchFetchTranslations: PropTypes.func.isRequired,
onSidebarVisibleChange: PropTypes.func.isRequired
};

View File

@ -5,8 +5,8 @@ import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
interface PageContentBodyProps {
className: string;
innerClassName: string;
className?: string;
innerClassName?: string;
children: ReactNode;
initialScrollTop?: number;
onScroll?: (payload: OnScroll) => void;

View File

@ -10,6 +10,7 @@ import { icons } from 'Helpers/Props';
import locationShape from 'Helpers/Props/Shapes/locationShape';
import dimensions from 'Styles/Variables/dimensions';
import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector';
import translate from 'Utilities/String/translate';
import MessagesConnector from './Messages/MessagesConnector';
import PageSidebarItem from './PageSidebarItem';
import styles from './PageSidebar.css';
@ -20,16 +21,22 @@ const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
const links = [
{
iconName: icons.SERIES_CONTINUING,
title: 'Series',
get title() {
return translate('Series');
},
to: '/',
alias: '/series',
children: [
{
title: 'Add New',
get title() {
return translate('AddNew');
},
to: '/add/new'
},
{
title: 'Library Import',
get title() {
return translate('LibraryImport');
},
to: '/add/import'
}
]
@ -37,26 +44,36 @@ const links = [
{
iconName: icons.CALENDAR,
title: 'Calendar',
get title() {
return translate('Calendar');
},
to: '/calendar'
},
{
iconName: icons.ACTIVITY,
title: 'Activity',
get title() {
return translate('Activity');
},
to: '/activity/queue',
children: [
{
title: 'Queue',
get title() {
return translate('Queue');
},
to: '/activity/queue',
statusComponent: QueueStatusConnector
},
{
title: 'History',
get title() {
return translate('History');
},
to: '/activity/history'
},
{
title: 'Blocklist',
get title() {
return translate('Blocklist');
},
to: '/activity/blocklist'
}
]
@ -64,15 +81,21 @@ const links = [
{
iconName: icons.WARNING,
title: 'Wanted',
get title() {
return translate('Wanted');
},
to: '/wanted/missing',
children: [
{
title: 'Missing',
get title() {
return translate('Missing');
},
to: '/wanted/missing'
},
{
title: 'Cutoff Unmet',
get title() {
return translate('CutoffUnmet');
},
to: '/wanted/cutoffunmet'
}
]
@ -80,59 +103,87 @@ const links = [
{
iconName: icons.SETTINGS,
title: 'Settings',
get title() {
return translate('Settings');
},
to: '/settings',
children: [
{
title: 'Media Management',
get title() {
return translate('MediaManagement');
},
to: '/settings/mediamanagement'
},
{
title: 'Profiles',
get title() {
return translate('Profiles');
},
to: '/settings/profiles'
},
{
title: 'Quality',
get title() {
return translate('Quality');
},
to: '/settings/quality'
},
{
title: 'Custom Formats',
get title() {
return translate('CustomFormats');
},
to: '/settings/customformats'
},
{
title: 'Indexers',
get title() {
return translate('Indexers');
},
to: '/settings/indexers'
},
{
title: 'Download Clients',
get title() {
return translate('DownloadClients');
},
to: '/settings/downloadclients'
},
{
title: 'Import Lists',
get title() {
return translate('ImportLists');
},
to: '/settings/importlists'
},
{
title: 'Connect',
get title() {
return translate('Connect');
},
to: '/settings/connect'
},
{
title: 'Metadata',
get title() {
return translate('Metadata');
},
to: '/settings/metadata'
},
{
title: 'Metadata Source',
get title() {
return translate('MetadataSource');
},
to: '/settings/metadatasource'
},
{
title: 'Tags',
get title() {
return translate('Tags');
},
to: '/settings/tags'
},
{
title: 'General',
get title() {
return translate('General');
},
to: '/settings/general'
},
{
title: 'UI',
get title() {
return translate('UI');
},
to: '/settings/ui'
}
]
@ -140,32 +191,46 @@ const links = [
{
iconName: icons.SYSTEM,
title: 'System',
get title() {
return translate('System');
},
to: '/system/status',
children: [
{
title: 'Status',
get title() {
return translate('Status');
},
to: '/system/status',
statusComponent: HealthStatusConnector
},
{
title: 'Tasks',
get title() {
return translate('Tasks');
},
to: '/system/tasks'
},
{
title: 'Backup',
get title() {
return translate('Backup');
},
to: '/system/backup'
},
{
title: 'Updates',
get title() {
return translate('Updates');
},
to: '/system/updates'
},
{
title: 'Events',
get title() {
return translate('Events');
},
to: '/system/events'
},
{
title: 'Log Files',
get title() {
return translate('LogFiles');
},
to: '/system/logs/files'
}
]

View File

@ -31,6 +31,7 @@ import {
faBookReader as fasBookReader,
faBroadcastTower as fasBroadcastTower,
faBug as fasBug,
faCalculator as fasCalculator,
faCalendarAlt as fasCalendarAlt,
faCaretDown as fasCaretDown,
faCheck as fasCheck,
@ -174,6 +175,7 @@ export const PAGE_PREVIOUS = fasBackward;
export const PAGE_NEXT = fasForward;
export const PAGE_LAST = fasFastForward;
export const PARENT = fasLevelUpAlt;
export const PARSE = fasCalculator;
export const PAUSED = fasPause;
export const PENDING = farClock;
export const PROFILE = fasUser;

View File

@ -30,6 +30,8 @@ import {
import { SelectStateInputProps } from 'typings/props';
import Rejection from 'typings/Rejection';
import formatBytes from 'Utilities/Number/formatBytes';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import translate from 'Utilities/String/translate';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import styles from './InteractiveImportRow.css';
@ -57,6 +59,7 @@ interface InteractiveImportRowProps {
languages?: Language[];
size: number;
customFormats?: object[];
customFormatScore?: number;
rejections: Rejection[];
columns: Column[];
episodeFileId?: number;
@ -80,6 +83,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
releaseGroup,
size,
customFormats,
customFormatScore,
rejections,
isReprocessing,
isSelected,
@ -427,8 +431,8 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
<TableRowCell>
{customFormats?.length ? (
<Popover
anchor={<Icon name={icons.INTERACTIVE} />}
title="Formats"
anchor={formatPreferredWordScore(customFormatScore)}
title={translate('CustomFormats')}
body={
<div className={styles.customFormatTooltip}>
<EpisodeFormats formats={customFormats} />

View File

@ -0,0 +1,45 @@
.inputContainer {
display: flex;
margin-bottom: 10px;
}
.inputIconContainer {
width: 58px;
height: 46px;
border: 1px solid var(--inputBorderColor);
border-right: none;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background-color: var(--inputIconContainerBackgroundColor);
text-align: center;
line-height: 46px;
}
.input {
composes: input from '~Components/Form/TextInput.css';
height: 46px;
border-radius: 0;
font-size: 18px;
}
.clearButton {
border: 1px solid var(--inputBorderColor);
border-left: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.message {
margin-top: 30px;
text-align: center;
font-weight: 300;
font-size: $largeFontSize;
}
.helpText {
margin-bottom: 10px;
font-size: 24px;
}

12
frontend/src/Parse/Parse.css.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'clearButton': string;
'helpText': string;
'input': string;
'inputContainer': string;
'inputIconContainer': string;
'message': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,111 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons } from 'Helpers/Props';
import { clear, fetch } from 'Store/Actions/parseActions';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import ParseResult from './ParseResult';
import parseStateSelector from './parseStateSelector';
import styles from './Parse.css';
function Parse() {
const { isFetching, error, item } = useSelector(parseStateSelector());
const [title, setTitle] = useState('');
const dispatch = useDispatch();
const onInputChange = useCallback(
({ value }: { value: string }) => {
const trimmedValue = value.trim();
setTitle(value);
if (trimmedValue === '') {
dispatch(clear());
} else {
dispatch(fetch({ title: trimmedValue }));
}
},
[setTitle, dispatch]
);
const onClearPress = useCallback(() => {
setTitle('');
dispatch(clear());
}, [setTitle, dispatch]);
useEffect(
() => {
return () => {
dispatch(clear());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<PageContent title="Parse">
<PageContentBody>
<div className={styles.inputContainer}>
<div className={styles.inputIconContainer}>
<Icon name={icons.PARSE} size={20} />
</div>
<TextInput
className={styles.input}
name="title"
value={title}
placeholder="eg. Series.Title.S01E05.720p.HDTV-RlsGroup"
autoFocus={true}
onChange={onInputChange}
/>
<Button className={styles.clearButton} onPress={onClearPress}>
<Icon name={icons.REMOVE} size={20} />
</Button>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<div className={styles.message}>
<div className={styles.helpText}>
Error parsing, please try again.
</div>
<div>{getErrorMessage(error)}</div>
</div>
) : null}
{!isFetching && title && !error && !item.parsedEpisodeInfo ? (
<div className={styles.message}>
Unable to parse the provided title, please try again.
</div>
) : null}
{!isFetching && !error && item.parsedEpisodeInfo ? (
<ParseResult item={item} />
) : null}
{title ? null : (
<div className={styles.message}>
<div className={styles.helpText}>
Enter a release title in the input above
</div>
<div>
Sonarr will attempt to parse the title and show you details about
it
</div>
</div>
)}
</PageContentBody>
</PageContent>
);
}
export default Parse;

View File

@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ParseModalContent from './ParseModalContent';
interface ParseModalProps {
isOpen: boolean;
onModalClose: () => void;
}
function ParseModal(props: ParseModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ParseModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ParseModal;

View File

@ -0,0 +1,45 @@
.inputContainer {
display: flex;
margin-bottom: 10px;
}
.inputIconContainer {
width: 58px;
height: 46px;
border: 1px solid var(--inputBorderColor);
border-right: none;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background-color: var(--inputIconContainerBackgroundColor);
text-align: center;
line-height: 46px;
}
.input {
composes: input from '~Components/Form/TextInput.css';
height: 46px;
border-radius: 0;
font-size: 18px;
}
.clearButton {
border: 1px solid var(--inputBorderColor);
border-left: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.message {
margin-top: 30px;
text-align: center;
font-weight: 300;
font-size: $largeFontSize;
}
.helpText {
margin-bottom: 10px;
font-size: 24px;
}

View File

@ -0,0 +1,12 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'clearButton': string;
'helpText': string;
'input': string;
'inputContainer': string;
'inputIconContainer': string;
'message': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,125 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons } from 'Helpers/Props';
import { clear, fetch } from 'Store/Actions/parseActions';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import ParseResult from './ParseResult';
import parseStateSelector from './parseStateSelector';
import styles from './ParseModalContent.css';
interface ParseModalContentProps {
onModalClose: () => void;
}
function ParseModalContent(props: ParseModalContentProps) {
const { onModalClose } = props;
const { isFetching, error, item } = useSelector(parseStateSelector());
const [title, setTitle] = useState('');
const dispatch = useDispatch();
const onInputChange = useCallback(
({ value }: { value: string }) => {
const trimmedValue = value.trim();
setTitle(value);
if (trimmedValue === '') {
dispatch(clear());
} else {
dispatch(fetch({ title: trimmedValue }));
}
},
[setTitle, dispatch]
);
const onClearPress = useCallback(() => {
setTitle('');
dispatch(clear());
}, [setTitle, dispatch]);
useEffect(
() => {
return () => {
dispatch(clear());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('TestParsing')}</ModalHeader>
<ModalBody>
<div className={styles.inputContainer}>
<div className={styles.inputIconContainer}>
<Icon name={icons.PARSE} size={20} />
</div>
<TextInput
className={styles.input}
name="title"
value={title}
placeholder="eg. Series.Title.S01E05.720p.HDTV-RlsGroup"
autoFocus={true}
onChange={onInputChange}
/>
<Button className={styles.clearButton} onPress={onClearPress}>
<Icon name={icons.REMOVE} size={20} />
</Button>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<div className={styles.message}>
<div className={styles.helpText}>
Error parsing, please try again.
</div>
<div>{getErrorMessage(error)}</div>
</div>
) : null}
{!isFetching && title && !error && !item.parsedEpisodeInfo ? (
<div className={styles.message}>
Unable to parse the provided title, please try again.
</div>
) : null}
{!isFetching && !error && item.parsedEpisodeInfo ? (
<ParseResult item={item} />
) : null}
{title ? null : (
<div className={styles.message}>
<div className={styles.helpText}>
Enter a release title in the input above
</div>
<div>
Sonarr will attempt to parse the title and show you details about
it
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default ParseModalContent;

View File

@ -0,0 +1,8 @@
.container {
display: flex;
flex-wrap: wrap;
}
.column {
flex: 0 0 50%;
}

View File

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

View File

@ -0,0 +1,243 @@
import React from 'react';
import { ParseModel } from 'App/State/ParseAppState';
import FieldSet from 'Components/FieldSet';
import EpisodeFormats from 'Episode/EpisodeFormats';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import translate from 'Utilities/String/translate';
import ParseResultItem from './ParseResultItem';
import styles from './ParseResult.css';
interface ParseResultProps {
item: ParseModel;
}
function ParseResult(props: ParseResultProps) {
const { item } = props;
const {
customFormats,
customFormatScore,
episodes,
languages,
parsedEpisodeInfo,
series,
} = item;
const {
releaseTitle,
seriesTitle,
seriesTitleInfo,
releaseGroup,
releaseHash,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
special,
fullSeason,
isMultiSeason,
isPartialSeason,
isDaily,
airDate,
quality,
} = parsedEpisodeInfo;
const finalLanguages = languages ?? parsedEpisodeInfo.languages;
return (
<div>
<FieldSet legend={translate('Release')}>
<ParseResultItem
title={translate('ReleaseTitle')}
data={releaseTitle}
/>
<ParseResultItem title={translate('SeriesTitle')} data={seriesTitle} />
<ParseResultItem
title={translate('Year')}
data={seriesTitleInfo.year > 0 ? seriesTitleInfo.year : '-'}
/>
<ParseResultItem
title={translate('AllTitles')}
data={
seriesTitleInfo.allTitles?.length > 0
? seriesTitleInfo.allTitles.join(', ')
: '-'
}
/>
<ParseResultItem
title={translate('ReleaseGroup')}
data={releaseGroup ?? '-'}
/>
<ParseResultItem
title={translate('ReleaseHash')}
data={releaseHash ? releaseHash : '-'}
/>
</FieldSet>
<FieldSet legend={translate('EpisodeInfo')}>
<div className={styles.container}>
<div className={styles.column}>
<ParseResultItem
title={translate('SeasonNumber')}
data={
seasonNumber === 0 && absoluteEpisodeNumbers.length
? '-'
: seasonNumber
}
/>
<ParseResultItem
title={translate('EpisodeNumbers')}
data={episodeNumbers.join(', ') || '-'}
/>
<ParseResultItem
title={translate('AbsoluteEpisodeNumbers')}
data={
absoluteEpisodeNumbers.length
? absoluteEpisodeNumbers.join(', ')
: '-'
}
/>
<ParseResultItem
title={translate('Daily')}
data={isDaily ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('AirDate')}
data={airDate ?? '-'}
/>
</div>
<div className={styles.column}>
<ParseResultItem
title={translate('Special')}
data={special ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('FullSeason')}
data={fullSeason ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('MultiSeason')}
data={isMultiSeason ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('PartialSeason')}
data={isPartialSeason ? 'True' : 'False'}
/>
</div>
</div>
</FieldSet>
<FieldSet legend={translate('Quality')}>
<div className={styles.container}>
<div className={styles.column}>
<ParseResultItem
title={translate('Quality')}
data={quality.quality.name}
/>
<ParseResultItem
title={translate('Proper')}
data={
quality.revision.version > 1 && !quality.revision.isRepack
? 'True'
: '-'
}
/>
<ParseResultItem
title={translate('Repack')}
data={quality.revision.isRepack ? 'True' : '-'}
/>
</div>
<div className={styles.column}>
<ParseResultItem
title={translate('Version')}
data={
quality.revision.version > 1 ? quality.revision.version : '-'
}
/>
<ParseResultItem
title={translate('Real')}
data={quality.revision.real ? 'True' : '-'}
/>
</div>
</div>
</FieldSet>
<FieldSet legend={translate('Languages')}>
<ParseResultItem
title={translate('Languages')}
data={finalLanguages.map((l) => l.name).join(', ')}
/>
</FieldSet>
<FieldSet legend={translate('Details')}>
<ParseResultItem
title={translate('MatchedToSeries')}
data={
series ? (
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
) : (
'-'
)
}
/>
<ParseResultItem
title={translate('MatchedToSeason')}
data={episodes.length ? episodes[0].seasonNumber : '-'}
/>
<ParseResultItem
title={translate('MatchedToEpisodes')}
data={
episodes.length ? (
<div>
{episodes.map((e) => {
return (
<div key={e.id}>
{e.episodeNumber}
{series?.seriesType === 'anime' && e.absoluteEpisodeNumber
? ` (${e.absoluteEpisodeNumber})`
: ''}{' '}
{` - ${e.title}`}
</div>
);
})}
</div>
) : (
'-'
)
}
/>
<ParseResultItem
title={translate('CustomFormats')}
data={<EpisodeFormats formats={customFormats} />}
/>
<ParseResultItem
title={translate('CustomFormatScore')}
data={customFormatScore}
/>
</FieldSet>
</div>
);
}
export default ParseResult;

View File

@ -0,0 +1,21 @@
.item {
display: flex;
}
.title {
margin-right: 20px;
width: 250px;
text-align: right;
font-weight: bold;
}
@media (max-width: $breakpointSmall) {
.item {
display: block;
margin-bottom: 10px;
}
.title {
text-align: left;
}
}

View File

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

View File

@ -0,0 +1,20 @@
import React, { ReactNode } from 'react';
import styles from './ParseResultItem.css';
interface ParseResultItemProps {
title: string;
data: string | number | ReactNode;
}
function ParseResultItem(props: ParseResultItemProps) {
const { title, data } = props;
return (
<div className={styles.item}>
<div className={styles.title}>{title}</div>
<div>{data}</div>
</div>
);
}
export default ParseResultItem;

View File

@ -0,0 +1,31 @@
import React, { Fragment, useCallback, useState } from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import ParseModal from 'Parse/ParseModal';
import translate from 'Utilities/String/translate';
function ParseToolbarButton() {
const [isParseModalOpen, setIsParseModalOpen] = useState(false);
const onOpenParseModalPress = useCallback(() => {
setIsParseModalOpen(true);
}, [setIsParseModalOpen]);
const onParseModalClose = useCallback(() => {
setIsParseModalOpen(false);
}, [setIsParseModalOpen]);
return (
<Fragment>
<PageToolbarButton
label={translate('TestParsing')}
iconName={icons.PARSE}
onPress={onOpenParseModalPress}
/>
<ParseModal isOpen={isParseModalOpen} onModalClose={onParseModalClose} />
</Fragment>
);
}
export default ParseToolbarButton;

View File

@ -0,0 +1,12 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import ParseAppState from 'App/State/ParseAppState';
export default function parseStateSelector() {
return createSelector(
(state: AppState) => state.parse,
(parse: ParseAppState) => {
return parse;
}
);
}

View File

@ -56,3 +56,9 @@
width: 120px;
}
.customFormatScore {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 55px;
}

View File

@ -3,6 +3,7 @@
interface CssExports {
'audio': string;
'audioLanguages': string;
'customFormatScore': string;
'episodeNumber': string;
'episodeNumberAnime': string;
'languages': string;

View File

@ -4,6 +4,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeNumber from 'Episode/EpisodeNumber';
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
@ -12,7 +13,9 @@ import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector';
import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes';
import { tooltipPositions } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import formatRuntime from 'Utilities/Number/formatRuntime';
import styles from './EpisodeRow.css';
@ -72,6 +75,7 @@ class EpisodeRow extends Component {
episodeFileSize,
releaseGroup,
customFormats,
customFormatScore,
alternateTitles,
columns
} = this.props;
@ -193,6 +197,24 @@ class EpisodeRow extends Component {
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
<Tooltip
anchor={formatPreferredWordScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell
@ -355,6 +377,7 @@ EpisodeRow.propTypes = {
episodeFileSize: PropTypes.number,
releaseGroup: PropTypes.string,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
mediaInfo: PropTypes.object,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,

View File

@ -19,6 +19,7 @@ function createMapStateToProps() {
episodeFileSize: episodeFile ? episodeFile.size : null,
releaseGroup: episodeFile ? episodeFile.releaseGroup : null,
customFormats: episodeFile ? episodeFile.customFormats : [],
customFormatScore: episodeFile ? episodeFile.customFormatScore : 0,
alternateTitles: series.alternateTitles
};
}

View File

@ -248,6 +248,8 @@ class SeriesDetails extends Component {
expandIcon = icons.EXPAND;
}
const fanartUrl = getFanartUrl(images);
return (
<PageContent title={title}>
<PageToolbar>
@ -327,9 +329,11 @@ class SeriesDetails 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

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

View File

@ -16,6 +16,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
@ -73,12 +74,12 @@ function TagsModalContent(props: TagsModalContentProps) {
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}
@ -89,7 +90,7 @@ function TagsModalContent(props: TagsModalContentProps) {
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@ -97,17 +98,17 @@ function TagsModalContent(props: TagsModalContentProps) {
value={applyTags}
values={applyTagsOptions}
helpTexts={[
'How to apply tags to the selected series',
'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('ApplyTagsHelpTextHowToApplySeries'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Result</FormLabel>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{seriesTags.map((id) => {
@ -124,7 +125,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}
>
@ -148,7 +153,7 @@ function TagsModalContent(props: TagsModalContentProps) {
return (
<Label
key={tag.id}
title={'Adding tag'}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
@ -162,10 +167,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 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions
import withScrollPosition from 'Components/withScrollPosition';
import { align, icons, kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchQueueDetails } from 'Store/Actions/queueActions';
@ -246,6 +247,9 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectAllMenuItem}
/>
<PageToolbarSeparator />
<ParseToolbarButton />
</PageToolbarSection>
<PageToolbarSection

View File

@ -1,32 +0,0 @@
import React, { Component } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
class CustomFormatSettingsConnector extends Component {
//
// Render
render() {
return (
<PageContent title="Custom Format Settings">
<SettingsToolbarConnector
showSave={false}
/>
<PageContentBody>
<DndProvider backend={HTML5Backend}>
<CustomFormatsConnector />
</DndProvider>
</PageContentBody>
</PageContent>
);
}
}
export default CustomFormatSettingsConnector;

View File

@ -0,0 +1,41 @@
import React, { Fragment } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
function CustomFormatSettingsPage() {
return (
<PageContent title="Custom Format Settings">
<SettingsToolbarConnector
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
showSave={false}
additionalButtons={
<Fragment>
<PageToolbarSeparator />
<ParseToolbarButton />
</Fragment>
}
/>
<PageContentBody>
{/* TODO: Upgrade react-dnd to get typings, we're 2 major versions behind */}
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<DndProvider backend={HTML5Backend}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<CustomFormatsConnector />
</DndProvider>
</PageContentBody>
</PageContent>
);
}
export default CustomFormatSettingsPage;

View File

@ -6,6 +6,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
@ -85,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 EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
import styles from './DownloadClient.css';
@ -55,7 +56,9 @@ class DownloadClient extends Component {
id,
name,
enable,
priority
priority,
tags,
tagList
} = this.props;
return (
@ -93,6 +96,11 @@ class DownloadClient extends Component {
}
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<EditDownloadClientModalConnector
id={id}
isOpen={this.state.isEditDownloadClientModalOpen}
@ -119,6 +127,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

@ -49,6 +49,7 @@ class DownloadClients extends Component {
const {
items,
onConfirmDeleteDownloadClient,
tagList,
...otherProps
} = this.props;
@ -70,6 +71,7 @@ class DownloadClients extends Component {
<DownloadClient
key={item.id}
{...item}
tagList={tagList}
onConfirmDeleteDownloadClient={onConfirmDeleteDownloadClient}
/>
);
@ -108,6 +110,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

@ -50,6 +50,7 @@ class EditDownloadClientModalContent extends Component {
removeCompletedDownloads,
removeFailedDownloads,
fields,
tags,
message
} = item;
@ -137,6 +138,18 @@ class EditDownloadClientModalContent extends Component {
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText="Only use this download client for series with at least one matching tag. Leave blank to use with all series."
{...tags}
onChange={onInputChange}
/>
</FormGroup>
<FieldSet
size={sizes.SMALL}
legend="Completed Download Handling"

View File

@ -27,9 +27,25 @@ 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,
get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
];
function ManageDownloadClientsEditModalContent(
@ -97,7 +113,9 @@ function ManageDownloadClientsEditModalContent(
setRemoveFailedDownloads(value);
break;
default:
console.warn('EditDownloadClientsModalContent Unknown Input');
console.warn(
`EditDownloadClientsModalContent Unknown Input: '${name}'`
);
}
},
[]
@ -162,7 +180,7 @@ function ManageDownloadClientsEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} download clients selected', {
{translate('CountDownloadClientsSelected', {
count: selectedCount,
})}
</div>
@ -170,7 +188,7 @@ function ManageDownloadClientsEditModalContent(
<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,55 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: 'Name',
get label() {
return translate('Name');
},
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
get label() {
return translate('Implementation');
},
isSortable: true,
isVisible: true,
},
{
name: 'enable',
label: 'Enabled',
get label() {
return translate('Enabled');
},
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: 'Priority',
get label() {
return translate('Priority');
},
isSortable: true,
isVisible: true,
},
{
name: 'removeCompletedDownloads',
label: 'Remove Completed',
get label() {
return translate('RemoveCompleted');
},
isSortable: true,
isVisible: true,
},
{
name: 'removeFailedDownloads',
label: 'Remove Failed',
get label() {
return translate('RemoveFailed');
},
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
isSortable: true,
isVisible: true,
},
@ -92,6 +113,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 +161,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 +205,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 +257,7 @@ function ManageDownloadClientsModalContent(
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
@ -211,11 +265,19 @@ function ManageDownloadClientsModalContent(
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected}
onPress={onTagsPress}
>
Set Tags
</SpinnerButton>
</div>
<Button onPress={onModalClose}>Close</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageDownloadClientsEditModal
@ -225,12 +287,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', {
count: 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,200 @@
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',
get value() {
return translate('Add');
},
},
{
key: 'remove',
get value() {
return translate('Remove');
},
},
{
key: 'replace',
get value() {
return 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

@ -6,6 +6,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
@ -81,7 +82,7 @@ class ImportListSettings extends Component {
/>
<PageToolbarButton
label="Manage Lists"
label={translate('ManageLists')}
iconName={icons.MANAGE}
onPress={this.onManageImportListsPress}
/>

View File

@ -26,9 +26,25 @@ 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,
get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
];
function ManageImportListsEditModalContent(
@ -87,7 +103,7 @@ function ManageImportListsEditModalContent(
setRootFolderPath(value);
break;
default:
console.warn('EditImportListModalContent Unknown Input');
console.warn(`EditImportListModalContent Unknown Input: '${name}'`);
}
},
[]
@ -142,7 +158,9 @@ function ManageImportListsEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} import lists selected', { count: selectedCount })}
{translate('CountImportListsSelected', {
count: 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,49 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: 'Name',
get label() {
return translate('Name');
},
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
get label() {
return translate('Implementation');
},
isSortable: true,
isVisible: true,
},
{
name: 'qualityProfileId',
label: 'Quality Profile',
get label() {
return translate('QualityProfile');
},
isSortable: true,
isVisible: true,
},
{
name: 'rootFolderPath',
label: 'Root Folder',
get label() {
return translate('RootFolder');
},
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticAdd',
label: 'Auto Add',
get label() {
return translate('AutoAdd');
},
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
get label() {
return translate('Tags');
},
isSortable: true,
isVisible: true,
},
@ -190,12 +204,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 +248,7 @@ function ManageImportListsModalContent(
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
@ -238,7 +256,7 @@ function ManageImportListsModalContent(
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
@ -246,11 +264,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 +288,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', {
count: 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,34 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
{
key: 'add',
get value() {
return translate('Add');
},
},
{
key: 'remove',
get value() {
return translate('Remove');
},
},
{
key: 'replace',
get value() {
return 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 +108,7 @@ function TagsModalContent(props: TagsModalContentProps) {
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@ -100,20 +116,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 +143,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 +164,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 +185,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

@ -6,6 +6,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import IndexersConnector from './Indexers/IndexersConnector';
import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal';
import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
@ -84,7 +85,7 @@ class IndexerSettings extends Component {
/>
<PageToolbarButton
label="Manage Indexers"
label={translate('ManageIndexers')}
iconName={icons.MANAGE}
onPress={this.onManageIndexersPress}
/>

View File

@ -27,9 +27,25 @@ 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,
get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
];
function ManageIndexersEditModalContent(
@ -97,7 +113,7 @@ function ManageIndexersEditModalContent(
setPriority(value);
break;
default:
console.warn('EditIndexersModalContent Unknown Input');
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
}
},
[]
@ -111,7 +127,7 @@ function ManageIndexersEditModalContent(
<ModalBody>
<FormGroup>
<FormLabel>{translate('EnableRss')}</FormLabel>
<FormLabel>{translate('EnableRSS')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@ -162,13 +178,15 @@ function ManageIndexersEditModalContent(
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} indexers selected', { count: selectedCount })}
{translate('CountIndexersSelected', {
count: 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,57 @@ type OnSelectedChangeCallback = React.ComponentProps<
const COLUMNS = [
{
name: 'name',
label: 'Name',
get label() {
return translate('Name');
},
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: 'Implementation',
get label() {
return translate('Implementation');
},
isSortable: true,
isVisible: true,
},
{
name: 'enableRss',
label: 'Enable RSS',
get label() {
return translate('EnableRSS');
},
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticSearch',
label: 'Enable Automatic Search',
get label() {
return translate('EnableAutomaticSearch');
},
isSortable: true,
isVisible: true,
},
{
name: 'enableInteractiveSearch',
label: 'Enable Interactive Search',
get label() {
return translate('EnableInteractiveSearch');
},
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: 'Priority',
get label() {
return translate('Priority');
},
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
get label() {
return translate('Tags');
},
isSortable: true,
isVisible: true,
},
@ -189,17 +205,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 +254,7 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isDisabled={!anySelected}
onPress={onDeletePress}
>
Delete
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
@ -242,7 +262,7 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
isDisabled={!anySelected}
onPress={onEditPress}
>
Edit
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
@ -250,11 +270,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 +294,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', {
count: 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,34 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
{
key: 'add',
get value() {
return translate('Add');
},
},
{
key: 'remove',
get value() {
return translate('Remove');
},
},
{
key: 'replace',
get value() {
return 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 +108,7 @@ function TagsModalContent(props: TagsModalContentProps) {
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
@ -100,20 +116,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 +143,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 +164,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 +185,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

@ -134,6 +134,7 @@ const historyShape = {
};
SettingsToolbarConnector.propTypes = {
showSave: PropTypes.bool,
hasPendingChanges: PropTypes.bool.isRequired,
history: PropTypes.shape(historyShape).isRequired,
onSavePress: PropTypes.func,

View File

@ -21,6 +21,7 @@ function TagDetailsModalContent(props) {
notifications,
releaseProfiles,
indexers,
downloadClients,
autoTags,
onModalClose,
onDeleteTagPress
@ -179,6 +180,22 @@ function TagDetailsModalContent(props) {
null
}
{
downloadClients.length ?
<FieldSet legend="Download Clients">
{
downloadClients.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
{
autoTags.length ?
<FieldSet legend="Auto Tagging">
@ -228,6 +245,7 @@ TagDetailsModalContent.propTypes = {
notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
autoTags: 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 createMatchingAutoTagsSelector() {
return createSelector(
(state, { autoTagIds }) => autoTagIds,
@ -93,8 +101,9 @@ function createMapStateToProps() {
createMatchingNotificationsSelector(),
createMatchingReleaseProfilesSelector(),
createMatchingIndexersSelector(),
createMatchingDownloadClientsSelector(),
createMatchingAutoTagsSelector(),
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers, autoTags) => {
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients, autoTags) => {
return {
series,
delayProfiles,
@ -102,6 +111,7 @@ function createMapStateToProps() {
notifications,
releaseProfiles,
indexers,
downloadClients,
autoTags
};
}

View File

@ -58,6 +58,7 @@ class Tag extends Component {
notificationIds,
restrictionIds,
indexerIds,
downloadClientIds,
autoTagIds,
seriesIds
} = this.props;
@ -73,6 +74,7 @@ class Tag extends Component {
notificationIds.length ||
restrictionIds.length ||
indexerIds.length ||
downloadClientIds.length ||
autoTagIds.length ||
seriesIds.length
);
@ -121,6 +123,11 @@ class Tag extends Component {
count={indexerIds.length}
/>
<TagInUse
label="download client"
count={downloadClientIds.length}
/>
<TagInUse
label="auto tagging"
count={autoTagIds.length}
@ -146,6 +153,7 @@ class Tag extends Component {
notificationIds={notificationIds}
restrictionIds={restrictionIds}
indexerIds={indexerIds}
downloadClientIds={downloadClientIds}
autoTagIds={autoTagIds}
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
@ -174,6 +182,7 @@ Tag.propTypes = {
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired,
autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired,
seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onConfirmDeleteTag: PropTypes.func.isRequired
@ -185,6 +194,7 @@ Tag.defaultProps = {
notificationIds: [],
restrictionIds: [],
indexerIds: [],
downloadClientIds: [],
autoTagIds: [],
seriesIds: []
};

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 TagConnector from './TagConnector';
import styles from './Tags.css';
@ -13,7 +15,9 @@ function Tags(props) {
if (!items.length) {
return (
<div>No tags have been added yet</div>
<Alert kind={kinds.INFO}>
No tags have been added yet
</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, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
import { fetchTagDetails } from 'Store/Actions/tagActions';
import Tags from './Tags';
@ -30,7 +30,8 @@ const mapDispatchToProps = {
dispatchFetchImportLists: fetchImportLists,
dispatchFetchNotifications: fetchNotifications,
dispatchFetchReleaseProfiles: fetchReleaseProfiles,
dispatchFetchIndexers: fetchIndexers
dispatchFetchIndexers: fetchIndexers,
dispatchFetchDownloadClients: fetchDownloadClients
};
class MetadatasConnector extends Component {
@ -45,7 +46,8 @@ class MetadatasConnector extends Component {
dispatchFetchImportLists,
dispatchFetchNotifications,
dispatchFetchReleaseProfiles,
dispatchFetchIndexers
dispatchFetchIndexers,
dispatchFetchDownloadClients
} = this.props;
dispatchFetchTagDetails();
@ -54,6 +56,7 @@ class MetadatasConnector extends Component {
dispatchFetchNotifications();
dispatchFetchReleaseProfiles();
dispatchFetchIndexers();
dispatchFetchDownloadClients();
}
//
@ -74,7 +77,8 @@ MetadatasConnector.propTypes = {
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchNotifications: PropTypes.func.isRequired,
dispatchFetchReleaseProfiles: PropTypes.func.isRequired,
dispatchFetchIndexers: PropTypes.func.isRequired
dispatchFetchIndexers: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);

View File

@ -31,9 +31,8 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl
export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
//
// Action Creators
@ -48,9 +47,8 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return {
@ -106,8 +104,8 @@ export default {
[TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'),
[BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk'),
[BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk')
[BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'),
[BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk')
},
//

View File

@ -31,9 +31,8 @@ export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList';
export const TEST_IMPORT_LIST = 'settings/importlists/testImportList';
export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList';
export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists';
export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists';
export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists';
export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists';
//
// Action Creators
@ -48,9 +47,8 @@ export const deleteImportList = createThunk(DELETE_IMPORT_LIST);
export const testImportList = createThunk(TEST_IMPORT_LIST);
export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS);
export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS);
export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS);
export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS);
export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
return {
@ -105,8 +103,8 @@ export default {
[TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
[CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section),
[TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist'),
[BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk'),
[BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk')
[BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk'),
[BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk')
},
//

View File

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
@ -11,8 +13,6 @@ import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import updateSectionState from 'Utilities/State/updateSectionState';
import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler';
//
// Variables
@ -34,9 +34,8 @@ export const DELETE_INDEXER = 'settings/indexers/deleteIndexer';
export const TEST_INDEXER = 'settings/indexers/testIndexer';
export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers';
export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
//
// Action Creators
@ -52,9 +51,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER);
export const testIndexer = createThunk(TEST_INDEXER);
export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return {
@ -110,9 +108,8 @@ export default {
[TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
[CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
[TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'),
[BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk'),
[BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk')
[BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'),
[BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk')
},
//

View File

@ -4,6 +4,7 @@ import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import { fetchTranslations as fetchAppTranslations } from 'Utilities/String/translate';
import createHandleActions from './Creators/createHandleActions';
function getDimensions(width, height) {
@ -41,7 +42,12 @@ export const defaultState = {
isReconnecting: false,
isDisconnected: false,
isRestarting: false,
isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen,
translations: {
isFetching: true,
isPopulated: false,
error: null
}
};
//
@ -53,6 +59,7 @@ export const SAVE_DIMENSIONS = 'app/saveDimensions';
export const SET_VERSION = 'app/setVersion';
export const SET_APP_VALUE = 'app/setAppValue';
export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible';
export const FETCH_TRANSLATIONS = 'app/fetchTranslations';
export const PING_SERVER = 'app/pingServer';
@ -66,6 +73,7 @@ export const setAppValue = createAction(SET_APP_VALUE);
export const showMessage = createAction(SHOW_MESSAGE);
export const hideMessage = createAction(HIDE_MESSAGE);
export const pingServer = createThunk(PING_SERVER);
export const fetchTranslations = createThunk(FETCH_TRANSLATIONS);
//
// Helpers
@ -127,6 +135,17 @@ function pingServerAfterTimeout(getState, dispatch) {
export const actionHandlers = handleThunks({
[PING_SERVER]: function(getState, payload, dispatch) {
pingServerAfterTimeout(getState, dispatch);
},
[FETCH_TRANSLATIONS]: async function(getState, payload, dispatch) {
const isFetchingComplete = await fetchAppTranslations();
dispatch(setAppValue({
translations: {
isFetching: false,
isPopulated: isFetchingComplete,
error: isFetchingComplete ? null : 'Failed to load translations from API'
}
}));
}
});

View File

@ -1,10 +1,13 @@
import _ from 'lodash';
import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import Icon from 'Components/Icon';
import episodeEntities from 'Episode/episodeEntities';
import { sortDirections } from 'Helpers/Props';
import { icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import translate from 'Utilities/String/translate';
import { updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
@ -109,6 +112,19 @@ export const defaultState = {
label: 'Formats',
isVisible: false
},
{
name: 'customFormatScore',
get columnLabel() {
return translate('CustomFormatScore');
},
label: React.createElement(Icon, {
name: icons.SCORE,
get title() {
return translate('CustomFormatScore');
}
}),
isVisible: false
},
{
name: 'status',
label: 'Status',

View File

@ -14,6 +14,7 @@ import * as importSeries from './importSeriesActions';
import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions';
import * as organizePreview from './organizePreviewActions';
import * as parse from './parseActions';
import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions';
import * as queue from './queueActions';
@ -44,6 +45,7 @@ export default [
interactiveImportActions,
oAuth,
organizePreview,
parse,
paths,
providerOptions,
queue,

View File

@ -47,6 +47,10 @@ export const defaultState = {
quality: function(item, direction) {
return item.qualityWeight || 0;
},
customFormats: function(item, direction) {
return item.customFormatScore;
}
}
};

View File

@ -0,0 +1,111 @@
import { Dispatch } from 'redux';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import AppState from 'App/State/AppState';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, update } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createClearReducer from './Creators/Reducers/createClearReducer';
interface FetchPayload {
title: string;
}
//
// Variables
export const section = 'parse';
let parseTimeout: number | null = null;
let abortCurrentRequest: (() => void) | null = null;
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
item: {},
};
//
// Actions Types
export const FETCH = 'parse/fetch';
export const CLEAR = 'parse/clear';
//
// Action Creators
export const fetch = createThunk(FETCH);
export const clear = createAction(CLEAR);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH]: function (
_getState: () => AppState,
payload: FetchPayload,
dispatch: Dispatch
) {
if (parseTimeout) {
clearTimeout(parseTimeout);
}
parseTimeout = window.setTimeout(async () => {
dispatch(set({ section, isFetching: true }));
if (abortCurrentRequest) {
abortCurrentRequest();
}
const { request, abortRequest } = createAjaxRequest({
url: '/parse',
data: {
title: payload.title,
},
});
try {
const data = await request;
dispatch(
batchActions([
update({ section, data }),
set({
section,
isFetching: false,
isPopulated: true,
error: null,
}),
])
);
} catch (error) {
dispatch(
set({
section,
isAdding: false,
isAdded: false,
addError: error,
})
);
}
abortCurrentRequest = abortRequest;
}, 300);
},
});
//
// Reducers
export const reducers = createHandleActions(
{
[CLEAR]: createClearReducer(section, defaultState),
},
defaultState,
section
);

View File

@ -1,7 +1,9 @@
import _ from 'lodash';
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';
@ -104,6 +106,15 @@ export const defaultState = {
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
columnLabel: 'Custom Format Score',
label: React.createElement(Icon, {
name: icons.SCORE,
title: 'Custom format score'
}),
isVisible: false
},
{
name: 'protocol',
label: 'Protocol',

View File

@ -4,23 +4,25 @@ import AppState from 'App/State/AppState';
type GetState = () => AppState;
type Thunk = (
getState: GetState,
identity: unknown,
identityFn: never,
dispatch: Dispatch
) => unknown;
const thunks: Record<string, Thunk> = {};
function identity(payload: unknown) {
return payload;
function identity<T, TResult>(payload: T): TResult {
return payload as unknown as TResult;
}
export function createThunk(type: string, identityFunction = identity) {
return function (payload: unknown = {}) {
return function <T>(payload?: T) {
return function (dispatch: Dispatch, getState: GetState) {
const thunk = thunks[type];
if (thunk) {
return thunk(getState, identityFunction(payload), dispatch);
const finalPayload = payload ?? {};
return thunk(getState, identityFunction(finalPayload), dispatch);
}
throw Error(`Thunk handler has not been registered for ${type}`);

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 LogsTableRow from './LogsTableRow';
function LogsTable(props) {
@ -81,9 +82,9 @@ function LogsTable(props) {
{
isPopulated && !error && !items.length &&
<div>
<Alert kind={kinds.INFO}>
No events found
</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 LogsNavMenu from '../LogsNavMenu';
import LogFilesTableRow from './LogFilesTableRow';
@ -117,7 +117,9 @@ class LogFiles extends Component {
{
!isFetching && !items.length &&
<div>No log files</div>
<Alert kind={kinds.INFO}>
No log files
</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';
@ -59,7 +60,9 @@ class Updates extends Component {
{
noUpdates &&
<div>No updates are available</div>
<Alert kind={kinds.INFO}>
No updates are available
</Alert>
}
{

View File

@ -1,28 +0,0 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
function getTranslations() {
return createAjaxRequest({
global: false,
dataType: 'json',
url: '/localization'
}).request;
}
let translations = {};
getTranslations().then((data) => {
translations = data.strings;
});
export default function translate(key, tokens) {
const translation = translations[key] || key;
if (tokens) {
return translation.replace(
/\{([a-z0-9]+?)\}/gi,
(match, tokenMatch) => String(tokens[tokenMatch]) ?? match
);
}
return translation;
}

View File

@ -0,0 +1,40 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
function getTranslations() {
return createAjaxRequest({
global: false,
dataType: 'json',
url: '/localization',
}).request;
}
let translations: Record<string, string> = {};
export async function fetchTranslations(): Promise<boolean> {
return new Promise(async (resolve) => {
try {
const data = await getTranslations();
translations = data.strings;
resolve(true);
} catch (error) {
resolve(false);
}
});
}
export default function translate(
key: string,
tokens?: Record<string, string | number | boolean>
) {
const translation = translations[key] || key;
if (tokens) {
return translation.replace(
/\{([a-z0-9]+?)\}/gi,
(match, tokenMatch) => String(tokens[tokenMatch]) ?? match
);
}
return translation;
}

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
@ -196,16 +197,16 @@ class CutoffUnmet extends Component {
{
!isFetching && error &&
<div>
<Alert kind={kinds.DANGER}>
Error fetching cutoff unmet
</div>
</Alert>
}
{
isPopulated && !error && !items.length &&
<div>
<Alert kind={kinds.INFO}>
No cutoff unmet items
</div>
</Alert>
}
{

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import ConfirmModal from 'Components/Modal/ConfirmModal';
@ -209,16 +210,16 @@ class Missing extends Component {
{
!isFetching && error &&
<div>
<Alert kind={kinds.DANGER}>
Error fetching missing items
</div>
</Alert>
}
{
isPopulated && !error && !items.length &&
<div>
<Alert kind={kinds.INFO}>
No missing items
</div>
</Alert>
}
{

View File

@ -0,0 +1,17 @@
import { createBrowserHistory } from 'history';
import React from 'react';
import { render } from 'react-dom';
import createAppStore from 'Store/createAppStore';
import App from './App/App';
import 'Diag/ConsoleApi';
export async function bootstrap() {
const history = createBrowserHistory();
const store = createAppStore(history);
render(
<App store={store} history={history} />,
document.getElementById('root')
);
}

View File

@ -48,7 +48,15 @@
/>
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css">
<!-- webpack bundles head -->
<script>
window.Sonarr = {
urlBase: '__URL_BASE__'
};
</script>
<% for (key in htmlWebpackPlugin.files.js) { %><script type="text/javascript" src="<%= htmlWebpackPlugin.files.js[key] %>" data-no-hash></script><% } %>
<% for (key in htmlWebpackPlugin.files.css) { %><link href="<%= htmlWebpackPlugin.files.css[key] %>" rel="stylesheet"></link><% } %>
<title>Sonarr</title>
@ -77,7 +85,4 @@
<div id="portal-root"></div>
<div id="root" class="root"></div>
</body>
<script src="/initialize.js" data-no-hash></script>
<!-- webpack bundles body -->
</html>

View File

@ -1,22 +0,0 @@
import { createBrowserHistory } from 'history';
import React from 'react';
import { render } from 'react-dom';
import createAppStore from 'Store/createAppStore';
import App from './App/App';
import './preload';
import './polyfills';
import 'Diag/ConsoleApi';
import 'Styles/globals.css';
import './index.css';
const history = createBrowserHistory();
const store = createAppStore(history);
render(
<App
store={store}
history={history}
/>,
document.getElementById('root')
);

19
frontend/src/index.ts Normal file
View File

@ -0,0 +1,19 @@
import './polyfills';
import 'Styles/globals.css';
import './index.css';
const initializeUrl = `${
window.Sonarr.urlBase
}/initialize.json?t=${Date.now()}`;
const response = await fetch(initializeUrl);
window.Sonarr = await response.json();
/* eslint-disable no-undef, @typescript-eslint/ban-ts-comment */
// @ts-ignore 2304
__webpack_public_path__ = `${window.Sonarr.urlBase}/`;
/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */
const { bootstrap } = await import('./bootstrap');
await bootstrap();

View File

@ -1,2 +0,0 @@
/* eslint no-undef: 0 */
__webpack_public_path__ = `${window.Sonarr.urlBase}/`;

View File

@ -1,11 +1,11 @@
{
"compilerOptions": {
"target": "es6",
"target": "esnext",
"allowJs": true,
"checkJs": false,
"baseUrl": "src",
"jsx": "react",
"module": "commonjs",
"module": "esnext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,

View File

@ -16,7 +16,7 @@
"author": "Team Sonarr",
"license": "GPL-3.0",
"readmeFilename": "readme.md",
"main": "index.js",
"main": "index.ts",
"browserslist": [
"defaults"
],
@ -101,6 +101,7 @@
"@types/react-router-dom": "5.3.3",
"@types/react-text-truncate": "0.14.1",
"@types/react-window": "1.8.5",
"@types/redux-actions": "2.6.2",
"@types/webpack-livereload-plugin": "^2.3.3",
"@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5",

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

@ -117,7 +117,7 @@ namespace NzbDrone.Console
{
System.Threading.Thread.Sleep(1000);
if (System.Console.KeyAvailable)
if (!System.Console.IsInputRedirected && System.Console.KeyAvailable)
{
break;
}

View File

@ -0,0 +1,41 @@
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 import_exclusion_typeFixture : MigrationTest<import_exclusion_type>
{
[Test]
public void should_alter_tvdbid_column()
{
var db = WithDapperMigrationTestDb(c =>
{
c.Insert.IntoTable("ImportListExclusions").Row(new
{
TvdbId = "1",
Title = "Some Series"
});
});
// Should be able to insert as int after migration
db.Execute("INSERT INTO ImportListExclusions (TvdbId, Title) VALUES (2, 'Some Other Series')");
var exclusions = db.Query<ImportListExclusions192>("SELECT * FROM ImportListExclusions");
exclusions.Should().HaveCount(2);
exclusions.First().TvdbId.Should().Be(1);
}
}
public class ImportListExclusions192
{
public int Id { get; set; }
public int TvdbId { get; set; }
public string Title { 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

@ -32,8 +32,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 episodes = Builder<Episode>.CreateListOfSize(2)
.TheFirst(1).With(s => s.Id = 12)

View File

@ -120,6 +120,8 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("[HatSubs] Anime Title 1004 [E63F2984].mkv", "Anime Title", 1004, 0, 0)]
[TestCase("Anime Title 100 S3 - 01 (1080p) [5A493522]", "Anime Title 100 S3", 1, 0, 0)]
[TestCase("[SubsPlease] Anime Title 100 S3 - 01 (1080p) [5A493522]", "Anime Title 100 S3", 1, 0, 0)]
[TestCase("[CameEsp] Another Anime 100 - Another 100 Anime - 01 [720p][ESP-ENG][mkv]", "Another Anime 100 - Another 100 Anime", 1, 0, 0)]
[TestCase("[SubsPlease] Another Anime 100 - Another 100 Anime - 01 (1080p) [4E6B4518].mkv", "Another Anime 100 - Another 100 Anime", 1, 0, 0)]
// [TestCase("", "", 0, 0, 0)]
public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber)

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