Use react-query for interactive search

New: Filter Interactive Search results by rejection reason
This commit is contained in:
Mark McDowall 2025-11-15 14:58:29 -08:00
parent 9b756df4bf
commit 8f95849e9b
41 changed files with 1031 additions and 924 deletions

View file

@ -15,7 +15,6 @@ import OAuthAppState from './OAuthAppState';
import OrganizePreviewAppState from './OrganizePreviewAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import ReleasesAppState from './ReleasesAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
@ -32,6 +31,7 @@ export interface FilterBuilderProp<T> {
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
}
// TODO: Make generic so key can be keyof T
export interface PropertyFilter {
key: string;
value: string | string[] | number[] | boolean[] | DateFilterValue;
@ -87,7 +87,6 @@ interface AppState {
organizePreview: OrganizePreviewAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
releases: ReleasesAppState;
series: SeriesAppState;
seriesHistory: SeriesHistoryAppState;
seriesIndex: SeriesIndexAppState;

View file

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

View file

@ -1,17 +0,0 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import { TagDetail } from 'Tags/useTagDetails';
import { Tag } from 'Tags/useTags';
export interface TagDetailAppState
extends AppSectionState<TagDetail>,
AppSectionDeleteState,
AppSectionSaveState {}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {
details: TagDetailAppState;
}
export default TagsAppState;

View file

@ -9,7 +9,7 @@ const protocols = [
];
type BoolFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, boolean>,
FilterBuilderRowValueProps<T, boolean, string>,
'tagList'
>;

View file

@ -33,7 +33,7 @@ function isInFilter(filterType: FilterType) {
interface DateFilterBuilderRowValueProps<T>
extends Omit<
FilterBuilderRowValueProps<T, string>,
FilterBuilderRowValueProps<T, string, string>,
'filterValue' | 'onChange'
> {
filterValue: string | DateFilterValue;

View file

@ -8,7 +8,7 @@ import FilterBuilderRowValue, {
} from './FilterBuilderRowValue';
type DefaultFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
FilterBuilderRowValueProps<T, string, string>,
'tagList'
>;

View file

@ -80,34 +80,41 @@ function getValue<T>(
return input;
}
interface FreeFormValue {
export interface FreeFormValue {
name: string;
}
export interface FilterBuilderTag<V extends string | number | boolean>
extends TagBase {
id: V;
name: string | number;
export interface FilterBuilderTag<
TId extends string | number | boolean,
TName extends string | number
> extends TagBase {
id: TId;
name: TName;
}
export interface FilterBuilderRowValueProps<
T,
V extends string | number | boolean
V extends string | number | boolean,
TagName extends string | number
> {
filterType: FilterType;
filterValue: V[];
sectionItems: T[];
selectedFilterBuilderProp: FilterBuilderProp<T>;
tagList: FilterBuilderTag<V>[];
tagList: FilterBuilderTag<V, TagName>[];
onChange: InputOnChange<V[]>;
}
function FilterBuilderRowValue<T, V extends string | number | boolean>({
function FilterBuilderRowValue<
T,
V extends string | number | boolean,
TagName extends string | number
>({
filterValue = [],
selectedFilterBuilderProp,
tagList,
onChange,
}: FilterBuilderRowValueProps<T, V>) {
}: FilterBuilderRowValueProps<T, V, TagName>) {
const hasItems = !!tagList.length;
const tags = useMemo(() => {
@ -129,7 +136,7 @@ function FilterBuilderRowValue<T, V extends string | number | boolean>({
}, [filterValue, tagList, selectedFilterBuilderProp, hasItems]);
const handleTagAdd = useCallback(
(tag: FilterBuilderTag<V> | FreeFormValue) => {
(tag: FilterBuilderTag<V, TagName> | FreeFormValue) => {
if ('id' in tag) {
onChange({
name: NAME,
@ -172,6 +179,7 @@ function FilterBuilderRowValue<T, V extends string | number | boolean>({
delimiters={['Tab', 'Enter']}
minQueryLength={0}
tagComponent={FilterBuilderRowValueTag}
// @ts-expect-error - TS is having trouble inferring the generic type here
onTagAdd={handleTagAdd}
onTagDelete={handleTagDelete}
/>

View file

@ -44,7 +44,7 @@ const EVENT_TYPE_OPTIONS = [
];
type QualityProfileFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
FilterBuilderRowValueProps<T, number, string>,
'tagList'
>;

View file

@ -7,7 +7,7 @@ import FilterBuilderRowValue, {
} from './FilterBuilderRowValue';
type IndexerFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
FilterBuilderRowValueProps<T, number, string>,
'tagList'
>;

View file

@ -6,7 +6,7 @@ import FilterBuilderRowValue, {
} from './FilterBuilderRowValue';
type LanguageFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
FilterBuilderRowValueProps<T, number, string>,
'tagList'
>;

View file

@ -9,7 +9,7 @@ const protocols = [
];
type ProtocolFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
FilterBuilderRowValueProps<T, string, string>,
'tagList'
>;

View file

@ -8,7 +8,7 @@ import FilterBuilderRowValue, {
} from './FilterBuilderRowValue';
type QualityFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
FilterBuilderRowValueProps<T, number, string>,
'tagList'
>;

View file

@ -17,7 +17,7 @@ function createQualityProfilesSelector() {
}
type QualityProfileFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
FilterBuilderRowValueProps<T, number, string>,
'tagList'
>;

View file

@ -62,7 +62,7 @@ const statusTagList = [
];
type QueueStatusFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
FilterBuilderRowValueProps<T, string, string>,
'tagList'
>;

View file

@ -26,7 +26,7 @@ const seasonsMonitoredStatusList = [
];
type SeasonsMonitoredStatusFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
FilterBuilderRowValueProps<T, string, string>,
'tagList'
>;

View file

@ -8,7 +8,7 @@ import FilterBuilderRowValue, {
} from './FilterBuilderRowValue';
type SeriesFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
FilterBuilderRowValueProps<T, number, string>,
'tagList'
>;

View file

@ -32,7 +32,7 @@ const statusTagList = [
];
type SeriesStatusFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
FilterBuilderRowValueProps<T, string, string>,
'tagList'
>;

View file

@ -26,7 +26,7 @@ const seriesTypeList = [
];
type SeriesTypeFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, string>,
FilterBuilderRowValueProps<T, string, string>,
'tagList'
>;

View file

@ -5,7 +5,7 @@ import FilterBuilderRowValue, {
} from './FilterBuilderRowValue';
type TagFilterBuilderRowValueProps<T> = Omit<
FilterBuilderRowValueProps<T, number>,
FilterBuilderRowValueProps<T, number, string>,
'tagList'
>;

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import Button from 'Components/Link/Button';
@ -14,10 +14,6 @@ import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
import Series from 'Series/Series';
import useSeries from 'Series/useSeries';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import {
cancelFetchReleases,
clearReleases,
} from 'Store/Actions/releaseActions';
import translate from 'Utilities/String/translate';
import EpisodeHistory from './History/EpisodeHistory';
import EpisodeSearch from './Search/EpisodeSearch';
@ -96,15 +92,6 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
[episodeEntity, episodeId, dispatch]
);
useEffect(() => {
return () => {
// Clear pending releases here, so we can reshow the search
// results even after switching tabs.
dispatch(cancelFetchReleases());
dispatch(clearReleases());
};
}, [dispatch]);
const seriesLink = `/series/${titleSlug}`;
return (

View file

@ -1,11 +1,11 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { useDispatch } from 'react-redux';
import * as commandNames from 'Commands/commandNames';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import { icons, kinds, sizes } from 'Helpers/Props';
import InteractiveSearch from 'InteractiveSearch/InteractiveSearch';
import useReleases from 'InteractiveSearch/useReleases';
import { executeCommand } from 'Store/Actions/commandActions';
import translate from 'Utilities/String/translate';
import styles from './EpisodeSearch.css';
@ -22,10 +22,10 @@ function EpisodeSearch({
onModalClose,
}: EpisodeSearchProps) {
const dispatch = useDispatch();
const { isPopulated } = useSelector((state: AppState) => state.releases);
const { isFetched } = useReleases({ episodeId });
const [isInteractiveSearchOpen, setIsInteractiveSearchOpen] = useState(
startInteractiveSearch || isPopulated
startInteractiveSearch || isFetched
);
const handleQuickSearchPress = useCallback(() => {

View file

@ -85,25 +85,7 @@ export const createOptionsStore = <T extends TSettings>(
}) => {
// @ts-expect-error - Cannot verify if T has sortKey and sortDirection
store.setState((state) => {
if ('sortKey' in state === false || 'sortDirection' in state === false) {
return state;
}
let newSortDirection = sortDirection;
if (sortDirection == null) {
if (state.sortKey === sortKey) {
newSortDirection =
state.sortDirection === 'ascending' ? 'descending' : 'ascending';
} else {
newSortDirection = state.sortDirection;
}
}
return {
sortKey,
sortDirection: newSortDirection,
};
return applySort(state, sortKey, sortDirection);
});
};
@ -188,3 +170,29 @@ const mergeColumns = <T extends { columns: Column[] }>(
columns,
};
};
export const applySort = <T extends TSettings>(
state: T,
sortKey: string,
sortDirection: SortDirection | undefined
) => {
if ('sortKey' in state === false || 'sortDirection' in state === false) {
return state;
}
let newSortDirection = sortDirection;
if (sortDirection == null) {
if (state.sortKey === sortKey) {
newSortDirection =
state.sortDirection === 'ascending' ? 'descending' : 'ascending';
} else {
newSortDirection = state.sortDirection;
}
}
return {
sortKey,
sortDirection: newSortDirection,
};
};

View file

@ -1,175 +1,59 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import ReleasesAppState from 'App/State/ReleasesAppState';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, sortDirections } from 'Helpers/Props';
import { align, kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
fetchReleases,
grabRelease,
setEpisodeReleasesFilter,
setReleasesSort,
setSeasonReleasesFilter,
} from 'Store/Actions/releaseActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import InteractiveSearchFilterModal from './InteractiveSearchFilterModal';
import InteractiveSearchPayload from './InteractiveSearchPayload';
import InteractiveSearchRow from './InteractiveSearchRow';
import InteractiveSearchType from './InteractiveSearchType';
import { setReleaseOption, useReleaseOptions } from './releaseOptionsStore';
import useReleases, { FILTERS, setReleaseSort } from './useReleases';
import styles from './InteractiveSearch.css';
const columns: Column[] = [
{
name: 'protocol',
label: () => translate('Source'),
isSortable: true,
isVisible: true,
},
{
name: 'age',
label: () => translate('Age'),
isSortable: true,
isVisible: true,
},
{
name: 'title',
label: () => translate('Title'),
isSortable: true,
isVisible: true,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: true,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: true,
},
{
name: 'peers',
label: () => translate('Peers'),
isSortable: true,
isVisible: true,
},
{
name: 'languageWeight',
label: () => translate('Languages'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityWeight',
label: () => translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'indexerFlags',
label: React.createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
title: () => translate('Rejections'),
}),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true,
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true,
},
];
interface InteractiveSearchProps {
type: InteractiveSearchType;
searchPayload: InteractiveSearchPayload;
}
function InteractiveSearch({ type, searchPayload }: InteractiveSearchProps) {
const customFilters = useSelector(createCustomFiltersSelector('releases'));
const { columns } = useReleaseOptions();
const {
isFetching,
isPopulated,
isFetched,
error,
items,
data,
totalItems,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
}: ReleasesAppState & ClientSideCollectionAppState = useSelector(
createClientSideCollectionSelector('releases', `releases.${type}`)
);
const dispatch = useDispatch();
} = useReleases(searchPayload);
const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => {
const action =
type === 'episode' ? setEpisodeReleasesFilter : setSeasonReleasesFilter;
dispatch(action({ selectedFilterKey }));
if (type === 'episode') {
setReleaseOption('episodeSelectedFilterKey', selectedFilterKey);
} else {
setReleaseOption('seasonSelectedFilterKey', selectedFilterKey);
}
},
[type, dispatch]
[type]
);
const handleSortPress = useCallback(
(sortKey: string, sortDirection?: SortDirection) => {
dispatch(setReleasesSort({ sortKey, sortDirection }));
setReleaseSort(sortKey, sortDirection);
},
[dispatch]
);
const handleGrabPress = useCallback(
(payload: object) => {
dispatch(grabRelease(payload));
},
[dispatch]
);
useEffect(
() => {
// Only fetch releases if they are not already being fetched and not yet populated.
if (!isFetching && !isPopulated) {
dispatch(fetchReleases(searchPayload));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
@ -181,11 +65,11 @@ function InteractiveSearch({ type, searchPayload }: InteractiveSearchProps) {
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
filters={FILTERS}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModal}
filterModalConnectorComponentProps={{ type }}
filterModalConnectorComponentProps={{ type, searchPayload }}
onFilterSelect={handleFilterSelect}
/>
</div>
@ -207,17 +91,17 @@ function InteractiveSearch({ type, searchPayload }: InteractiveSearchProps) {
</div>
) : null}
{!isFetching && isPopulated && !totalItems ? (
{!isFetching && isFetched && !totalItems ? (
<Alert kind={kinds.INFO}>{translate('NoResultsFound')}</Alert>
) : null}
{!!totalItems && isPopulated && !items.length ? (
{!!totalItems && !isFetching && !data.length ? (
<Alert kind={kinds.WARNING}>
{translate('AllResultsAreHiddenByTheAppliedFilter')}
</Alert>
) : null}
{isPopulated && !!items.length ? (
{!isFetching && !!data.length ? (
<Table
columns={columns}
sortKey={sortKey}
@ -225,13 +109,12 @@ function InteractiveSearch({ type, searchPayload }: InteractiveSearchProps) {
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{data.map((item) => {
return (
<InteractiveSearchRow
key={`${item.indexerId}-${item.guid}`}
key={`${item.release.indexerId}-${item.release.guid}`}
{...item}
searchPayload={searchPayload}
onGrabPress={handleGrabPress}
/>
);
})}
@ -239,7 +122,7 @@ function InteractiveSearch({ type, searchPayload }: InteractiveSearchProps) {
</Table>
) : null}
{totalItems !== items.length && !!items.length ? (
{!isFetching && totalItems !== data.length && !!data.length ? (
<div className={styles.filteredMessage}>
{translate('SomeResultsAreHiddenByTheAppliedFilter')}
</div>

View file

@ -1,63 +1,47 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { SetFilter } from 'Components/Filter/Filter';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import InteractiveSearchType from 'InteractiveSearch/InteractiveSearchType';
import {
setEpisodeReleasesFilter,
setSeasonReleasesFilter,
} from 'Store/Actions/releaseActions';
import Release from 'typings/Release';
function createReleasesSelector() {
return createSelector(
(state: AppState) => state.releases.items,
(releases) => {
return releases;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.releases.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
import InteractiveSearchPayload from './InteractiveSearchPayload';
import { setReleaseOption } from './releaseOptionsStore';
import useReleases, { FILTER_BUILDER, Release } from './useReleases';
interface InteractiveSearchFilterModalProps extends FilterModalProps<Release> {
type: InteractiveSearchType;
searchPayload: InteractiveSearchPayload;
}
export default function InteractiveSearchFilterModal({
type,
searchPayload,
...otherProps
}: InteractiveSearchFilterModalProps) {
const sectionItems = useSelector(createReleasesSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const { data } = useReleases(searchPayload);
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: unknown) => {
const action =
type === 'episode' ? setEpisodeReleasesFilter : setSeasonReleasesFilter;
dispatch(action(payload));
const handleFilterSelect = useCallback(
(selectedFilter: SetFilter) => {
if (type === 'episode') {
setReleaseOption(
'episodeSelectedFilterKey',
selectedFilter.selectedFilterKey
);
} else {
setReleaseOption(
'seasonSelectedFilterKey',
selectedFilter.selectedFilterKey
);
}
},
[type, dispatch]
[type]
);
return (
<FilterModal
{...otherProps}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
sectionItems={data}
filterBuilderProps={FILTER_BUILDER}
customFilterType="releases"
dispatchSetFilter={dispatchSetFilter}
dispatchSetFilter={handleFilterSelect}
/>
);
}

View file

@ -15,7 +15,6 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
import IndexerFlags from 'Episode/IndexerFlags';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Release from 'typings/Release';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
@ -25,6 +24,7 @@ import InteractiveSearchPayload from './InteractiveSearchPayload';
import OverrideMatchModal from './OverrideMatch/OverrideMatchModal';
import Peers from './Peers';
import ReleaseSceneIndicator from './ReleaseSceneIndicator';
import { Release, useGrabRelease } from './useReleases';
import styles from './InteractiveSearchRow.css';
function getDownloadIcon(
@ -73,59 +73,65 @@ function getDownloadTooltip(
interface InteractiveSearchRowProps extends Release {
searchPayload: InteractiveSearchPayload;
onGrabPress(...args: unknown[]): void;
}
function InteractiveSearchRow(props: InteractiveSearchRowProps) {
const {
guid,
indexerId,
protocol,
age,
ageHours,
ageMinutes,
decision,
parsedInfo,
release,
publishDate,
title,
infoUrl,
indexer,
size,
seeders,
leechers,
quality,
languages,
customFormatScore,
customFormats,
sceneMapping,
seasonNumber,
episodeNumbers,
absoluteEpisodeNumbers,
mappedSeriesId,
mappedSeasonNumber,
mappedEpisodeNumbers,
mappedAbsoluteEpisodeNumbers,
mappedEpisodeInfo,
indexerFlags = 0,
rejections = [],
episodeRequested,
downloadAllowed,
isDaily,
isGrabbing = false,
isGrabbed = false,
grabError,
searchPayload,
onGrabPress,
} = props;
const { rejections = [] } = decision;
const {
absoluteEpisodeNumbers,
episodeNumbers,
isDaily,
seasonNumber,
quality,
} = parsedInfo;
const {
guid,
indexerId,
age,
ageHours,
ageMinutes,
title,
infoUrl,
indexer,
size,
seeders,
leechers,
protocol,
} = release;
const { longDateFormat, timeFormat, timeZone } = useSelector(
createUISettingsSelector()
);
const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false);
const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false);
const { isGrabbing, isGrabbed, grabError, grabRelease } = useGrabRelease();
const onGrabPressWrapper = useCallback(() => {
const handleGrabPress = useCallback(() => {
if (downloadAllowed) {
onGrabPress({
grabRelease({
guid,
indexerId,
});
@ -138,19 +144,19 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
guid,
indexerId,
downloadAllowed,
onGrabPress,
grabRelease,
setIsConfirmGrabModalOpen,
]);
const onGrabConfirm = useCallback(() => {
setIsConfirmGrabModalOpen(false);
onGrabPress({
grabRelease({
guid,
indexerId,
...searchPayload,
searchInfo: searchPayload,
});
}, [guid, indexerId, searchPayload, onGrabPress, setIsConfirmGrabModalOpen]);
}, [guid, indexerId, searchPayload, grabRelease, setIsConfirmGrabModalOpen]);
const onGrabCancel = useCallback(() => {
setIsConfirmGrabModalOpen(false);
@ -246,7 +252,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
body={
<ul>
{rejections.map((rejection, index) => {
return <li key={index}>{rejection}</li>;
return <li key={index}>{rejection.message}</li>;
})}
</ul>
}
@ -261,7 +267,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
kind={getDownloadKind(isGrabbed, grabError)}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={onGrabPressWrapper}
onPress={handleGrabPress}
/>
<Link
@ -310,6 +316,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
grabRelease={grabRelease}
onModalClose={onOverrideModalClose}
/>
</TableRow>

View file

@ -1,61 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import { sizes } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import { ReleaseEpisode } from 'typings/Release';
import OverrideMatchModalContent from './OverrideMatchModalContent';
import OverrideMatchModalContent, {
OverrideMatchModalContentProps,
} from './OverrideMatchModalContent';
interface OverrideMatchModalProps {
interface OverrideMatchModalProps extends OverrideMatchModalContentProps {
isOpen: boolean;
title: string;
indexerId: number;
guid: string;
seriesId?: number;
seasonNumber?: number;
episodes: ReleaseEpisode[];
languages: Language[];
quality: QualityModel;
protocol: DownloadProtocol;
isGrabbing: boolean;
grabError?: string;
onModalClose(): void;
}
function OverrideMatchModal(props: OverrideMatchModalProps) {
const {
isOpen,
title,
indexerId,
guid,
seriesId,
seasonNumber,
episodes,
languages,
quality,
protocol,
isGrabbing,
grabError,
onModalClose,
} = props;
function OverrideMatchModal({
isOpen,
onModalClose,
...otherProps
}: OverrideMatchModalProps) {
return (
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
<OverrideMatchModalContent
title={title}
indexerId={indexerId}
guid={guid}
seriesId={seriesId}
seasonNumber={seasonNumber}
episodes={episodes}
languages={languages}
quality={quality}
protocol={protocol}
isGrabbing={isGrabbing}
grabError={grabError}
onModalClose={onModalClose}
/>
<OverrideMatchModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}

View file

@ -18,14 +18,13 @@ import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import { ReleaseEpisode, useGrabRelease } from 'InteractiveSearch/useReleases';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import { grabRelease } from 'Store/Actions/releaseActions';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector';
import { createSeriesSelectorForHook } from 'Store/Selectors/createSeriesSelector';
import { ReleaseEpisode } from 'typings/Release';
import translate from 'Utilities/String/translate';
import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal';
import OverrideMatchData from './OverrideMatchData';
@ -40,7 +39,7 @@ type SelectType =
| 'language'
| 'downloadClient';
interface OverrideMatchModalContentProps {
export interface OverrideMatchModalContentProps {
indexerId: number;
title: string;
guid: string;
@ -52,6 +51,7 @@ interface OverrideMatchModalContentProps {
protocol: DownloadProtocol;
isGrabbing: boolean;
grabError?: string;
grabRelease: ReturnType<typeof useGrabRelease>['grabRelease'];
onModalClose(): void;
}
@ -64,6 +64,7 @@ function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
protocol,
isGrabbing,
grabError,
grabRelease,
onModalClose,
} = props;
@ -198,18 +199,17 @@ function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
return;
}
dispatch(
grabRelease({
indexerId,
guid,
grabRelease({
indexerId,
guid,
override: {
seriesId,
episodeIds: episodes.map((e) => e.id),
quality,
languages,
downloadClientId,
shouldOverride: true,
})
);
},
});
}, [
indexerId,
guid,
@ -219,7 +219,7 @@ function OverrideMatchModalContent(props: OverrideMatchModalContentProps) {
languages,
downloadClientId,
setError,
dispatch,
grabRelease,
]);
useEffect(() => {

View file

@ -0,0 +1,121 @@
import { createElement } from 'react';
import { FilterBuilderTag } from 'Components/Filter/Builder/FilterBuilderRowValue';
import { SelectedFilterKey } from 'Components/Filter/Filter';
import Icon from 'Components/Icon';
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
export interface ReleaseOptions
extends Omit<
PageableOptions,
'pageSize' | 'selectedFilterKey' | 'sortKey' | 'sortDirection'
> {
episodeSelectedFilterKey: SelectedFilterKey;
seasonSelectedFilterKey: SelectedFilterKey;
rejectionFilterTags: FilterBuilderTag<string, string>[];
}
const { useOptions, useOption, getOptions, getOption, setOptions, setOption } =
createOptionsStore<ReleaseOptions>('release_options', () => {
return {
episodeSelectedFilterKey: 'all',
seasonSelectedFilterKey: 'season-pack',
rejectionFilterTags: [],
columns: [
{
name: 'protocol',
label: () => translate('Source'),
isSortable: true,
isVisible: true,
},
{
name: 'age',
label: () => translate('Age'),
isSortable: true,
isVisible: true,
},
{
name: 'title',
label: () => translate('Title'),
isSortable: true,
isVisible: true,
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: true,
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: true,
},
{
name: 'peers',
label: () => translate('Peers'),
isSortable: true,
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityWeight',
label: () => translate('Quality'),
isSortable: true,
isVisible: true,
},
{
name: 'customFormatScore',
label: createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'indexerFlags',
label: createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'rejections',
label: createElement(Icon, {
name: icons.DANGER,
title: () => translate('Rejections'),
}),
isSortable: true,
fixedSortDirection: 'ascending',
isVisible: true,
},
{
name: 'releaseWeight',
label: createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: 'ascending',
isVisible: true,
},
],
};
});
export const useReleaseOptions = useOptions;
export const getReleaseOptions = getOptions;
export const setReleaseOptions = setOptions;
export const useReleaseOption = useOption;
export const getReleaseOption = getOption;
export const setReleaseOption = setOption;

View file

@ -0,0 +1,517 @@
import { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { create } from 'zustand';
import ModelBase from 'App/ModelBase';
import { Filter, FilterBuilderProp } from 'App/State/AppState';
import { FilterBuilderTag } from 'Components/Filter/Builder/FilterBuilderRowValue';
import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { applySort } from 'Helpers/Hooks/useOptionsStore';
import { filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props';
import { FilterType } from 'Helpers/Props/filterTypes';
import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import { AlternateTitle } from 'Series/Series';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import CustomFormat from 'typings/CustomFormat';
import Rejection from 'typings/Rejection';
import sortByProp from 'Utilities/Array/sortByProp';
import clientSideFilterAndSort from 'Utilities/Filter/clientSideFilterAndSort';
import enumToTitle from 'Utilities/String/enumToTitle';
import translate from 'Utilities/String/translate';
import InteractiveSearchPayload from './InteractiveSearchPayload';
import {
getReleaseOption,
setReleaseOption,
useReleaseOptions,
} from './releaseOptionsStore';
export interface ReleaseEpisode {
id: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
title: string;
}
export interface Release extends ModelBase {
parsedInfo: ParsedInfo;
release: ReleaseInfo;
decision: Decision;
qualityWeight: number;
languages: Language[];
mappedSeriesId?: number;
mappedSeasonNumber?: number;
mappedEpisodeNumbers?: number[];
mappedAbsoluteEpisodeNumbers?: number[];
mappedEpisodeInfo: ReleaseEpisode[];
publishDate: string;
episodeRequested: boolean;
downloadAllowed: boolean;
releaseWeight: number;
customFormats: CustomFormat[];
customFormatScore: number;
indexerFlags: number;
sceneMapping?: AlternateTitle;
}
export interface ParsedInfo {
quality: QualityModel;
releaseGroup: string;
releaseHash: string;
fullSeason: boolean;
seasonNumber: number;
seriesTitle: string;
episodeNumbers: number[];
absoluteEpisodeNumbers?: number[];
isDaily: boolean;
isAbsoluteNumbering: boolean;
isPossibleSpecialEpisode: boolean;
special: boolean;
}
export interface ReleaseInfo {
guid: string;
age: number;
ageHours: number;
ageMinutes: number;
size: number;
indexerId: number;
indexer: string;
title: string;
tvdbId: number;
tvRageId: number;
publishDate: string;
commentUrl: string;
downloadUrl: string;
infoUrl: string;
protocol: DownloadProtocol;
indexerFlags: number;
seeders?: number;
leechers?: number;
magnetUrl?: string;
infoHash?: string;
}
export interface Decision {
approved: boolean;
temporarilyRejected: boolean;
rejected: boolean;
rejections: Rejection[];
}
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'season-pack',
label: () => translate('SeasonPack'),
filters: [
{
key: 'fullSeason',
value: [true],
type: 'equal',
},
],
},
{
key: 'not-season-pack',
label: () => translate('NotSeasonPack'),
filters: [
{
key: 'fullSeason',
value: [false],
type: 'equal',
},
],
},
];
export const FILTER_BUILDER: FilterBuilderProp<Release>[] = [
{
name: 'title',
label: () => translate('Title'),
type: filterBuilderTypes.STRING,
},
{
name: 'age',
label: () => translate('Age'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.PROTOCOL,
},
{
name: 'indexerId',
label: () => translate('Indexer'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.INDEXER,
},
{
name: 'size',
label: () => translate('Size'),
type: filterBuilderTypes.NUMBER,
valueType: filterBuilderValueTypes.BYTES,
},
{
name: 'seeders',
label: () => translate('Seeders'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'peers',
label: () => translate('Peers'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY,
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function (items) {
const languageList = items.reduce<FilterBuilderTag<string, string>[]>(
(acc, release) => {
release.languages.forEach((language) => {
acc.push({
id: language.name,
name: language.name,
});
});
return acc;
},
[]
);
return languageList.sort(
sortByProp<FilterBuilderTag<string, string>, 'name'>('name')
);
},
},
{
name: 'customFormatScore',
label: () => translate('CustomFormatScore'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'rejectionCount',
label: () => translate('RejectionCount'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'rejections',
label: () => translate('Rejections'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function () {
return getReleaseOption('rejectionFilterTags');
},
},
{
name: 'fullSeason',
label: () => translate('SeasonPack'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL,
},
{
name: 'episodeRequested',
label: () => translate('EpisodeRequested'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL,
},
];
const FILTER_PREDICATES = {
age: (item: Release, value: number, type: FilterType) => {
return applyFilterPredicate(item.release.age, value, type);
},
episodeRequested: (item: Release, value: boolean, type: FilterType) => {
return applyFilterPredicate(item.episodeRequested, value, type);
},
fullSeason: (item: Release, value: boolean, type: FilterType) => {
return applyFilterPredicate(item.parsedInfo.fullSeason, value, type);
},
indexerId: (item: Release, value: number, type: FilterType) => {
return applyFilterPredicate(item.release.indexerId, value, type);
},
languages: (item: Release, filterValue: string[], type: FilterType) => {
const languages = item.languages.map((language) => language.name);
return applyFilterPredicate(languages, filterValue, type);
},
peers: (item: Release, value: number, type: FilterType) => {
const seeders = item.release.seeders || 0;
const leechers = item.release.leechers || 0;
const peers = seeders + leechers;
return applyFilterPredicate(peers, value, type);
},
protocol: (item: Release, value: DownloadProtocol, type: FilterType) => {
return applyFilterPredicate(item.release.protocol, value, type);
},
quality: (item: Release, value: number, type: FilterType) => {
return applyFilterPredicate(
item.parsedInfo.quality.quality.id,
value,
type
);
},
rejectionCount: (item: Release, value: number, type: FilterType) => {
return applyFilterPredicate(item.decision.rejections.length, value, type);
},
rejections: (item: Release, value: string[], type: FilterType) => {
return applyFilterPredicate(
item.decision.rejections.map((r) => r.reason),
value,
type
);
},
seeders: (item: Release, value: number, type: FilterType) => {
return applyFilterPredicate(item.release.seeders ?? 0, value, type);
},
size: (item: Release, value: number, type: FilterType) => {
return applyFilterPredicate(item.release.size, value, type);
},
title: (item: Release, value: string, type: FilterType) => {
return applyFilterPredicate(item.release.title, value, type);
},
} as const;
const SORT_PREDICATES = {
age: function (item: Release, _direction: SortDirection) {
return item.release.ageMinutes;
},
indexer: (item: Release, _direction: SortDirection) => {
return item.release.indexerId;
},
indexerFlags: (item: Release, _direction: SortDirection) => {
return item.release.indexerFlags;
},
languages: (item: Release, _direction: SortDirection) => {
if (item.languages.length > 1) {
return 10000;
}
return item.languages[0]?.id ?? 0;
},
peers: (item: Release, _direction: SortDirection) => {
const seeders = item.release.seeders || 0;
const leechers = item.release.leechers || 0;
return seeders * 1000000 + leechers;
},
protocol: (item: Release, _direction: SortDirection) => {
return item.release.protocol;
},
qualityWeight: (item: Release, _direction: SortDirection) => {
return item.qualityWeight;
},
rejections: (item: Release, _direction: SortDirection) => {
const rejections = item.decision.rejections;
const releaseWeight = item.releaseWeight;
if (rejections.length !== 0) {
return releaseWeight + 1000000;
}
return releaseWeight;
},
size: (item: Release, _direction: SortDirection) => {
return item.release.size;
},
title: (item: Release, _direction: SortDirection) => {
return item.release.title;
},
} as const;
interface ReleaseStore {
sortKey: string;
sortDirection: SortDirection;
}
const releaseStore = create<ReleaseStore>(() => ({
sortKey: 'releaseWeight',
sortDirection: 'ascending',
}));
const DEFAULT_RELEASES: Release[] = [];
const THIRTY_MINUTES = 30 * 60 * 1000;
const useReleases = (payload: InteractiveSearchPayload) => {
const customFilters = useSelector(createCustomFiltersSelector('releases'));
const { episodeSelectedFilterKey, seasonSelectedFilterKey } =
useReleaseOptions();
const { sortKey, sortDirection } = releaseStore();
const selectedFilterKey =
'seriesId' in payload ? seasonSelectedFilterKey : episodeSelectedFilterKey;
const { data, queryKey, ...result } = useApiQuery<Release[]>({
path: '/release',
queryParams: {
...payload,
},
queryOptions: {
// Cache and stale times set to 30 minutes
staleTime: THIRTY_MINUTES,
gcTime: THIRTY_MINUTES,
refetchOnMount: 'always',
// Disable refetch on window focus to prevent refetching when the user switch tabs
refetchOnWindowFocus: false,
retry: false,
},
});
const { data: filteredData, totalItems } = useMemo(
() =>
clientSideFilterAndSort<Release, typeof FILTER_PREDICATES>(
data ?? DEFAULT_RELEASES,
{
selectedFilterKey,
filters: FILTERS,
filterPredicates: FILTER_PREDICATES,
customFilters,
sortKey,
sortDirection,
sortPredicates: SORT_PREDICATES,
}
),
[data, selectedFilterKey, customFilters, sortKey, sortDirection]
);
useEffect(() => {
if (!data) {
return;
}
// Get existing rejection tags as a map for easy lookup
const rejectionsMap = new Map(
getReleaseOption('rejectionFilterTags').map((tag) => [tag.id, tag])
);
data.forEach((release) => {
release.decision.rejections.forEach((rejection) => {
if (!rejectionsMap.has(rejection.reason)) {
rejectionsMap.set(rejection.reason, {
id: rejection.reason,
name: enumToTitle(rejection.reason),
});
}
});
});
const rejections = Array.from(rejectionsMap.values()).sort(
sortByProp<FilterBuilderTag<string, string>, 'name'>('name')
);
setReleaseOption('rejectionFilterTags', rejections);
}, [data]);
return {
...result,
data: filteredData,
selectedFilterKey,
sortKey,
sortDirection,
totalItems,
};
};
export default useReleases;
interface OverrideRelease {
seriesId: number;
episodeIds: number[];
downloadClientId: number | null;
quality: QualityModel;
languages: Language[];
}
interface GrabRelease {
guid: string;
indexerId: number;
override?: OverrideRelease;
searchInfo?: InteractiveSearchPayload;
}
export const useGrabRelease = () => {
const [isGrabbed, setIsGrabbed] = useState(false);
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
const { mutate, isPending, error } = useApiMutation<unknown, GrabRelease>({
path: '/release',
method: 'POST',
mutationOptions: {
onMutate: () => {
setIsGrabbed(false);
},
onSuccess: () => {
setIsGrabbed(true);
},
},
});
const grabError = useMemo(() => {
if (!error) {
return undefined;
}
return error.statusBody?.message ?? translate('InteractiveSearchGrabError');
}, [error]);
return {
grabRelease: mutate,
isGrabbing: isPending,
isGrabbed,
grabError,
};
};
export const setReleaseSort = (
sortKey: string,
sortDirection: SortDirection | undefined
) => {
releaseStore.setState((state) => applySort(state, sortKey, sortDirection));
};
const applyFilterPredicate = <T>(itemValue: T, value: T, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
return predicate(itemValue, value);
};

View file

@ -1,11 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import {
cancelFetchReleases,
clearReleases,
} from 'Store/Actions/releaseActions';
import SeasonInteractiveSearchModalContent, {
SeasonInteractiveSearchModalContentProps,
} from './SeasonInteractiveSearchModalContent';
@ -20,34 +15,18 @@ function SeasonInteractiveSearchModal(
) {
const { isOpen, episodeCount, seriesId, seasonNumber, onModalClose } = props;
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
onModalClose();
dispatch(cancelFetchReleases());
dispatch(clearReleases());
}, [dispatch, onModalClose]);
useEffect(() => {
return () => {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
};
}, [dispatch]);
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={handleModalClose}
onModalClose={onModalClose}
>
<SeasonInteractiveSearchModalContent
episodeCount={episodeCount}
seriesId={seriesId}
seasonNumber={seasonNumber}
onModalClose={handleModalClose}
onModalClose={onModalClose}
/>
</Modal>
);

View file

@ -11,7 +11,6 @@ import * as oAuth from './oAuthActions';
import * as organizePreview from './organizePreviewActions';
import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions';
import * as releases from './releaseActions';
import * as series from './seriesActions';
import * as seriesHistory from './seriesHistoryActions';
import * as seriesIndex from './seriesIndexActions';
@ -31,7 +30,6 @@ export default [
organizePreview,
paths,
providerOptions,
releases,
series,
seriesHistory,
seriesIndex,

View file

@ -1,396 +0,0 @@
import { createAction } from 'redux-actions';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate';
import { createThunk, handleThunks } from 'Store/thunks';
import sortByProp from 'Utilities/Array/sortByProp';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import translate from 'Utilities/String/translate';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
//
// Variables
export const section = 'releases';
export const episodeSection = 'releases.episode';
export const seasonSection = 'releases.season';
let abortCurrentRequest = null;
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
items: [],
sortKey: 'releaseWeight',
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
age: function(item, direction) {
return item.ageMinutes;
},
peers: function(item, direction) {
const seeders = item.seeders || 0;
const leechers = item.leechers || 0;
return seeders * 1000000 + leechers;
},
languages: function(item, direction) {
if (item.languages.length > 1) {
return 10000;
}
return item.languages[0]?.id ?? 0;
},
rejections: function(item, direction) {
const rejections = item.rejections;
const releaseWeight = item.releaseWeight;
if (rejections.length !== 0) {
return releaseWeight + 1000000;
}
return releaseWeight;
}
},
filters: [
{
key: 'all',
label: () => translate('All'),
filters: []
},
{
key: 'season-pack',
label: () => translate('SeasonPack'),
filters: [
{
key: 'fullSeason',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'not-season-pack',
label: () => translate('NotSeasonPack'),
filters: [
{
key: 'fullSeason',
value: false,
type: filterTypes.EQUAL
}
]
}
],
filterPredicates: {
quality: function(item, value, type) {
const qualityId = item.quality.quality.id;
if (type === filterTypes.EQUAL) {
return qualityId === value;
}
if (type === filterTypes.NOT_EQUAL) {
return qualityId !== value;
}
// Default to false
return false;
},
languages: function(item, filterValue, type) {
const predicate = getFilterTypePredicate(type);
const languages = item.languages.map((language) => language.name);
return predicate(languages, filterValue);
},
rejectionCount: function(item, value, type) {
const rejectionCount = item.rejections.length;
switch (type) {
case filterTypes.EQUAL:
return rejectionCount === value;
case filterTypes.GREATER_THAN:
return rejectionCount > value;
case filterTypes.GREATER_THAN_OR_EQUAL:
return rejectionCount >= value;
case filterTypes.LESS_THAN:
return rejectionCount < value;
case filterTypes.LESS_THAN_OR_EQUAL:
return rejectionCount <= value;
case filterTypes.NOT_EQUAL:
return rejectionCount !== value;
default:
return false;
}
},
peers: function(item, value, type) {
const seeders = item.seeders || 0;
const leechers = item.leechers || 0;
const peers = seeders + leechers;
switch (type) {
case filterTypes.EQUAL:
return peers === value;
case filterTypes.GREATER_THAN:
return peers > value;
case filterTypes.GREATER_THAN_OR_EQUAL:
return peers >= value;
case filterTypes.LESS_THAN:
return peers < value;
case filterTypes.LESS_THAN_OR_EQUAL:
return peers <= value;
case filterTypes.NOT_EQUAL:
return peers !== value;
default:
return false;
}
}
},
filterBuilderProps: [
{
name: 'title',
label: () => translate('Title'),
type: filterBuilderTypes.STRING
},
{
name: 'age',
label: () => translate('Age'),
type: filterBuilderTypes.NUMBER
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.PROTOCOL
},
{
name: 'indexerId',
label: () => translate('Indexer'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.INDEXER
},
{
name: 'size',
label: () => translate('Size'),
type: filterBuilderTypes.NUMBER,
valueType: filterBuilderValueTypes.BYTES
},
{
name: 'seeders',
label: () => translate('Seeders'),
type: filterBuilderTypes.NUMBER
},
{
name: 'peers',
label: () => translate('Peers'),
type: filterBuilderTypes.NUMBER
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function(items) {
const genreList = items.reduce((acc, release) => {
release.languages.forEach((language) => {
acc.push({
id: language.name,
name: language.name
});
});
return acc;
}, []);
return genreList.sort(sortByProp('name'));
}
},
{
name: 'customFormatScore',
label: () => translate('CustomFormatScore'),
type: filterBuilderTypes.NUMBER
},
{
name: 'rejectionCount',
label: () => translate('RejectionCount'),
type: filterBuilderTypes.NUMBER
},
{
name: 'fullSeason',
label: () => translate('SeasonPack'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'episodeRequested',
label: () => translate('EpisodeRequested'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
}
],
episode: {
selectedFilterKey: 'all'
},
season: {
selectedFilterKey: 'season-pack'
}
};
export const persistState = [
'releases.episode.selectedFilterKey',
'releases.episode.customFilters',
'releases.season.selectedFilterKey',
'releases.season.customFilters'
];
//
// Actions Types
export const FETCH_RELEASES = 'releases/fetchReleases';
export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases';
export const SET_RELEASES_SORT = 'releases/setReleasesSort';
export const CLEAR_RELEASES = 'releases/clearReleases';
export const GRAB_RELEASE = 'releases/grabRelease';
export const UPDATE_RELEASE = 'releases/updateRelease';
export const SET_EPISODE_RELEASES_FILTER = 'releases/setEpisodeReleasesFilter';
export const SET_SEASON_RELEASES_FILTER = 'releases/setSeasonReleasesFilter';
//
// Action Creators
export const fetchReleases = createThunk(FETCH_RELEASES);
export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES);
export const setReleasesSort = createAction(SET_RELEASES_SORT);
export const clearReleases = createAction(CLEAR_RELEASES);
export const grabRelease = createThunk(GRAB_RELEASE);
export const updateRelease = createAction(UPDATE_RELEASE);
export const setEpisodeReleasesFilter = createAction(SET_EPISODE_RELEASES_FILTER);
export const setSeasonReleasesFilter = createAction(SET_SEASON_RELEASES_FILTER);
//
// Helpers
const fetchReleasesHelper = createFetchHandler(section, '/release');
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_RELEASES]: function(getState, payload, dispatch) {
const abortRequest = fetchReleasesHelper(getState, payload, dispatch);
abortCurrentRequest = abortRequest;
},
[CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) {
if (abortCurrentRequest) {
abortCurrentRequest = abortCurrentRequest();
}
},
[GRAB_RELEASE]: function(getState, payload, dispatch) {
const guid = payload.guid;
dispatch(updateRelease({ guid, isGrabbing: true }));
const promise = createAjaxRequest({
url: '/release',
method: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(payload)
}).request;
promise.done((data) => {
dispatch(updateRelease({
guid,
isGrabbing: false,
isGrabbed: true,
grabError: null
}));
});
promise.fail((xhr) => {
const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue';
dispatch(updateRelease({
guid,
isGrabbing: false,
isGrabbed: false,
grabError
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[CLEAR_RELEASES]: (state) => {
const {
episode,
season,
...otherDefaultState
} = defaultState;
return Object.assign({}, state, otherDefaultState);
},
[UPDATE_RELEASE]: (state, { payload }) => {
const guid = payload.guid;
const newState = Object.assign({}, state);
const items = newState.items;
const index = items.findIndex((item) => item.guid === guid);
// Don't try to update if there isn't a matching item (the user closed the modal)
if (index >= 0) {
const item = Object.assign({}, items[index], payload);
newState.items = [...items];
newState.items.splice(index, 1, item);
}
return newState;
},
[SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section),
[SET_EPISODE_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(episodeSection),
[SET_SEASON_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(seasonSection)
}, defaultState, section);

View file

@ -0,0 +1,167 @@
import _ from 'lodash';
import ModelBase from 'App/ModelBase';
import { CustomFilter, Filter } from 'App/State/AppState';
import { filterTypes, sortDirections } from 'Helpers/Props';
import { FilterType } from 'Helpers/Props/filterTypes';
import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate';
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
import { SortDirection } from '../../Helpers/Props/sortDirections';
const getSortClause = <T, TSort = null>(
sortKey: string,
sortDirection: SortDirection,
sortPredicates?: Record<
keyof TSort,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(item: T, direction: SortDirection) => any
>
) => {
if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) {
return function (item: T) {
return sortPredicates[sortKey as keyof TSort](item, sortDirection);
};
}
return function (item: T) {
return item[sortKey as keyof T];
};
};
const filter = <T extends ModelBase, TFilter = null, TSort = null>(
data: T[],
options: ClientSideFilterAndSortOptions<T, TFilter, TSort>
) => {
const { selectedFilterKey, filters, customFilters, filterPredicates } =
options;
if (!selectedFilterKey) {
return data;
}
const selectedFilters = findSelectedFilters(
selectedFilterKey,
filters,
customFilters
);
return data.filter((item: T) => {
let i = 0;
let accepted = true;
while (accepted && i < selectedFilters.length) {
const { key, value, type = filterTypes.EQUAL } = selectedFilters[i];
if (filterPredicates && filterPredicates.hasOwnProperty(key)) {
const predicate = filterPredicates[key as keyof TFilter];
if (Array.isArray(value)) {
if (
type === filterTypes.NOT_CONTAINS ||
type === filterTypes.NOT_EQUAL
) {
accepted = value.every((v) => predicate(item, v, type));
} else {
accepted = value.some((v) => predicate(item, v, type));
}
} else {
accepted = predicate(item, value, type);
}
} else if (item.hasOwnProperty(key)) {
const predicate = getFilterTypePredicate(type);
if (Array.isArray(value)) {
if (
type === filterTypes.NOT_CONTAINS ||
type === filterTypes.NOT_EQUAL
) {
accepted = value.every((v) => predicate(item[key as keyof T], v));
} else {
accepted = value.some((v) => predicate(item[key as keyof T], v));
}
} else {
accepted = predicate(item[key as keyof T], value);
}
} else {
// Default to false if the filter can't be tested
accepted = false;
}
i++;
}
return accepted;
});
};
const sort = <T extends ModelBase, TFilter = null, TSort = null>(
data: T[],
options: ClientSideFilterAndSortOptions<T, TFilter, TSort>
) => {
const {
sortKey,
sortDirection,
sortPredicates,
secondarySortKey,
secondarySortDirection,
} = options;
const clauses = [];
const orders: ('asc' | 'desc')[] = [];
clauses.push(getSortClause(sortKey, sortDirection, sortPredicates));
orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
if (
secondarySortKey &&
secondarySortDirection &&
(sortKey !== secondarySortKey || sortDirection !== secondarySortDirection)
) {
clauses.push(
getSortClause(secondarySortKey, secondarySortDirection, sortPredicates)
);
orders.push(
secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'
);
}
return _.orderBy(data, clauses, orders);
};
interface ClientSideFilterAndSortOptions<T extends ModelBase, TFilter, TSort> {
selectedFilterKey: string | number;
filters: Filter[];
filterPredicates?: Record<
keyof TFilter,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(item: T, value: any, type: FilterType) => boolean
>;
customFilters: CustomFilter[];
sortKey: string;
sortDirection: SortDirection;
secondarySortKey?: string;
secondarySortDirection?: SortDirection;
sortPredicates?: Record<
keyof TSort,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(item: T, direction: SortDirection) => any
>;
}
const clientSideFilterAndSort = <
T extends ModelBase,
TFilter = null,
TSort = null
>(
data: T[],
options: ClientSideFilterAndSortOptions<T, TFilter, TSort>
) => {
const filteredData = filter(data, options);
const sortedData = sort(filteredData, options);
return {
data: sortedData,
totalItems: data.length,
};
};
export default clientSideFilterAndSort;

View file

@ -2,7 +2,7 @@ import { Error } from 'App/State/AppSectionState';
import { ApiError } from 'Utilities/Fetch/fetchJson';
function getErrorMessage(
error: Error | ApiError | undefined,
error: Error | ApiError | undefined | null,
fallbackErrorMessage = ''
) {
if (!error) {

View file

@ -0,0 +1,7 @@
const enumToTitle = (s: string) => {
const result = s.replace(/([A-Z])/g, ' $1');
return result.charAt(0).toUpperCase() + result.slice(1);
};
export default enumToTitle;

View file

@ -1,11 +1,7 @@
export enum RejectionType {
Permanent = 'permanent',
Temporary = 'temporary',
}
interface Rejection {
message: string;
reason: string;
type: RejectionType;
type: 'permanent' | 'temporary';
}
export default Rejection;

View file

@ -1,53 +0,0 @@
import type DownloadProtocol from 'DownloadClient/DownloadProtocol';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
export interface ReleaseEpisode {
id: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
title: string;
}
interface Release {
guid: string;
protocol: DownloadProtocol;
age: number;
ageHours: number;
ageMinutes: number;
publishDate: string;
title: string;
infoUrl: string;
indexerId: number;
indexer: string;
size: number;
seeders?: number;
leechers?: number;
quality: QualityModel;
languages: Language[];
customFormats: CustomFormat[];
customFormatScore: number;
sceneMapping?: object;
seasonNumber?: number;
episodeNumbers?: number[];
absoluteEpisodeNumbers?: number[];
mappedSeriesId?: number;
mappedSeasonNumber?: number;
mappedEpisodeNumbers?: number[];
mappedAbsoluteEpisodeNumbers?: number[];
mappedEpisodeInfo: ReleaseEpisode[];
indexerFlags: number;
rejections: string[];
episodeRequested: boolean;
downloadAllowed: boolean;
isDaily: boolean;
isGrabbing?: boolean;
isGrabbed?: boolean;
grabError?: string;
}
export default Release;

View file

@ -1091,6 +1091,7 @@
"InteractiveImportNoSeason": "Season must be chosen for each selected file",
"InteractiveImportNoSeries": "Series must be chosen for each selected file",
"InteractiveSearch": "Interactive Search",
"InteractiveSearchGrabError": "Failed to add to download queue",
"InteractiveSearchModalHeader": "Interactive Search",
"InteractiveSearchModalHeaderSeason": "Interactive Search - {season}",
"InteractiveSearchResultsSeriesFailedErrorMessage": "Search failed because its {message}. Try refreshing the series info and verify the necessary information is present before searching again.",

View file

@ -15,12 +15,13 @@
using NzbDrone.Core.Tv;
using NzbDrone.Core.Validation;
using Sonarr.Http;
using Sonarr.Http.REST;
using HttpStatusCode = System.Net.HttpStatusCode;
namespace Sonarr.Api.V5.Release;
[V5ApiController]
public class ReleaseController : ReleaseControllerBase
public class ReleaseController : RestController<ReleaseResource>
{
private readonly IFetchAndParseRss _rssFetcherAndParser;
private readonly ISearchForReleases _releaseSearchService;
@ -32,6 +33,7 @@ public class ReleaseController : ReleaseControllerBase
private readonly IParsingService _parsingService;
private readonly Logger _logger;
private readonly QualityProfile _qualityProfile;
private readonly ICached<RemoteEpisode> _remoteEpisodeCache;
public ReleaseController(IFetchAndParseRss rssFetcherAndParser,
@ -45,7 +47,6 @@ public ReleaseController(IFetchAndParseRss rssFetcherAndParser,
ICacheManager cacheManager,
IQualityProfileService qualityProfileService,
Logger logger)
: base(qualityProfileService)
{
_rssFetcherAndParser = rssFetcherAndParser;
_releaseSearchService = releaseSearchService;
@ -57,11 +58,23 @@ public ReleaseController(IFetchAndParseRss rssFetcherAndParser,
_parsingService = parsingService;
_logger = logger;
_qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty);
_remoteEpisodeCache = cacheManager.GetCache<RemoteEpisode>(GetType(), "remoteEpisodes");
PostValidator.RuleFor(s => s.Release).NotNull();
PostValidator.RuleFor(s => s.Release!.IndexerId).ValidId();
PostValidator.RuleFor(s => s.Release!.Guid).NotEmpty();
}
_remoteEpisodeCache = cacheManager.GetCache<RemoteEpisode>(GetType(), "remoteEpisodes");
[NonAction]
public override ActionResult<ReleaseResource> GetResourceByIdWithErrorHandler(int id)
{
return base.GetResourceByIdWithErrorHandler(id);
}
protected override ReleaseResource GetResourceById(int id)
{
throw new NotImplementedException();
}
[HttpPost]
@ -234,14 +247,6 @@ private async Task<List<ReleaseResource>> GetRss()
return MapDecisions(prioritizedDecisions);
}
protected override ReleaseResource MapDecision(DownloadDecision decision, int initialWeight)
{
var resource = base.MapDecision(decision, initialWeight);
_remoteEpisodeCache.Set(GetCacheKey(resource), decision.RemoteEpisode, TimeSpan.FromMinutes(30));
return resource;
}
private string GetCacheKey(ReleaseResource resource)
{
return string.Concat(resource.Release!.IndexerId, "_", resource.Release!.Guid);
@ -251,4 +256,18 @@ private string GetCacheKey(ReleaseGrabResource resource)
{
return string.Concat(resource.IndexerId, "_", resource.Guid);
}
private List<ReleaseResource> MapDecisions(IEnumerable<DownloadDecision> decisions)
{
var result = new List<ReleaseResource>();
foreach (var downloadDecision in decisions)
{
var release = downloadDecision.MapDecision(result.Count, _qualityProfile);
result.Add(release);
}
return result;
}
}

View file

@ -1,61 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Profiles.Qualities;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Release;
public abstract class ReleaseControllerBase : RestController<ReleaseResource>
{
private readonly QualityProfile _qualityProfile;
public ReleaseControllerBase(IQualityProfileService qualityProfileService)
{
_qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty);
}
[NonAction]
public override ActionResult<ReleaseResource> GetResourceByIdWithErrorHandler(int id)
{
return base.GetResourceByIdWithErrorHandler(id);
}
protected override ReleaseResource GetResourceById(int id)
{
throw new NotImplementedException();
}
protected virtual List<ReleaseResource> MapDecisions(IEnumerable<DownloadDecision> decisions)
{
var result = new List<ReleaseResource>();
foreach (var downloadDecision in decisions)
{
var release = MapDecision(downloadDecision, result.Count);
result.Add(release);
}
return result;
}
protected virtual ReleaseResource MapDecision(DownloadDecision decision, int initialWeight)
{
var release = decision.ToResource();
release.ReleaseWeight = initialWeight;
if (release.ParsedInfo?.Quality == null)
{
release.QualityWeight = 0;
}
else
{
release.QualityWeight = _qualityProfile.GetIndex(release.ParsedInfo.Quality.Quality).Index * 100;
release.QualityWeight += release.ParsedInfo.Quality.Revision.Real * 10;
release.QualityWeight += release.ParsedInfo.Quality.Revision.Version;
}
return release;
}
}

View file

@ -21,9 +21,10 @@ public class ReleasePushController : RestController<ReleasePushResource>
private readonly IProcessDownloadDecisions _downloadDecisionProcessor;
private readonly IIndexerFactory _indexerFactory;
private readonly IDownloadClientFactory _downloadClientFactory;
private readonly IQualityProfileService _qualityProfileService;
private readonly Logger _logger;
private readonly QualityProfile _qualityProfile;
private static readonly object PushLock = new object();
public ReleasePushController(IMakeDownloadDecision downloadDecisionMaker,
@ -37,9 +38,10 @@ public ReleasePushController(IMakeDownloadDecision downloadDecisionMaker,
_downloadDecisionProcessor = downloadDecisionProcessor;
_indexerFactory = indexerFactory;
_downloadClientFactory = downloadClientFactory;
_qualityProfileService = qualityProfileService;
_logger = logger;
_qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty);
PostValidator.RuleFor(s => s.Title).NotEmpty();
PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty().When(s => s.MagnetUrl.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.MagnetUrl).NotEmpty().When(s => s.DownloadUrl.IsNullOrWhiteSpace());
@ -79,7 +81,7 @@ public ActionResult<ReleaseResource> Create([FromBody] ReleasePushResource relea
throw new ValidationException(new List<ValidationFailure> { new("Title", "Unable to parse", release.Title) });
}
return decision.MapDecision(1, _qualityProfileService.GetDefaultProfile(string.Empty));
return decision.MapDecision(1, _qualityProfile);
}
private void ResolveIndexer(ReleaseInfo release)