Merge branch 'Sonarr:develop' into putio-download

pull/6029/head
Michael Feinbier 1 year ago committed by GitHub
commit b8007391b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,5 +1,10 @@
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
[![Translated](https://translate.servarr.com/widgets/servarr/-/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Backers on Open Collective](https://opencollective.com/Sonarr/backers/badge.svg)](#backers)
[![Sponsors on Open Collective](https://opencollective.com/Sonarr/sponsors/badge.svg)](#sponsors)
[![Mega Sponsors on Open Collective](https://opencollective.com/Sonarr/megasponsors/badge.svg)](#mega-sponsors)
Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new episodes of your favorite shows and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available.
## Getting Started

@ -36,6 +36,7 @@ class Blocklist extends Component {
lastToggled: null,
selectedState: {},
isConfirmRemoveModalOpen: false,
isConfirmClearModalOpen: false,
items: props.items
};
}
@ -90,6 +91,19 @@ class Blocklist extends Component {
this.setState({ isConfirmRemoveModalOpen: false });
};
onClearBlocklistPress = () => {
this.setState({ isConfirmClearModalOpen: true });
};
onClearBlocklistConfirmed = () => {
this.props.onClearBlocklistPress();
this.setState({ isConfirmClearModalOpen: false });
};
onConfirmClearModalClose = () => {
this.setState({ isConfirmClearModalOpen: false });
};
//
// Render
@ -103,7 +117,6 @@ class Blocklist extends Component {
totalRecords,
isRemoving,
isClearingBlocklistExecuting,
onClearBlocklistPress,
...otherProps
} = this.props;
@ -111,7 +124,8 @@ class Blocklist extends Component {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen
isConfirmRemoveModalOpen,
isConfirmClearModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
@ -131,8 +145,9 @@ class Blocklist extends Component {
<PageToolbarButton
label={translate('Clear')}
iconName={icons.CLEAR}
isDisabled={!items.length}
isSpinning={isClearingBlocklistExecuting}
onPress={onClearBlocklistPress}
onPress={this.onClearBlocklistPress}
/>
</PageToolbarSection>
@ -215,6 +230,16 @@ class Blocklist extends Component {
onConfirm={this.onRemoveSelectedConfirmed}
onCancel={this.onConfirmRemoveModalClose}
/>
<ConfirmModal
isOpen={isConfirmClearModalOpen}
kind={kinds.DANGER}
title={translate('ClearBlocklist')}
message={translate('ClearBlocklistMessageText')}
confirmLabel={translate('Clear')}
onConfirm={this.onClearBlocklistConfirmed}
onCancel={this.onConfirmClearModalClose}
/>
</PageContent>
);
}

@ -231,7 +231,7 @@ function HistoryDetails(props) {
reasonMessage = translate('DeletedReasonManual');
break;
case 'MissingFromDisk':
reasonMessage = translate('DeletedReasonMissingFromDisk');
reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk');
break;
case 'Upgrade':
reasonMessage = translate('DeletedReasonUpgrade');

@ -15,6 +15,7 @@ import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import translate from 'Utilities/String/translate';
import HistoryFilterModal from './HistoryFilterModal';
import HistoryRowConnector from './HistoryRowConnector';
class History extends Component {
@ -52,6 +53,7 @@ class History extends Component {
columns,
selectedFilterKey,
filters,
customFilters,
totalRecords,
isEpisodesFetching,
isEpisodesPopulated,
@ -92,7 +94,8 @@ class History extends Component {
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
customFilters={customFilters}
filterModalConnectorComponent={HistoryFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
@ -163,8 +166,9 @@ History.propTypes = {
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number,
isEpisodesFetching: PropTypes.bool.isRequired,
isEpisodesPopulated: PropTypes.bool.isRequired,

@ -6,6 +6,7 @@ import withCurrentPage from 'Components/withCurrentPage';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import * as historyActions from 'Store/Actions/historyActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
@ -15,11 +16,13 @@ function createMapStateToProps() {
return createSelector(
(state) => state.history,
(state) => state.episodes,
(history, episodes) => {
createCustomFiltersSelector('history'),
(history, episodes, customFilters) => {
return {
isEpisodesFetching: episodes.isFetching,
isEpisodesPopulated: episodes.isPopulated,
episodesError: episodes.error,
customFilters,
...history
};
}

@ -39,19 +39,19 @@ function getIconKind(eventType) {
function getTooltip(eventType, data) {
switch (eventType) {
case 'grabbed':
return translate('GrabbedHistoryTooltip', { indexer: data.indexer, downloadClient: data.downloadClient });
return translate('EpisodeGrabbedTooltip', { indexer: data.indexer, downloadClient: data.downloadClient });
case 'seriesFolderImported':
return translate('SeriesFolderImportedTooltip');
case 'downloadFolderImported':
return translate('EpisodeImportedTooltip');
case 'downloadFailed':
return translate('DownloadFailedTooltip');
return translate('DownloadFailedEpisodeTooltip');
case 'episodeFileDeleted':
return translate('EpisodeFileDeletedTooltip');
case 'episodeFileRenamed':
return translate('EpisodeFileRenamedTooltip');
case 'downloadIgnored':
return translate('DownloadIgnoredTooltip');
return translate('DownloadIgnoredEpisodeTooltip');
default:
return translate('UnknownEventTooltip');
}

@ -0,0 +1,54 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setHistoryFilter } from 'Store/Actions/historyActions';
function createHistorySelector() {
return createSelector(
(state: AppState) => state.history.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.history.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface HistoryFilterModalProps {
isOpen: boolean;
}
export default function HistoryFilterModal(props: HistoryFilterModalProps) {
const sectionItems = useSelector(createHistorySelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'history';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setHistoryFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
@ -21,6 +22,7 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import QueueFilterModal from './QueueFilterModal';
import QueueOptionsConnector from './QueueOptionsConnector';
import QueueRowConnector from './QueueRowConnector';
import RemoveQueueItemsModal from './RemoveQueueItemsModal';
@ -151,11 +153,16 @@ class Queue extends Component {
isEpisodesPopulated,
episodesError,
columns,
selectedFilterKey,
filters,
customFilters,
count,
totalRecords,
isGrabbing,
isRemoving,
isRefreshMonitoredDownloadsExecuting,
onRefreshPress,
onFilterSelect,
...otherProps
} = this.props;
@ -218,6 +225,15 @@ class Queue extends Component {
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={QueueFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
@ -239,7 +255,11 @@ class Queue extends Component {
{
isAllPopulated && !hasError && !items.length ?
<Alert kind={kinds.INFO}>
{translate('QueueIsEmpty')}
{
selectedFilterKey !== 'all' && count > 0 ?
translate('QueueFilterHasNoItems') :
translate('QueueIsEmpty')
}
</Alert> :
null
}
@ -323,13 +343,22 @@ Queue.propTypes = {
isEpisodesPopulated: PropTypes.bool.isRequired,
episodesError: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
count: PropTypes.number.isRequired,
totalRecords: PropTypes.number,
isGrabbing: PropTypes.bool.isRequired,
isRemoving: PropTypes.bool.isRequired,
isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onGrabSelectedPress: PropTypes.func.isRequired,
onRemoveSelectedPress: PropTypes.func.isRequired
onRemoveSelectedPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
Queue.defaultProps = {
count: 0
};
export default Queue;

@ -7,6 +7,7 @@ import withCurrentPage from 'Components/withCurrentPage';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import * as queueActions from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@ -18,12 +19,16 @@ function createMapStateToProps() {
(state) => state.episodes,
(state) => state.queue.options,
(state) => state.queue.paged,
(state) => state.queue.status.item,
createCustomFiltersSelector('queue'),
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS),
(episodes, options, queue, isRefreshMonitoredDownloadsExecuting) => {
(episodes, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => {
return {
count: options.includeUnknownSeriesItems ? status.totalCount : status.count,
isEpisodesFetching: episodes.isFetching,
isEpisodesPopulated: episodes.isPopulated,
episodesError: episodes.error,
customFilters,
isRefreshMonitoredDownloadsExecuting,
...options,
...queue
@ -122,6 +127,10 @@ class QueueConnector extends Component {
this.props.setQueueSort({ sortKey });
};
onFilterSelect = (selectedFilterKey) => {
this.props.setQueueFilter({ selectedFilterKey });
};
onTableOptionChange = (payload) => {
this.props.setQueueTableOption(payload);
@ -156,6 +165,7 @@ class QueueConnector extends Component {
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onRefreshPress={this.onRefreshPress}
onGrabSelectedPress={this.onGrabSelectedPress}
@ -178,6 +188,7 @@ QueueConnector.propTypes = {
gotoQueueLastPage: PropTypes.func.isRequired,
gotoQueuePage: PropTypes.func.isRequired,
setQueueSort: PropTypes.func.isRequired,
setQueueFilter: PropTypes.func.isRequired,
setQueueTableOption: PropTypes.func.isRequired,
clearQueue: PropTypes.func.isRequired,
grabQueueItems: PropTypes.func.isRequired,

@ -81,4 +81,9 @@ QueueDetails.propTypes = {
progressBar: PropTypes.node.isRequired
};
QueueDetails.defaultProps = {
trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading'
};
export default QueueDetails;

@ -0,0 +1,54 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions';
function createQueueSelector() {
return createSelector(
(state: AppState) => state.queue.paged.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.queue.paged.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
interface QueueFilterModalProps {
isOpen: boolean;
}
export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'queue';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
dispatch(setQueueFilter(payload));
},
[dispatch]
);
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
import React from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import QueueStatus from './QueueStatus';
import styles from './QueueStatusCell.css';
@ -41,8 +40,8 @@ QueueStatusCell.propTypes = {
};
QueueStatusCell.defaultProps = {
trackedDownloadStatus: translate('Ok'),
trackedDownloadState: translate('Downloading')
trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading'
};
export default QueueStatusCell;

@ -120,7 +120,7 @@ class RemoveQueueItemModal extends Component {
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseHelpText')}
helpText={translate('BlocklistReleaseSearchEpisodeAgainHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>

@ -123,7 +123,7 @@ class RemoveQueueItemsModal extends Component {
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseHelpText')}
helpText={translate('BlocklistReleaseSearchEpisodeAgainHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>

@ -1,6 +1,9 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
@ -25,11 +28,13 @@ function TimeleftCell(props) {
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell
className={styles.timeleft}
title={translate('DelayingDownloadUntil', { date, time })}
>
-
<TableRowCell className={styles.timeleft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('DelayingDownloadUntil', { date, time })}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
</TableRowCell>
);
}
@ -39,11 +44,13 @@ function TimeleftCell(props) {
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell
className={styles.timeleft}
title={translate('RetryingDownloadOn', { date, time })}
>
-
<TableRowCell className={styles.timeleft}>
<Tooltip
anchor={<Icon name={icons.INFO} />}
tooltip={translate('RetryingDownloadOn', { date, time })}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
</TableRowCell>
);
}

@ -79,17 +79,17 @@ class ImportSeriesSelectFolder extends Component {
!error && isPopulated &&
<div>
<div className={styles.header}>
{translate('LibraryImportHeader')}
{translate('LibraryImportSeriesHeader')}
</div>
<div className={styles.tips}>
{translate('LibraryImportTips')}
<ul>
<li className={styles.tip}>
<InlineMarkdown data={translate('LibraryImportTipsQualityInFilename')} />
<InlineMarkdown data={translate('LibraryImportTipsQualityInEpisodeFilename')} />
</li>
<li className={styles.tip}>
<InlineMarkdown data={translate('LibraryImportTipsUseRootFolder', { goodFolderExample, badFolderExample })} />
<InlineMarkdown data={translate('LibraryImportTipsSeriesUseRootFolder', { goodFolderExample, badFolderExample })} />
</li>
<li className={styles.tip}>
{translate('LibraryImportTipsDontUseDownloadsFolder')}

@ -5,12 +5,13 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions';
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
createRootFoldersSelector(),
createSystemStatusSelector(),
(rootFolders, systemStatus) => {
return {

@ -0,0 +1,22 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import translate from 'Utilities/String/translate';
function SeriesMonitorNewItemsOptionsPopoverContent() {
return (
<DescriptionList>
<DescriptionListItem
title={translate('MonitorAllSeasons')}
data={translate('MonitorAllSeasonsDescription')}
/>
<DescriptionListItem
title={translate('MonitorNoNewSeasons')}
data={translate('MonitorNoNewSeasonsDescription')}
/>
</DescriptionList>
);
}
export default SeriesMonitorNewItemsOptionsPopoverContent;

@ -26,29 +26,39 @@ function SeriesMonitoringOptionsPopoverContent() {
data={translate('MonitorExistingEpisodesDescription')}
/>
<DescriptionListItem
title={translate('MonitorRecentEpisodes')}
data={translate('MonitorRecentEpisodesDescription')}
/>
<DescriptionListItem
title={translate('MonitorPilotEpisode')}
data={translate('MonitorPilotEpisodeDescription')}
/>
<DescriptionListItem
title={translate('MonitorFirstSeason')}
data={translate('MonitorFirstSeasonDescription')}
/>
<DescriptionListItem
title={translate('MonitorLatestSeason')}
data={translate('MonitorLatestSeasonDescription')}
title={translate('MonitorLastSeason')}
data={translate('MonitorLastSeasonDescription')}
/>
<DescriptionListItem
title={translate('MonitorSpecials')}
data={translate('MonitorSpecialsDescription')}
title={translate('MonitorSpecialEpisodes')}
data={translate('MonitorSpecialEpisodesDescription')}
/>
<DescriptionListItem
title={translate('UnmonitorSpecials')}
data={translate('UnmonitorSpecialsDescription')}
title={translate('UnmonitorSpecialEpisodes')}
data={translate('UnmonitorSpecialsEpisodesDescription')}
/>
<DescriptionListItem
title={translate('MonitorNone')}
data={translate('MonitorNoneDescription')}
title={translate('MonitorNoEpisodes')}
data={translate('MonitorNoEpisodesDescription')}
/>
</DescriptionList>
);

@ -8,17 +8,17 @@ function SeriesTypePopoverContent() {
<DescriptionList>
<DescriptionListItem
title={translate('Anime')}
data={translate('AnimeTypeDescription')}
data={translate('AnimeEpisodeTypeDescription')}
/>
<DescriptionListItem
title={translate('Daily')}
data={translate('DailyTypeDescription')}
data={translate('DailyEpisodeTypeDescription')}
/>
<DescriptionListItem
title={translate('Standard')}
data={translate('StandardTypeDescription')}
data={translate('StandardEpisodeTypeDescription')}
/>
</DescriptionList>
);

@ -65,12 +65,12 @@ function AppUpdatedModalContent(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AppUpdated', { appName: 'Sonarr' })}
{translate('AppUpdated')}
</ModalHeader>
<ModalBody>
<div>
<InlineMarkdown data={translate('AppUpdatedVersion', { appName: 'Sonarr', version })} blockClassName={styles.version} />
<InlineMarkdown data={translate('AppUpdatedVersion', { version })} blockClassName={styles.version} />
</div>
{

@ -28,11 +28,11 @@ function ConnectionLostModal(props) {
<ModalBody>
<div>
{translate('ConnectionLostToBackend', { appName: 'Sonarr' })}
{translate('ConnectionLostToBackend')}
</div>
<div className={styles.automatic}>
{translate('ConnectionLostReconnect', { appName: 'Sonarr' })}
{translate('ConnectionLostReconnect')}
</div>
</ModalBody>
<ModalFooter>

@ -1,4 +1,5 @@
import SortDirection from 'Helpers/Props/SortDirection';
import { FilterBuilderProp } from './AppState';
export interface Error {
responseJSON: {
@ -20,6 +21,10 @@ export interface PagedAppSectionState {
pageSize: number;
}
export interface AppSectionFilterState<T> {
filterBuilderProps: FilterBuilderProp<T>[];
}
export interface AppSectionSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;

@ -3,6 +3,7 @@ import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import ParseAppState from './ParseAppState';
import QueueAppState from './QueueAppState';
import RootFolderAppState from './RootFolderAppState';
@ -48,6 +49,7 @@ interface AppState {
commands: CommandAppState;
episodeFiles: EpisodeFilesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
parse: ParseAppState;
queue: QueueAppState;

@ -1,9 +1,10 @@
import AppSectionState from 'App/State/AppSectionState';
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
import { FilterBuilderProp } from './AppState';
interface CalendarAppState extends AppSectionState<Episode> {
filterBuilderProps: FilterBuilderProp<Episode>[];
}
interface CalendarAppState
extends AppSectionState<Episode>,
AppSectionFilterState<Episode> {}
export default CalendarAppState;

@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import History from 'typings/History';
interface HistoryAppState
extends AppSectionState<History>,
AppSectionFilterState<History> {}
export default HistoryAppState;

@ -1,43 +1,17 @@
import ModelBase from 'App/ModelBase';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import AppSectionState, { AppSectionItemState, Error } from './AppSectionState';
export interface StatusMessage {
title: string;
messages: string[];
}
export interface Queue extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
statusMessages: StatusMessage[];
errorMessage: string;
downloadId: string;
protocol: string;
downloadClient: string;
outputPath: string;
episodeHasFile: boolean;
seriesId?: number;
episodeId?: number;
seasonNumber?: number;
}
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
} from './AppSectionState';
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState extends AppSectionState<Queue> {
export interface QueuePagedAppState
extends AppSectionState<Queue>,
AppSectionFilterState<Queue> {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;

@ -23,13 +23,11 @@ function createFilterBuilderPropsSelector() {
);
}
interface SeriesIndexFilterModalProps {
interface CalendarFilterModalProps {
isOpen: boolean;
}
export default function CalendarFilterModal(
props: SeriesIndexFilterModalProps
) {
export default function CalendarFilterModal(props: CalendarFilterModalProps) {
const sectionItems = useSelector(createCalendarSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'calendar';

@ -25,7 +25,7 @@ function Legend(props) {
name="Finale"
icon={icons.INFO}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
tooltip={translate('CalendarLegendFinaleTooltip')}
tooltip={translate('CalendarLegendSeriesFinaleTooltip')}
/>
);
}
@ -58,7 +58,7 @@ function Legend(props) {
<div>
<LegendItem
status="unaired"
tooltip={translate('CalendarLegendUnairedTooltip')}
tooltip={translate('CalendarLegendEpisodeUnairedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
@ -66,7 +66,7 @@ function Legend(props) {
<LegendItem
status="unmonitored"
tooltip={translate('CalendarLegendUnmonitoredTooltip')}
tooltip={translate('CalendarLegendEpisodeUnmonitoredTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
@ -77,7 +77,7 @@ function Legend(props) {
<LegendItem
status="onAir"
name="On Air"
tooltip={translate('CalendarLegendOnAirTooltip')}
tooltip={translate('CalendarLegendEpisodeOnAirTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
@ -85,7 +85,7 @@ function Legend(props) {
<LegendItem
status="missing"
tooltip={translate('CalendarLegendMissingTooltip')}
tooltip={translate('CalendarLegendEpisodeMissingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
@ -95,7 +95,7 @@ function Legend(props) {
<div>
<LegendItem
status="downloading"
tooltip={translate('CalendarLegendDownloadingTooltip')}
tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
@ -103,7 +103,7 @@ function Legend(props) {
<LegendItem
status="downloaded"
tooltip={translate('CalendarLegendDownloadedTooltip')}
tooltip={translate('CalendarLegendEpisodeDownloadedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
@ -116,7 +116,7 @@ function Legend(props) {
icon={icons.INFO}
kind={kinds.INFO}
darken={true}
tooltip={translate('CalendarLegendPremiereTooltip')}
tooltip={translate('CalendarLegendSeriesPremiereTooltip')}
/>
{iconsToShow[0]}

@ -116,7 +116,7 @@ class CalendarLinkModalContent extends Component {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CalendarFeed', { appName: 'Sonarr' })}
{translate('CalendarFeed')}
</ModalHeader>
<ModalBody>
@ -128,7 +128,7 @@ class CalendarLinkModalContent extends Component {
type={inputTypes.CHECK}
name="unmonitored"
value={unmonitored}
helpText={translate('ICalIncludeUnmonitoredHelpText')}
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
@ -164,7 +164,7 @@ class CalendarLinkModalContent extends Component {
type={inputTypes.TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsHelpText')}
helpText={translate('ICalTagsSeriesHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>

@ -1,9 +1,7 @@
.description {
line-height: $lineHeight;
}
.description {
margin-left: 0;
line-height: $lineHeight;
overflow-wrap: break-word;
}
@media (min-width: 768px) {

@ -6,10 +6,13 @@ import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Prop
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
@ -57,9 +60,15 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.DATE:
return DateFilterBuilderRowValue;
case filterBuilderValueTypes.HISTORY_EVENT_TYPE:
return HistoryEventTypeFilterBuilderRowValue;
case filterBuilderValueTypes.INDEXER:
return IndexerFilterBuilderRowValueConnector;
case filterBuilderValueTypes.LANGUAGE:
return LanguageFilterBuilderRowValue;
case filterBuilderValueTypes.PROTOCOL:
return ProtocolFilterBuilderRowValue;
@ -69,6 +78,9 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.SERIES:
return SeriesFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES_STATUS:
return SeriesStatusFilterBuilderRowValue;

@ -0,0 +1,16 @@
import { FilterBuilderProp } from 'App/State/AppState';
interface FilterBuilderRowOnChangeProps {
name: string;
value: unknown[];
}
interface FilterBuilderRowValueProps {
filterType?: string;
filterValue: string | number | object | string[] | number[] | object[];
selectedFilterBuilderProp: FilterBuilderProp<unknown>;
sectionItem: unknown[];
onChange: (payload: FilterBuilderRowOnChangeProps) => void;
}
export default FilterBuilderRowValueProps;

@ -0,0 +1,51 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
const EVENT_TYPE_OPTIONS = [
{
id: 1,
get name() {
return translate('Grabbed');
},
},
{
id: 3,
get name() {
return translate('Imported');
},
},
{
id: 4,
get name() {
return translate('Failed');
},
},
{
id: 5,
get name() {
return translate('Deleted');
},
},
{
id: 6,
get name() {
return translate('Renamed');
},
},
{
id: 7,
get name() {
return translate('Ignored');
},
},
];
function HistoryEventTypeFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
return <FilterBuilderRowValue {...props} tagList={EVENT_TYPE_OPTIONS} />;
}
export default HistoryEventTypeFilterBuilderRowValue;

@ -0,0 +1,13 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
function LanguageFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
const { items } = useSelector(createLanguagesSelector());
return <FilterBuilderRowValue {...props} tagList={items} />;
}
export default LanguageFilterBuilderRowValue;

@ -0,0 +1,19 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import sortByName from 'Utilities/Array/sortByName';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
function SeriesFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const tagList = allSeries
.map((series) => ({ id: series.id, name: series.title }))
.sort(sortByName);
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default SeriesFilterBuilderRowValue;

@ -14,6 +14,7 @@ import FormInputHelpText from './FormInputHelpText';
import IndexerSelectInputConnector from './IndexerSelectInputConnector';
import KeyValueListInput from './KeyValueListInput';
import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput';
import MonitorNewItemsSelectInput from './MonitorNewItemsSelectInput';
import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector';
import PasswordInput from './PasswordInput';
@ -49,6 +50,9 @@ function getComponent(type) {
case inputTypes.MONITOR_EPISODES_SELECT:
return MonitorEpisodesSelectInput;
case inputTypes.MONITOR_NEW_ITEMS_SELECT:
return MonitorNewItemsSelectInput;
case inputTypes.NUMBER:
return NumberInput;

@ -2,8 +2,10 @@
display: flex;
justify-content: flex-end;
margin-right: $formLabelRightMarginWidth;
padding-top: 8px;
min-height: 35px;
text-align: end;
font-weight: bold;
line-height: 35px;
}
.hasError {

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React from 'react';
import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
import SelectInput from './SelectInput';
function MonitorNewItemsSelectInput(props) {
const {
includeNoChange,
includeMixed,
...otherProps
} = props;
const values = [...monitorNewItemsOptions];
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: 'No Change',
disabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
disabled: true
});
}
return (
<SelectInput
values={values}
{...otherProps}
/>
);
}
MonitorNewItemsSelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired,
includeMixed: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};
MonitorNewItemsSelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false
};
export default MonitorNewItemsSelectInput;

@ -37,6 +37,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.OAUTH;
case 'rootFolder':
return inputTypes.ROOT_FOLDER_SELECT;
case 'qualityProfile':
return inputTypes.QUALITY_PROFILE_SELECT;
default:
return inputTypes.TEXT;
}

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addRootFolder } from 'Store/Actions/rootFolderActions';
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import translate from 'Utilities/String/translate';
import RootFolderSelectInput from './RootFolderSelectInput';
@ -10,7 +11,7 @@ const ADD_NEW_KEY = 'addNew';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
createRootFoldersSelector(),
(state, { value }) => value,
(state, { includeMissingValue }) => includeMissingValue,
(state, { includeNoChange }) => includeNoChange,

@ -23,21 +23,21 @@ const seriesTypeOptions: ISeriesTypeOption[] = [
key: seriesTypes.STANDARD,
value: 'Standard',
get format() {
return translate('StandardTypeFormat', { format: 'S01E05' });
return translate('StandardEpisodeTypeFormat', { format: 'S01E05' });
},
},
{
key: seriesTypes.DAILY,
value: 'Daily / Date',
get format() {
return translate('DailyTypeFormat', { format: '2020-05-25' });
return translate('DailyEpisodeTypeFormat', { format: '2020-05-25' });
},
},
{
key: seriesTypes.ANIME,
value: 'Anime / Absolute',
get format() {
return translate('AnimeTypeFormat', { format: '005' });
return translate('AnimeEpisodeTypeFormat', { format: '005' });
},
},
];

@ -3,6 +3,7 @@ import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ModalContent.css';
function ModalContent(props) {
@ -28,6 +29,7 @@ function ModalContent(props) {
<Icon
name={icons.CLOSE}
size={18}
title={translate('Close')}
/>
</Link>
}

@ -81,6 +81,7 @@ class PageHeader extends Component {
aria-label={translate('Donate')}
to="https://sonarr.tv/donate.html"
size={14}
title={translate('Donate')}
/>
<PageHeaderActionsMenuConnector
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}

@ -24,6 +24,7 @@ function PageHeaderActionsMenu(props) {
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon
name={icons.INTERACTIVE}
title={translate('Menu')}
/>
</MenuButton>

@ -4,6 +4,7 @@ import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EpisodeDetailsModal from './EpisodeDetailsModal';
import styles from './EpisodeSearchCell.css';
@ -50,11 +51,13 @@ class EpisodeSearchCell extends Component {
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
title={translate('AutomaticSearch')}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
title={translate('InteractiveSearch')}
/>
<EpisodeDetailsModal

@ -34,7 +34,8 @@ function AuthenticationRequiredModalContent(props) {
authenticationMethod,
authenticationRequired,
username,
password
password,
passwordConfirmation
} = settings;
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
@ -63,7 +64,7 @@ function AuthenticationRequiredModalContent(props) {
className={styles.authRequiredAlert}
kind={kinds.WARNING}
>
{translate('AuthenticationRequiredWarning', { appName: 'Sonarr' })}
{translate('AuthenticationRequiredWarning')}
</Alert>
{
@ -76,7 +77,7 @@ function AuthenticationRequiredModalContent(props) {
type={inputTypes.SELECT}
name="authenticationMethod"
values={authenticationMethodOptions}
helpText={translate('AuthenticationMethodHelpText', { appName: 'Sonarr' })}
helpText={translate('AuthenticationMethodHelpText')}
helpTextWarning={authenticationMethod.value === 'none' ? translate('AuthenticationMethodHelpTextWarning') : undefined}
helpLink="https://wiki.servarr.com/sonarr/faq#forced-authentication"
onChange={onInputChange}
@ -120,6 +121,18 @@ function AuthenticationRequiredModalContent(props) {
{...password}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="passwordConfirmation"
onChange={onInputChange}
helpTextWarning={passwordConfirmation?.value ? undefined : translate('AuthenticationRequiredPasswordConfirmationHelpTextWarning')}
{...passwordConfirmation}
/>
</FormGroup>
</div> :
null
}

@ -2,10 +2,13 @@ export const BOOL = 'bool';
export const BYTES = 'bytes';
export const DATE = 'date';
export const DEFAULT = 'default';
export const HISTORY_EVENT_TYPE = 'historyEventType';
export const INDEXER = 'indexer';
export const LANGUAGE = 'language';
export const PROTOCOL = 'protocol';
export const QUALITY = 'quality';
export const QUALITY_PROFILE = 'qualityProfile';
export const SERIES = 'series';
export const SERIES_STATUS = 'seriesStatus';
export const SERIES_TYPES = 'seriesType';
export const TAG = 'tag';

@ -4,6 +4,7 @@ export const CHECK = 'check';
export const DEVICE = 'device';
export const KEY_VALUE_LIST = 'keyValueList';
export const MONITOR_EPISODES_SELECT = 'monitorEpisodesSelect';
export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect';
export const FLOAT = 'float';
export const NUMBER = 'number';
export const OAUTH = 'oauth';
@ -31,6 +32,7 @@ export const all = [
DEVICE,
KEY_VALUE_LIST,
MONITOR_EPISODES_SELECT,
MONITOR_NEW_ITEMS_SELECT,
FLOAT,
NUMBER,
OAUTH,

@ -69,8 +69,6 @@ interface SelectEpisodeModalContentProps {
seasonNumber?: number;
selectedDetails?: string;
isAnime: boolean;
sortKey?: string;
sortDirection?: string;
modalTitle: string;
onEpisodesSelect(selectedEpisodes: SelectedEpisode[]): unknown;
onModalClose(): unknown;
@ -86,8 +84,6 @@ function SelectEpisodeModalContent(props: SelectEpisodeModalContentProps) {
seasonNumber,
selectedDetails,
isAnime,
sortKey,
sortDirection,
modalTitle,
onEpisodesSelect,
onModalClose,
@ -97,9 +93,8 @@ function SelectEpisodeModalContent(props: SelectEpisodeModalContentProps) {
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const { isFetching, isPopulated, items, error } = useSelector(
episodesSelector()
);
const { isFetching, isPopulated, items, error, sortKey, sortDirection } =
useSelector(episodesSelector());
const dispatch = useDispatch();
const filterEpisodeNumber = parseInt(filter);

@ -139,7 +139,7 @@ function InteractiveSearch(props) {
{
errorMessage ?
<Fragment>
{translate('InteractiveSearchResultsFailedErrorMessage', { message: errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1) })}
{translate('InteractiveSearchResultsSeriesFailedErrorMessage', { message: errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1) })}
</Fragment> :
translate('EpisodeSearchResultsLoadError')
}

@ -309,7 +309,9 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
isOpen={isConfirmGrabModalOpen}
kind={kinds.WARNING}
title={translate('GrabRelease')}
message={translate('GrabReleaseMessageText', { title })}
message={translate('GrabReleaseUnknownSeriesOrEpisodeMessageText', {
title,
})}
confirmLabel={translate('Grab')}
onConfirm={onGrabConfirm}
onCancel={onGrabCancel}

@ -89,7 +89,7 @@ class DeleteSeriesModalContent extends Component {
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText={translate('AddListExclusionHelpText')}
helpText={translate('AddListExclusionSeriesHelpText')}
onChange={onDeleteOptionChange}
/>
</FormGroup>

@ -156,6 +156,12 @@
.headerContent {
padding: 15px;
}
.title {
font-weight: 300;
font-size: 30px;
line-height: 30px;
}
}
@media only screen and (max-width: $breakpointLarge) {

@ -45,11 +45,7 @@ const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images) {
const fanartImage = _.find(images, { coverType: 'fanart' });
if (fanartImage) {
// Remove protocol
return fanartImage.url.replace(/^https?:/, '');
}
return _.find(images, { coverType: 'fanart' })?.url;
}
function getExpandedState(newState) {
@ -194,7 +190,7 @@ class SeriesDetails extends Component {
genres,
tags,
year,
previousAiring,
lastAired,
isSaving,
isRefreshing,
isSearching,
@ -231,7 +227,7 @@ class SeriesDetails extends Component {
} = this.state;
const statusDetails = getSeriesStatusDetails(status);
const runningYears = statusDetails.title === translate('Ended') ? `${year}-${getDateYear(previousAiring)}` : `${year}-`;
const runningYears = statusDetails.title === translate('Ended') ? `${year}-${getDateYear(lastAired)}` : `${year}-`;
let episodeFilesCountMessage = translate('SeriesDetailsNoEpisodeFiles');
@ -715,6 +711,7 @@ SeriesDetails.propTypes = {
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
year: PropTypes.number.isRequired,
lastAired: PropTypes.string,
previousAiring: PropTypes.string,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,

@ -210,12 +210,15 @@ class SeriesDetailsSeason extends Component {
seasonNumber,
items,
columns,
sortKey,
sortDirection,
statistics,
isSaving,
isExpanded,
isSearching,
seriesMonitored,
isSmallScreen,
onSortPress,
onTableOptionChange,
onMonitorSeasonPress,
onSearchPress
@ -447,6 +450,9 @@ class SeriesDetailsSeason extends Component {
items.length ?
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onTableOptionChange={onTableOptionChange}
>
<TableBody>
@ -530,6 +536,8 @@ SeriesDetailsSeason.propTypes = {
seasonNumber: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string.isRequired,
sortDirection: PropTypes.oneOf(sortDirections.all),
statistics: PropTypes.object.isRequired,
isSaving: PropTypes.bool,
isExpanded: PropTypes.bool,
@ -537,6 +545,7 @@ SeriesDetailsSeason.propTypes = {
seriesMonitored: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
onMonitorSeasonPress: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired,
onMonitorEpisodePress: PropTypes.func.isRequired,

@ -4,8 +4,9 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import { setEpisodesTableOption, toggleEpisodesMonitored } from 'Store/Actions/episodeActions';
import { setEpisodesSort, setEpisodesTableOption, toggleEpisodesMonitored } from 'Store/Actions/episodeActions';
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
@ -15,7 +16,7 @@ import SeriesDetailsSeason from './SeriesDetailsSeason';
function createMapStateToProps() {
return createSelector(
(state, { seasonNumber }) => seasonNumber,
(state) => state.episodes,
createClientSideCollectionSelector('episodes'),
createSeriesSelector(),
createCommandsSelector(),
createDimensionsSelector(),
@ -27,11 +28,12 @@ function createMapStateToProps() {
}));
const episodesInSeason = episodes.items.filter((episode) => episode.seasonNumber === seasonNumber);
const sortedEpisodes = episodesInSeason.sort((a, b) => b.episodeNumber - a.episodeNumber);
return {
items: sortedEpisodes,
items: episodesInSeason,
columns: episodes.columns,
sortKey: episodes.sortKey,
sortDirection: episodes.sortDirection,
isSearching,
seriesMonitored: series.monitored,
path: series.path,
@ -45,6 +47,7 @@ const mapDispatchToProps = {
toggleSeasonMonitored,
toggleEpisodesMonitored,
setEpisodesTableOption,
setEpisodesSort,
executeCommand
};
@ -90,6 +93,13 @@ class SeriesDetailsSeasonConnector extends Component {
});
};
onSortPress = (sortKey, sortDirection) => {
this.props.setEpisodesSort({
sortKey,
sortDirection
});
};
//
// Render
@ -98,6 +108,7 @@ class SeriesDetailsSeasonConnector extends Component {
<SeriesDetailsSeason
{...this.props}
onTableOptionChange={this.onTableOptionChange}
onSortPress={this.onSortPress}
onMonitorSeasonPress={this.onMonitorSeasonPress}
onSearchPress={this.onSearchPress}
onMonitorEpisodePress={this.onMonitorEpisodePress}
@ -112,6 +123,7 @@ SeriesDetailsSeasonConnector.propTypes = {
toggleSeasonMonitored: PropTypes.func.isRequired,
toggleEpisodesMonitored: PropTypes.func.isRequired,
setEpisodesTableOption: PropTypes.func.isRequired,
setEpisodesSort: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};

@ -3,3 +3,7 @@
margin-right: auto;
}
.labelIcon {
margin-left: 8px;
}

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

@ -1,16 +1,19 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SeriesMonitorNewItemsOptionsPopoverContent from 'AddSeries/SeriesMonitorNewItemsOptionsPopoverContent';
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 Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
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 } from 'Helpers/Props';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
import translate from 'Utilities/String/translate';
import styles from './EditSeriesModalContent.css';
@ -73,6 +76,7 @@ class EditSeriesModalContent extends Component {
const {
monitored,
monitorNewItems,
seasonFolder,
qualityProfileId,
seriesType,
@ -94,12 +98,37 @@ class EditSeriesModalContent extends Component {
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText={translate('MonitoredHelpText')}
helpText={translate('MonitoredEpisodesHelpText')}
{...monitored}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('MonitorNewSeasons')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MonitorNewSeasons')}
body={<SeriesMonitorNewItemsOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_NEW_ITEMS_SELECT}
name="monitorNewItems"
helpText={translate('MonitorNewSeasonsHelpText')}
{...monitorNewItems}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('UseSeasonFolder')}</FormLabel>

@ -38,6 +38,7 @@ function createMapStateToProps() {
const seriesSettings = _.pick(series, [
'monitored',
'monitorNewItems',
'seasonFolder',
'qualityProfileId',
'seriesType',

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

@ -101,7 +101,7 @@ function SeriesIndexPosterOptionsModalContent(
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowTitleHelpText')}
helpText={translate('ShowSeriesTitleHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>

@ -98,7 +98,7 @@ function DeleteSeriesModalContent(props: DeleteSeriesModalContentProps) {
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText={translate('AddListExclusionHelpText')}
helpText={translate('AddListExclusionSeriesHelpText')}
onChange={onDeleteOptionChange}
/>
</FormGroup>

@ -14,6 +14,7 @@ import styles from './EditSeriesModalContent.css';
interface SavePayload {
monitored?: boolean;
monitorNewItems?: string;
qualityProfileId?: number;
seriesType?: string;
seasonFolder?: boolean;
@ -77,6 +78,7 @@ function EditSeriesModalContent(props: EditSeriesModalContentProps) {
const { seriesIds, onSavePress, onModalClose } = props;
const [monitored, setMonitored] = useState(NO_CHANGE);
const [monitorNewItems, setMonitorNewItems] = useState(NO_CHANGE);
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
NO_CHANGE
);
@ -95,6 +97,11 @@ function EditSeriesModalContent(props: EditSeriesModalContentProps) {
payload.monitored = monitored === 'monitored';
}
if (monitorNewItems !== NO_CHANGE) {
hasChanges = true;
payload.monitorNewItems = monitorNewItems;
}
if (qualityProfileId !== NO_CHANGE) {
hasChanges = true;
payload.qualityProfileId = qualityProfileId as number;
@ -124,6 +131,7 @@ function EditSeriesModalContent(props: EditSeriesModalContentProps) {
},
[
monitored,
monitorNewItems,
qualityProfileId,
seriesType,
seasonFolder,
@ -139,6 +147,9 @@ function EditSeriesModalContent(props: EditSeriesModalContentProps) {
case 'monitored':
setMonitored(value);
break;
case 'monitorNewItems':
setMonitorNewItems(value);
break;
case 'qualityProfileId':
setQualityProfileId(value);
break;
@ -199,6 +210,19 @@ function EditSeriesModalContent(props: EditSeriesModalContentProps) {
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MonitorNewItems')}</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_NEW_ITEMS_SELECT}
name="monitorNewItems"
value={monitorNewItems}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>

@ -15,7 +15,10 @@ function createSeriesQueueDetailsSelector(
(queueItems) => {
return queueItems.reduce(
(acc: SeriesQueueDetails, item) => {
if (item.seriesId !== seriesId) {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}

@ -7,12 +7,10 @@ function findImage(images, coverType) {
}
function getUrl(image, coverType, size) {
if (image) {
// Remove protocol
let url = image.url.replace(/^https?:/, '');
url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
const imageUrl = image?.url;
return url;
if (imageUrl) {
return imageUrl.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
}
}

@ -152,7 +152,7 @@ class CustomFormat extends Component {
isOpen={this.state.isDeleteCustomFormatModalOpen}
kind={kinds.DANGER}
title={translate('DeleteCustomFormat')}
message={translate('DeleteCustomFormatMessageText', [name])}
message={translate('DeleteCustomFormatMessageText', { customFormatName: name })}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteCustomFormat}

@ -147,7 +147,7 @@ class EditDownloadClientModalContent extends Component {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('DownloadClientTagHelpText')}
helpText={translate('DownloadClientSeriesTagHelpText')}
{...tags}
onChange={onInputChange}
/>

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

@ -54,7 +54,7 @@ class RemotePathMappings extends Component {
>
<Alert kind={kinds.INFO}>
<InlineMarkdown data={translate('RemotePathMappingsInfo', { app: 'Sonarr', wikiLink: 'https://wiki.servarr.com/sonarr/settings#remote-path-mappings' })} />
<InlineMarkdown data={translate('RemotePathMappingsInfo', { wikiLink: 'https://wiki.servarr.com/sonarr/settings#remote-path-mappings' })} />
</Alert>
<div className={styles.remotePathMappingsHeader}>

@ -124,6 +124,7 @@ class SecuritySettings extends Component {
authenticationRequired,
username,
password,
passwordConfirmation,
apiKey,
certificateValidation
} = settings;
@ -139,8 +140,8 @@ class SecuritySettings extends Component {
type={inputTypes.SELECT}
name="authenticationMethod"
values={authenticationMethodOptions}
helpText={translate('AuthenticationMethodHelpText', { appName: 'Sonarr' })}
helpTextWarning={translate('AuthenticationRequiredWarning', { appName: 'Sonarr' })}
helpText={translate('AuthenticationMethodHelpText')}
helpTextWarning={translate('AuthenticationRequiredWarning')}
onChange={onInputChange}
{...authenticationMethod}
/>
@ -193,6 +194,21 @@ class SecuritySettings extends Component {
null
}
{
authenticationEnabled ?
<FormGroup>
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="passwordConfirmation"
onChange={onInputChange}
{...passwordConfirmation}
/>
</FormGroup> :
null
}
<FormGroup>
<FormLabel>{translate('ApiKey')}</FormLabel>

@ -83,7 +83,7 @@ function UpdateSettings(props) {
type={inputTypes.CHECK}
name="updateAutomatically"
helpText={translate('UpdateAutomaticallyHelpText')}
helpTextWarning={updateMechanism.value === 'docker' ? translate('AutomaticUpdatesDisabledDocker', { appName: 'Sonarr' }) : undefined}
helpTextWarning={updateMechanism.value === 'docker' ? translate('AutomaticUpdatesDisabledDocker') : undefined}
onChange={onInputChange}
{...updateAutomatically}
/>

@ -56,7 +56,7 @@ class AddImportListModalContent extends Component {
<Alert kind={kinds.INFO}>
<div>
{translate('SupportedLists')}
{translate('SupportedListsSeries')}
</div>
<div>
{translate('SupportedListsMoreInfo')}

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
import SeriesMonitorNewItemsOptionsPopoverContent from 'AddSeries/SeriesMonitorNewItemsOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
@ -46,9 +47,11 @@ function EditImportListModalContent(props) {
implementationName,
name,
enableAutomaticAdd,
searchForMissingEpisodes,
minRefreshInterval,
shouldMonitor,
rootFolderPath,
monitorNewItems,
qualityProfileId,
seriesType,
seasonFolder,
@ -107,12 +110,24 @@ function EditImportListModalContent(props) {
<FormInputGroup
type={inputTypes.CHECK}
name="enableAutomaticAdd"
helpText={translate('EnableAutomaticAddHelpText')}
helpText={translate('EnableAutomaticAddSeriesHelpText')}
{...enableAutomaticAdd}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ImportListSearchForMissingEpisodes')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="searchForMissingEpisodes"
helpText={translate('ImportListSearchForMissingEpisodesHelpText')}
{...searchForMissingEpisodes}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Monitor')}
@ -138,6 +153,31 @@ function EditImportListModalContent(props) {
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('MonitorNewSeasons')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MonitorNewSeasons')}
body={<SeriesMonitorNewItemsOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_NEW_ITEMS_SELECT}
name="monitorNewItems"
helpText={translate('MonitorNewSeasonsHelpText')}
{...monitorNewItems}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>

@ -200,7 +200,7 @@ function EditIndexerModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('IndexerTagHelpText')}
helpText={translate('IndexerTagSeriesHelpText')}
{...tags}
onChange={onInputChange}
/>

@ -180,7 +180,7 @@ class MediaManagement extends Component {
<FormInputGroup
type={inputTypes.CHECK}
name="deleteEmptyFolders"
helpText={translate('DeleteEmptyFoldersHelpText')}
helpText={translate('DeleteEmptySeriesFoldersHelpText')}
onChange={onInputChange}
{...settings.deleteEmptyFolders}
/>
@ -257,7 +257,7 @@ class MediaManagement extends Component {
<FormInputGroup
type={inputTypes.CHECK}
name="copyUsingHardlinks"
helpText={translate('CopyUsingHardlinksHelpText')}
helpText={translate('CopyUsingHardlinksSeriesHelpText')}
helpTextWarning={translate('CopyUsingHardlinksHelpTextWarning')}
onChange={onInputChange}
{...settings.copyUsingHardlinks}
@ -305,7 +305,7 @@ class MediaManagement extends Component {
<FormInputGroup
type={inputTypes.CHECK}
name="importExtraFiles"
helpText={translate('ImportExtraFilesHelpText')}
helpText={translate('ImportExtraFilesEpisodeHelpText')}
onChange={onInputChange}
{...settings.importExtraFiles}
/>
@ -399,7 +399,7 @@ class MediaManagement extends Component {
<FormInputGroup
type={inputTypes.SELECT}
name="rescanAfterRefresh"
helpText={translate('RescanAfterRefreshHelpText')}
helpText={translate('RescanAfterRefreshSeriesHelpText')}
helpTextWarning={translate('RescanAfterRefreshHelpTextWarning')}
values={rescanAfterRefreshOptions}
onChange={onInputChange}

@ -82,13 +82,16 @@ const fileNameTokens = [
const seriesTokens = [
{ token: '{Series Title}', example: 'The Series Title\'s!' },
{ token: '{Series CleanTitle}', example: 'The Series Title\'s!' },
{ token: '{Series CleanTitleYear}', example: 'The Series Titles! 2010' },
{ token: '{Series TitleYear}', example: 'The Series Title\'s! (2010)' },
{ token: '{Series CleanTitleYear}', example: 'The Series Title\'s! 2010' },
{ token: '{Series TitleWithoutYear}', example: 'The Series Title\'s!' },
{ token: '{Series CleanTitleWithoutYear}', example: 'The Series Title\'s!' },
{ token: '{Series TitleThe}', example: 'Series Title\'s!, The' },
{ token: '{Series CleanTitleThe}', example: 'Series Title\'s!, The' },
{ token: '{Series TitleTheYear}', example: 'Series Title\'s!, The (2010)' },
{ token: '{Series CleanTitleTheYear}', example: 'Series Title\'s!, The 2010' },
{ token: '{Series TitleTheWithoutYear}', example: 'Series Title\'s!, The' },
{ token: '{Series TitleYear}', example: 'The Series Title\'s! (2010)' },
{ token: '{Series TitleWithoutYear}', example: 'Series Title\'s!' },
{ token: '{Series CleanTitleTheWithoutYear}', example: 'Series Title\'s!, The' },
{ token: '{Series TitleFirstCharacter}', example: 'S' },
{ token: '{Series Year}', example: '2010' }
];

@ -99,7 +99,7 @@ function EditNotificationModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('NotificationsTagsHelpText')}
helpText={translate('NotificationsTagsSeriesHelpText')}
{...tags}
onChange={onInputChange}
/>

@ -186,7 +186,7 @@ function EditDelayProfileModalContent(props) {
{
id === 1 ?
<Alert>
{translate('DefaultDelayProfile')}
{translate('DefaultDelayProfileSeries')}
</Alert> :
<FormGroup>
@ -196,7 +196,7 @@ function EditDelayProfileModalContent(props) {
type={inputTypes.TAG}
name="tags"
{...tags}
helpText={translate('DelayProfileTagsHelpText')}
helpText={translate('DelayProfileSeriesTagsHelpText')}
onChange={onInputChange}
/>
</FormGroup>

@ -203,7 +203,7 @@ class EditQualityProfileModalContent extends Component {
name="cutoff"
{...cutoff}
values={qualities}
helpText={translate('UpgradeUntilHelpText')}
helpText={translate('UpgradeUntilEpisodeHelpText')}
onChange={onCutoffChange}
/>
</FormGroup>
@ -237,7 +237,7 @@ class EditQualityProfileModalContent extends Component {
type={inputTypes.NUMBER}
name="cutoffFormatScore"
{...cutoffFormatScore}
helpText={translate('UpgradeUntilCustomFormatScoreHelpText')}
helpText={translate('UpgradeUntilCustomFormatScoreEpisodeHelpText')}
onChange={onInputChange}
/>
</FormGroup>
@ -281,7 +281,7 @@ class EditQualityProfileModalContent extends Component {
className={styles.deleteButtonContainer}
title={
isInUse ?
translate('QualityProfileInUse') :
translate('QualityProfileInUseSeriesListCollection') :
undefined
}
>

@ -126,7 +126,7 @@ function EditReleaseProfileModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('ReleaseProfileTagHelpText')}
helpText={translate('ReleaseProfileTagSeriesHelpText')}
{...tags}
onChange={onInputChange}
/>

@ -60,7 +60,7 @@ class QualityDefinitions extends Component {
<div className={styles.sizeLimitHelpTextContainer}>
<div className={styles.sizeLimitHelpText}>
{translate('QualityLimitsHelpText')}
{translate('QualityLimitsSeriesRuntimeHelpText')}
</div>
</div>
</PageSectionContent>

@ -110,7 +110,7 @@ function Settings() {
</Link>
<div className={styles.summary}>
{translate('MetadataSettingsSummary')}
{translate('MetadataSettingsSeriesSummary')}
</div>
<Link
@ -121,7 +121,7 @@ function Settings() {
</Link>
<div className={styles.summary}>
{translate('MetadataSourceSettingsSummary')}
{translate('MetadataSourceSettingsSeriesSummary')}
</div>
<Link

@ -6,6 +6,8 @@ import getSectionState from 'Utilities/State/getSectionState';
import { set, updateServerSideCollection } from '../baseActions';
function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) {
const [baseSection] = section.split('.');
return function(getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
@ -25,10 +27,13 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter
const {
selectedFilterKey,
filters,
customFilters
filters
} = sectionState;
const customFilters = getState().customFilters.items.filter((customFilter) => {
return customFilter.type === section || customFilter.type === baseSection;
});
const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters);
selectedFilters.forEach((filter) => {
@ -37,7 +42,8 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter
const promise = createAjaxRequest({
url,
data
data,
traditional: true
}).request;
promise.done((response) => {

@ -52,8 +52,6 @@ export const defaultState = {
selectedFilterKey: 'monitored',
customFilters: [],
filters: [
{
key: 'all',

@ -40,32 +40,38 @@ export const defaultState = {
{
name: 'episodeNumber',
label: '#',
isVisible: true
isVisible: true,
isSortable: true
},
{
name: 'title',
label: () => translate('Title'),
isVisible: true
isVisible: true,
isSortable: true
},
{
name: 'path',
label: () => translate('Path'),
isVisible: false
isVisible: false,
isSortable: true
},
{
name: 'relativePath',
label: () => translate('RelativePath'),
isVisible: false
isVisible: false,
isSortable: true
},
{
name: 'airDateUtc',
label: () => translate('AirDate'),
isVisible: true
isVisible: true,
isSortable: true
},
{
name: 'runtime',
label: () => translate('Runtime'),
isVisible: false
isVisible: false,
isSortable: true
},
{
name: 'languages',
@ -100,7 +106,8 @@ export const defaultState = {
{
name: 'size',
label: () => translate('Size'),
isVisible: false
isVisible: false,
isSortable: true
},
{
name: 'releaseGroup',
@ -119,7 +126,8 @@ export const defaultState = {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isVisible: false
isVisible: false,
isSortable: true
},
{
name: 'status',
@ -136,7 +144,9 @@ export const defaultState = {
};
export const persistState = [
'episodes.columns'
'episodes.columns',
'episodes.sortDirection',
'episodes.sortKey'
];
//

@ -1,7 +1,7 @@
import React from 'react';
import { createAction } from 'redux-actions';
import Icon from 'Components/Icon';
import { filterTypes, icons, sortDirections } from 'Helpers/Props';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@ -185,6 +185,33 @@ export const defaultState = {
}
]
}
],
filterBuilderProps: [
{
name: 'eventType',
label: () => translate('EventType'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE
},
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
}
]
};

@ -28,8 +28,8 @@ export const defaultState = {
error: null,
items: [],
originalItems: [],
sortKey: 'quality',
sortDirection: sortDirections.DESCENDING,
sortKey: 'relativePath',
sortDirection: sortDirections.ASCENDING,
recentFolders: [],
importMode: 'chooseImportMode',
sortPredicates: {

@ -3,7 +3,7 @@ import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import Icon from 'Components/Icon';
import { icons, sortDirections } from 'Helpers/Props';
import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@ -144,7 +144,7 @@ export const defaultState = {
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisibile: false
isVisible: false
},
{
name: 'outputPath',
@ -170,6 +170,43 @@ export const defaultState = {
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: 'All',
filters: []
}
],
filterBuilderProps: [
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
}
]
}
};
@ -179,7 +216,8 @@ export const persistState = [
'queue.paged.pageSize',
'queue.paged.sortKey',
'queue.paged.sortDirection',
'queue.paged.columns'
'queue.paged.columns',
'queue.paged.selectedFilterKey'
];
//
@ -204,6 +242,7 @@ export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
export const SET_QUEUE_SORT = 'queue/setQueueSort';
export const SET_QUEUE_FILTER = 'queue/setQueueFilter';
export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
export const SET_QUEUE_OPTION = 'queue/setQueueOption';
export const CLEAR_QUEUE = 'queue/clearQueue';
@ -228,6 +267,7 @@ export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
export const setQueueSort = createThunk(SET_QUEUE_SORT);
export const setQueueFilter = createThunk(SET_QUEUE_FILTER);
export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
export const setQueueOption = createAction(SET_QUEUE_OPTION);
export const clearQueue = createAction(CLEAR_QUEUE);
@ -279,7 +319,8 @@ export const actionHandlers = handleThunks({
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
[serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT
[serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT,
[serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER
},
fetchDataAugmenter
),

@ -168,9 +168,10 @@ export const filterPredicates = {
},
hasMissingSeason: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
const { seasons = [] } = item;
return seasons.some((season) => {
const hasMissingSeason = seasons.some((season) => {
const {
seasonNumber,
statistics = {}
@ -189,6 +190,8 @@ export const filterPredicates = {
episodeFileCount === 0
);
});
return predicate(hasMissingSeason, filterValue);
}
};
@ -347,7 +350,13 @@ export const filterBuilderProps = [
{
name: 'hasMissingSeason',
label: () => translate('HasMissingSeason'),
type: filterBuilderTypes.EXACT
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'year',
label: () => translate('Year'),
type: filterBuilderTypes.NUMBER
}
];

@ -72,6 +72,7 @@ function getInternalLink(source) {
function getTestLink(source, props) {
switch (source) {
case 'IndexerStatusCheck':
case 'IndexerLongTermStatusCheck':
return (
<SpinnerIconButton
name={icons.TEST}

@ -3,6 +3,7 @@ function getNewSeries(series, payload) {
const {
rootFolderPath,
monitor,
monitorNewItems,
qualityProfileId,
seriesType,
seasonFolder,
@ -19,6 +20,7 @@ function getNewSeries(series, payload) {
series.addOptions = addOptions;
series.monitored = true;
series.monitorNewItems = monitorNewItems;
series.qualityProfileId = qualityProfileId;
series.rootFolderPath = rootFolderPath;
series.seriesType = seriesType;

@ -0,0 +1,18 @@
import translate from 'Utilities/String/translate';
const monitorNewItemsOptions = [
{
key: 'all',
get value() {
return translate('MonitorAllSeasons');
}
},
{
key: 'none',
get value() {
return translate('MonitorNoNewSeasons');
}
}
];
export default monitorNewItemsOptions;

@ -25,6 +25,12 @@ const monitorOptions = [
return translate('MonitorExistingEpisodes');
}
},
{
key: 'recent',
get value() {
return translate('MonitorRecentEpisodes');
}
},
{
key: 'pilot',
get value() {
@ -38,27 +44,27 @@ const monitorOptions = [
}
},
{
key: 'latestSeason',
key: 'lastSeason',
get value() {
return translate('MonitorLatestSeason');
return translate('MonitorLastSeason');
}
},
{
key: 'monitorSpecials',
get value() {
return translate('MonitorSpecials');
return translate('MonitorSpecialEpisodes');
}
},
{
key: 'unmonitorSpecials',
get value() {
return translate('UnmonitorSpecials');
return translate('UnmonitorSpecialEpisodes');
}
},
{
key: 'none',
get value() {
return translate('MonitorNone');
return translate('MonitorNoEpisodes');
}
}
];

@ -25,15 +25,13 @@ export async function fetchTranslations(): Promise<boolean> {
export default function translate(
key: string,
tokens?: Record<string, string | number | boolean>
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)
);
}
tokens.appName = 'Sonarr';
return translation;
return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) =>
String(tokens[tokenMatch] ?? match)
);
}

@ -247,11 +247,11 @@ class CutoffUnmet extends Component {
<ConfirmModal
isOpen={isConfirmSearchAllCutoffUnmetModalOpen}
kind={kinds.DANGER}
title={translate('SearchForCutoffUnmet')}
title={translate('SearchForCutoffUnmetEpisodes')}
message={
<div>
<div>
{translate('SearchForCutoffUnmetConfirmationCount', { totalRecords })}
{translate('SearchForCutoffUnmetEpisodesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}

@ -260,11 +260,11 @@ class Missing extends Component {
<ConfirmModal
isOpen={isConfirmSearchAllMissingModalOpen}
kind={kinds.DANGER}
title={translate('SearchForAllMissing')}
title={translate('SearchForAllMissingEpisodes')}
message={
<div>
<div>
{translate('SearchForAllMissingConfirmationCount', { totalRecords })}
{translate('SearchForAllMissingEpisodesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}

@ -0,0 +1,28 @@
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from './CustomFormat';
export type HistoryEventType =
| 'grabbed'
| 'seriesFolderImported'
| 'downloadFolderImported'
| 'downloadFailed'
| 'episodeFileDeleted'
| 'episodeFileRenamed'
| 'downloadIgnored';
export default interface History {
episodeId: number;
seriesId: number;
sourceTitle: string;
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
customFormatScore: number;
qualityCutoffNotMet: boolean;
date: string;
downloadId: string;
eventType: HistoryEventType;
data: unknown;
id: number;
}

@ -0,0 +1,46 @@
import ModelBase from 'App/ModelBase';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
export type QueueTrackedDownloadStatus = 'ok' | 'warning' | 'error';
export type QueueTrackedDownloadState =
| 'downloading'
| 'importPending'
| 'importing'
| 'imported'
| 'failedPending'
| 'failed'
| 'ignored';
export interface StatusMessage {
title: string;
messages: string[];
}
interface Queue extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: QueueTrackedDownloadStatus;
trackedDownloadState: QueueTrackedDownloadState;
statusMessages: StatusMessage[];
errorMessage: string;
downloadId: string;
protocol: string;
downloadClient: string;
outputPath: string;
episodeHasFile: boolean;
seriesId?: number;
episodeId?: number;
seasonNumber?: number;
}
export default Queue;

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

Loading…
Cancel
Save