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

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

View File

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

View File

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

View File

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

View File

@ -4,13 +4,14 @@ import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import Tooltip from 'Components/Tooltip/Tooltip';
import episodeEntities from 'Episode/episodeEntities'; import episodeEntities from 'Episode/episodeEntities';
import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages'; import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import { icons } from 'Helpers/Props'; import { icons, tooltipPositions } from 'Helpers/Props';
import SeriesTitleLink from 'Series/SeriesTitleLink'; import SeriesTitleLink from 'Series/SeriesTitleLink';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import HistoryDetailsModal from './Details/HistoryDetailsModal'; import HistoryDetailsModal from './Details/HistoryDetailsModal';
@ -210,7 +211,14 @@ class HistoryRow extends Component {
key={name} key={name}
className={styles.customFormatScore} className={styles.customFormatScore}
> >
{formatPreferredWordScore(customFormatScore)} <Tooltip
anchor={formatPreferredWordScore(
customFormatScore,
customFormats.length
)}
tooltip={<EpisodeFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell> </TableRowCell>
); );
} }
@ -294,4 +302,8 @@ HistoryRow.propTypes = {
onMarkAsFailedPress: PropTypes.func.isRequired onMarkAsFailedPress: PropTypes.func.isRequired
}; };
HistoryRow.defaultProps = {
customFormats: []
};
export default HistoryRow; export default HistoryRow;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -578,7 +578,7 @@ EnhancedSelectInput.propTypes = {
className: PropTypes.string, className: PropTypes.string,
disabledClassName: PropTypes.string, disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired, 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, values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -248,6 +248,8 @@ class SeriesDetails extends Component {
expandIcon = icons.EXPAND; expandIcon = icons.EXPAND;
} }
const fanartUrl = getFanartUrl(images);
return ( return (
<PageContent title={title}> <PageContent title={title}>
<PageToolbar> <PageToolbar>
@ -327,9 +329,11 @@ class SeriesDetails extends Component {
<div className={styles.header}> <div className={styles.header}>
<div <div
className={styles.backdrop} className={styles.backdrop}
style={{ style={
backgroundImage: `url(${getFanartUrl(images)})` fanartUrl ?
}} { backgroundImage: `url(${fanartUrl})` } :
null
}
> >
<div className={styles.backdropOverlay} /> <div className={styles.backdropOverlay} />
</div> </div>

View File

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

View File

@ -16,6 +16,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series'; import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css'; import styles from './TagsModalContent.css';
interface TagsModalContentProps { interface TagsModalContentProps {
@ -73,12 +74,12 @@ function TagsModalContent(props: TagsModalContentProps) {
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader>Tags</ModalHeader> <ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody> <ModalBody>
<Form> <Form>
<FormGroup> <FormGroup>
<FormLabel>Tags</FormLabel> <FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.TAG} type={inputTypes.TAG}
@ -89,7 +90,7 @@ function TagsModalContent(props: TagsModalContentProps) {
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormLabel>Apply Tags</FormLabel> <FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.SELECT} type={inputTypes.SELECT}
@ -97,17 +98,17 @@ function TagsModalContent(props: TagsModalContentProps) {
value={applyTags} value={applyTags}
values={applyTagsOptions} values={applyTagsOptions}
helpTexts={[ helpTexts={[
'How to apply tags to the selected series', translate('ApplyTagsHelpTextHowToApplySeries'),
'Add: Add the tags the existing list of tags', translate('ApplyTagsHelpTextAdd'),
'Remove: Remove the entered tags', translate('ApplyTagsHelpTextRemove'),
'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)', translate('ApplyTagsHelpTextReplace'),
]} ]}
onChange={onApplyTagsChange} onChange={onApplyTagsChange}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormLabel>Result</FormLabel> <FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}> <div className={styles.result}>
{seriesTags.map((id) => { {seriesTags.map((id) => {
@ -124,7 +125,11 @@ function TagsModalContent(props: TagsModalContentProps) {
return ( return (
<Label <Label
key={tag.id} key={tag.id}
title={removeTag ? 'Removing tag' : 'Existing tag'} title={
removeTag
? translate('RemovingTag')
: translate('ExistingTag')
}
kind={removeTag ? kinds.INVERSE : kinds.INFO} kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE} size={sizes.LARGE}
> >
@ -148,7 +153,7 @@ function TagsModalContent(props: TagsModalContentProps) {
return ( return (
<Label <Label
key={tag.id} key={tag.id}
title={'Adding tag'} title={translate('AddingTag')}
kind={kinds.SUCCESS} kind={kinds.SUCCESS}
size={sizes.LARGE} size={sizes.LARGE}
> >
@ -162,10 +167,10 @@ function TagsModalContent(props: TagsModalContentProps) {
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onPress={onModalClose}>Cancel</Button> <Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}> <Button kind={kinds.PRIMARY} onPress={onApplyPress}>
Apply {translate('Apply')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -23,6 +23,7 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions
import withScrollPosition from 'Components/withScrollPosition'; import withScrollPosition from 'Components/withScrollPosition';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection'; import SortDirection from 'Helpers/Props/SortDirection';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchQueueDetails } from 'Store/Actions/queueActions'; import { fetchQueueDetails } from 'Store/Actions/queueActions';
@ -246,6 +247,9 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
isSelectMode={isSelectMode} isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectAllMenuItem} overflowComponent={SeriesIndexSelectAllMenuItem}
/> />
<PageToolbarSeparator />
<ParseToolbarButton />
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection <PageToolbarSection

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal'; import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
@ -85,7 +86,7 @@ class DownloadClientSettings extends Component {
/> />
<PageToolbarButton <PageToolbarButton
label="Manage Clients" label={translate('ManageClients')}
iconName={icons.MANAGE} iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress} onPress={this.onManageDownloadClientsPress}
/> />

View File

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

View File

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

View File

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

View File

@ -50,6 +50,7 @@ class EditDownloadClientModalContent extends Component {
removeCompletedDownloads, removeCompletedDownloads,
removeFailedDownloads, removeFailedDownloads,
fields, fields,
tags,
message message
} = item; } = item;
@ -137,6 +138,18 @@ class EditDownloadClientModalContent extends Component {
/> />
</FormGroup> </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 <FieldSet
size={sizes.SMALL} size={sizes.SMALL}
legend="Completed Download Handling" legend="Completed Download Handling"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,9 +26,25 @@ interface ManageImportListsEditModalContentProps {
const NO_CHANGE = 'noChange'; const NO_CHANGE = 'noChange';
const autoAddOptions = [ const autoAddOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true }, {
{ key: 'enabled', value: 'Enabled' }, key: NO_CHANGE,
{ key: 'disabled', value: 'Disabled' }, get value() {
return translate('NoChange');
},
disabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
]; ];
function ManageImportListsEditModalContent( function ManageImportListsEditModalContent(
@ -87,7 +103,7 @@ function ManageImportListsEditModalContent(
setRootFolderPath(value); setRootFolderPath(value);
break; break;
default: default:
console.warn('EditImportListModalContent Unknown Input'); console.warn(`EditImportListModalContent Unknown Input: '${name}'`);
} }
}, },
[] []
@ -142,7 +158,9 @@ function ManageImportListsEditModalContent(
<ModalFooter className={styles.modalFooter}> <ModalFooter className={styles.modalFooter}>
<div className={styles.selected}> <div className={styles.selected}>
{translate('{count} import lists selected', { count: selectedCount })} {translate('CountImportListsSelected', {
count: selectedCount,
})}
</div> </div>
<div> <div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,6 +21,7 @@ function TagDetailsModalContent(props) {
notifications, notifications,
releaseProfiles, releaseProfiles,
indexers, indexers,
downloadClients,
autoTags, autoTags,
onModalClose, onModalClose,
onDeleteTagPress onDeleteTagPress
@ -179,6 +180,22 @@ function TagDetailsModalContent(props) {
null null
} }
{
downloadClients.length ?
<FieldSet legend="Download Clients">
{
downloadClients.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
{ {
autoTags.length ? autoTags.length ?
<FieldSet legend="Auto Tagging"> <FieldSet legend="Auto Tagging">
@ -228,6 +245,7 @@ TagDetailsModalContent.propTypes = {
notifications: PropTypes.arrayOf(PropTypes.object).isRequired, notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired, indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
autoTags: PropTypes.arrayOf(PropTypes.object).isRequired, autoTags: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired,
onDeleteTagPress: PropTypes.func.isRequired onDeleteTagPress: PropTypes.func.isRequired

View File

@ -77,6 +77,14 @@ function createMatchingIndexersSelector() {
); );
} }
function createMatchingDownloadClientsSelector() {
return createSelector(
(state, { downloadClientIds }) => downloadClientIds,
(state) => state.settings.downloadClients.items,
findMatchingItems
);
}
function createMatchingAutoTagsSelector() { function createMatchingAutoTagsSelector() {
return createSelector( return createSelector(
(state, { autoTagIds }) => autoTagIds, (state, { autoTagIds }) => autoTagIds,
@ -93,8 +101,9 @@ function createMapStateToProps() {
createMatchingNotificationsSelector(), createMatchingNotificationsSelector(),
createMatchingReleaseProfilesSelector(), createMatchingReleaseProfilesSelector(),
createMatchingIndexersSelector(), createMatchingIndexersSelector(),
createMatchingDownloadClientsSelector(),
createMatchingAutoTagsSelector(), createMatchingAutoTagsSelector(),
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers, autoTags) => { (series, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients, autoTags) => {
return { return {
series, series,
delayProfiles, delayProfiles,
@ -102,6 +111,7 @@ function createMapStateToProps() {
notifications, notifications,
releaseProfiles, releaseProfiles,
indexers, indexers,
downloadClients,
autoTags autoTags
}; };
} }

View File

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

View File

@ -1,7 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet'; import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent'; import PageSectionContent from 'Components/Page/PageSectionContent';
import { kinds } from 'Helpers/Props';
import TagConnector from './TagConnector'; import TagConnector from './TagConnector';
import styles from './Tags.css'; import styles from './Tags.css';
@ -13,7 +15,9 @@ function Tags(props) {
if (!items.length) { if (!items.length) {
return ( return (
<div>No tags have been added yet</div> <Alert kind={kinds.INFO}>
No tags have been added yet
</Alert>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
import _ from 'lodash'; import _ from 'lodash';
import React from 'react';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions'; import { batchActions } from 'redux-batched-actions';
import Icon from 'Components/Icon';
import episodeEntities from 'Episode/episodeEntities'; import episodeEntities from 'Episode/episodeEntities';
import { sortDirections } from 'Helpers/Props'; import { icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import translate from 'Utilities/String/translate';
import { updateItem } from './baseActions'; import { updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler'; import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
@ -109,6 +112,19 @@ export const defaultState = {
label: 'Formats', label: 'Formats',
isVisible: false 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', name: 'status',
label: 'Status', label: 'Status',

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
import _ from 'lodash'; import _ from 'lodash';
import React from 'react';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-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 { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@ -104,6 +106,15 @@ export const defaultState = {
isSortable: false, isSortable: false,
isVisible: true isVisible: true
}, },
{
name: 'customFormatScore',
columnLabel: 'Custom Format Score',
label: React.createElement(Icon, {
name: icons.SCORE,
title: 'Custom format score'
}),
isVisible: false
},
{ {
name: 'protocol', name: 'protocol',
label: 'Protocol', label: 'Protocol',

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Label from 'Components/Label'; import Label from 'Components/Label';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
@ -59,7 +60,9 @@ class Updates extends Component {
{ {
noUpdates && noUpdates &&
<div>No updates are available</div> <Alert kind={kinds.INFO}>
No updates are available
</Alert>
} }
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,7 +48,15 @@
/> />
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css"> <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> <title>Sonarr</title>
@ -77,7 +85,4 @@
<div id="portal-root"></div> <div id="portal-root"></div>
<div id="root" class="root"></div> <div id="root" class="root"></div>
</body> </body>
<script src="/initialize.js" data-no-hash></script>
<!-- webpack bundles body -->
</html> </html>

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -121,6 +121,11 @@ namespace NzbDrone.Common.Serializer
return JsonConvert.SerializeObject(obj, SerializerSettings); 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) public static void Serialize<TModel>(TModel model, TextWriter outputStream)
{ {
var jsonTextWriter = new JsonTextWriter(outputStream); var jsonTextWriter = new JsonTextWriter(outputStream);

View File

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

View File

@ -0,0 +1,41 @@
using System.Linq;
using Dapper;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class import_exclusion_typeFixture : MigrationTest<import_exclusion_type>
{
[Test]
public void should_alter_tvdbid_column()
{
var db = WithDapperMigrationTestDb(c =>
{
c.Insert.IntoTable("ImportListExclusions").Row(new
{
TvdbId = "1",
Title = "Some Series"
});
});
// Should be able to insert as int after migration
db.Execute("INSERT INTO ImportListExclusions (TvdbId, Title) VALUES (2, 'Some Other Series')");
var exclusions = db.Query<ImportListExclusions192>("SELECT * FROM ImportListExclusions");
exclusions.Should().HaveCount(2);
exclusions.First().TvdbId.Should().Be(1);
}
}
public class ImportListExclusions192
{
public int Id { get; set; }
public int TvdbId { get; set; }
public string Title { get; set; }
}
}

View File

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

View File

@ -32,8 +32,8 @@ namespace NzbDrone.Core.Test.Download
.Returns(_downloadClients); .Returns(_downloadClients);
Mocker.GetMock<IProvideDownloadClient>() Mocker.GetMock<IProvideDownloadClient>()
.Setup(v => v.GetDownloadClient(It.IsAny<DownloadProtocol>(), It.IsAny<int>(), It.IsAny<bool>())) .Setup(v => v.GetDownloadClient(It.IsAny<DownloadProtocol>(), It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<HashSet<int>>()))
.Returns<DownloadProtocol, int, bool>((v, i, f) => _downloadClients.FirstOrDefault(d => d.Protocol == v)); .Returns<DownloadProtocol, int, bool, HashSet<int>>((v, i, f, t) => _downloadClients.FirstOrDefault(d => d.Protocol == v));
var episodes = Builder<Episode>.CreateListOfSize(2) var episodes = Builder<Episode>.CreateListOfSize(2)
.TheFirst(1).With(s => s.Id = 12) .TheFirst(1).With(s => s.Id = 12)

View File

@ -120,6 +120,8 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("[HatSubs] Anime Title 1004 [E63F2984].mkv", "Anime Title", 1004, 0, 0)] [TestCase("[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("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("[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)] // [TestCase("", "", 0, 0, 0)]
public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber) 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