mirror of
https://github.com/Sonarr/Sonarr
synced 2026-04-27 15:42:55 +02:00
Use react-query for interactive search
New: Filter Interactive Search results by rejection reason
This commit is contained in:
parent
9b756df4bf
commit
8f95849e9b
41 changed files with 1031 additions and 924 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -9,7 +9,7 @@ const protocols = [
|
|||
];
|
||||
|
||||
type BoolFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, boolean>,
|
||||
FilterBuilderRowValueProps<T, boolean, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import FilterBuilderRowValue, {
|
|||
} from './FilterBuilderRowValue';
|
||||
|
||||
type DefaultFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
FilterBuilderRowValueProps<T, string, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const EVENT_TYPE_OPTIONS = [
|
|||
];
|
||||
|
||||
type QualityProfileFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, number>,
|
||||
FilterBuilderRowValueProps<T, number, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import FilterBuilderRowValue, {
|
|||
} from './FilterBuilderRowValue';
|
||||
|
||||
type IndexerFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, number>,
|
||||
FilterBuilderRowValueProps<T, number, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import FilterBuilderRowValue, {
|
|||
} from './FilterBuilderRowValue';
|
||||
|
||||
type LanguageFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, number>,
|
||||
FilterBuilderRowValueProps<T, number, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const protocols = [
|
|||
];
|
||||
|
||||
type ProtocolFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
FilterBuilderRowValueProps<T, string, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import FilterBuilderRowValue, {
|
|||
} from './FilterBuilderRowValue';
|
||||
|
||||
type QualityFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, number>,
|
||||
FilterBuilderRowValueProps<T, number, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function createQualityProfilesSelector() {
|
|||
}
|
||||
|
||||
type QualityProfileFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, number>,
|
||||
FilterBuilderRowValueProps<T, number, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const statusTagList = [
|
|||
];
|
||||
|
||||
type QueueStatusFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
FilterBuilderRowValueProps<T, string, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const seasonsMonitoredStatusList = [
|
|||
];
|
||||
|
||||
type SeasonsMonitoredStatusFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
FilterBuilderRowValueProps<T, string, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import FilterBuilderRowValue, {
|
|||
} from './FilterBuilderRowValue';
|
||||
|
||||
type SeriesFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, number>,
|
||||
FilterBuilderRowValueProps<T, number, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const statusTagList = [
|
|||
];
|
||||
|
||||
type SeriesStatusFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
FilterBuilderRowValueProps<T, string, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const seriesTypeList = [
|
|||
];
|
||||
|
||||
type SeriesTypeFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, string>,
|
||||
FilterBuilderRowValueProps<T, string, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import FilterBuilderRowValue, {
|
|||
} from './FilterBuilderRowValue';
|
||||
|
||||
type TagFilterBuilderRowValueProps<T> = Omit<
|
||||
FilterBuilderRowValueProps<T, number>,
|
||||
FilterBuilderRowValueProps<T, number, string>,
|
||||
'tagList'
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
121
frontend/src/InteractiveSearch/releaseOptionsStore.ts
Normal file
121
frontend/src/InteractiveSearch/releaseOptionsStore.ts
Normal 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;
|
||||
517
frontend/src/InteractiveSearch/useReleases.ts
Normal file
517
frontend/src/InteractiveSearch/useReleases.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
167
frontend/src/Utilities/Filter/clientSideFilterAndSort.ts
Normal file
167
frontend/src/Utilities/Filter/clientSideFilterAndSort.ts
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
7
frontend/src/Utilities/String/enumToTitle.ts
Normal file
7
frontend/src/Utilities/String/enumToTitle.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue