Merge branch 'develop' of https://github.com/Sonarr/Sonarr into issue-5148
This commit is contained in:
commit
cbf4c9f9cb
|
@ -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
|
||||
|
|
|
@ -11,6 +11,7 @@ on:
|
|||
- ".github/workflows/api_docs.yml"
|
||||
- "docs.sh"
|
||||
- "src/Sonarr.Api.*/**"
|
||||
- "src/Sonarr.Http/**"
|
||||
- "src/**/*.csproj"
|
||||
- "src/*"
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'actions': string;
|
||||
'customFormatScore': string;
|
||||
'progress': string;
|
||||
'protocol': string;
|
||||
'quality': string;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -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) });
|
||||
};
|
||||
|
||||
//
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,8 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 0 0 50%;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
}
|
|
@ -56,3 +56,9 @@
|
|||
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.customFormatScore {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 55px;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
interface CssExports {
|
||||
'audio': string;
|
||||
'audioLanguages': string;
|
||||
'customFormatScore': string;
|
||||
'episodeNumber': string;
|
||||
'episodeNumberAnime': string;
|
||||
'languages': string;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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;
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,12 @@
|
|||
.renameIcon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result {
|
||||
padding-top: 4px;
|
||||
}
|
9
frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts
vendored
Normal file
9
frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.css.d.ts
vendored
Normal 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;
|
|
@ -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;
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -134,6 +134,7 @@ const historyShape = {
|
|||
};
|
||||
|
||||
SettingsToolbarConnector.propTypes = {
|
||||
showSave: PropTypes.bool,
|
||||
hasPendingChanges: PropTypes.bool.isRequired,
|
||||
history: PropTypes.shape(historyShape).isRequired,
|
||||
onSavePress: PropTypes.func,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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: []
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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')
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -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')
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -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')
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -47,6 +47,10 @@ export const defaultState = {
|
|||
|
||||
quality: function(item, direction) {
|
||||
return item.qualityWeight || 0;
|
||||
},
|
||||
|
||||
customFormats: function(item, direction) {
|
||||
return item.customFormatScore;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -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',
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
);
|
|
@ -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();
|
|
@ -1,2 +0,0 @@
|
|||
/* eslint no-undef: 0 */
|
||||
__webpack_public_path__ = `${window.Sonarr.urlBase}/`;
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -117,7 +117,7 @@ namespace NzbDrone.Console
|
|||
{
|
||||
System.Threading.Thread.Sleep(1000);
|
||||
|
||||
if (System.Console.KeyAvailable)
|
||||
if (!System.Console.IsInputRedirected && System.Console.KeyAvailable)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue