1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-20 21:54:58 -04:00
Files
Sonarr/frontend/src/Series/useSeries.ts
T
2025-12-28 16:48:21 -08:00

994 lines
24 KiB
TypeScript

import { useQueryClient } from '@tanstack/react-query';
import moment from 'moment';
import { useCallback, useMemo } from 'react';
import { FilterBuilderTag } from 'Components/Filter/Builder/FilterBuilderRowValue';
import { Filter, FilterBuilderProp } from 'Filters/Filter';
import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import {
filterBuilderTypes,
filterBuilderValueTypes,
sortDirections,
} from 'Helpers/Props';
import { FilterType } from 'Helpers/Props/filterTypes';
import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate';
import { SortDirection } from 'Helpers/Props/sortDirections';
import sortByProp from 'Utilities/Array/sortByProp';
import clientSideFilterAndSort from 'Utilities/Filter/clientSideFilterAndSort';
import translate from 'Utilities/String/translate';
import Series, { Statistics } from './Series';
import { useSeriesOptions } from './seriesOptionsStore';
// Date filter predicate helper
const dateFilterPredicate = (
itemDate: string | undefined,
filterValue: string | Date,
type: FilterType
): boolean => {
if (!itemDate) return false;
const predicate = getFilterTypePredicate(type);
return predicate(itemDate, filterValue);
};
export const FILTERS: Filter[] = [
{
key: 'all',
label: () => translate('All'),
filters: [],
},
{
key: 'monitored',
label: () => translate('MonitoredOnly'),
filters: [
{
key: 'monitored',
value: [true],
type: 'equal',
},
],
},
{
key: 'unmonitored',
label: () => translate('UnmonitoredOnly'),
filters: [
{
key: 'monitored',
value: [false],
type: 'equal',
},
],
},
{
key: 'continuing',
label: () => translate('ContinuingOnly'),
filters: [
{
key: 'status',
value: 'continuing',
type: 'equal',
},
],
},
{
key: 'ended',
label: () => translate('EndedOnly'),
filters: [
{
key: 'status',
value: 'ended',
type: 'equal',
},
],
},
{
key: 'missing',
label: () => translate('MissingEpisodes'),
filters: [
{
key: 'missing',
value: [true],
type: 'equal',
},
],
},
];
const SORT_PREDICATES = {
status: (item: Series, _direction: SortDirection) => {
let result = 0;
if (item.monitored) {
result += 2;
}
if (item.status === 'continuing') {
result++;
}
return result;
},
sizeOnDisk: (item: Series, _direction: SortDirection) => {
return item.statistics?.sizeOnDisk ?? 0;
},
network: (item: Series, _direction: SortDirection) => {
const network = item.network;
return network ? network.toLowerCase() : '';
},
nextAiring: (item: Series, direction: SortDirection) => {
const nextAiring = item.nextAiring;
if (nextAiring) {
return moment(nextAiring).unix();
}
if (direction === sortDirections.DESCENDING) {
return 0;
}
return Number.MAX_VALUE;
},
previousAiring: (item: Series, direction: SortDirection) => {
const previousAiring = item.previousAiring;
if (previousAiring) {
return moment(previousAiring).unix();
}
if (direction === sortDirections.DESCENDING) {
return -Number.MAX_VALUE;
}
return Number.MAX_VALUE;
},
episodeProgress: (item: Series, _direction: SortDirection) => {
const statistics = item.statistics;
const episodeCount = statistics?.episodeCount ?? 0;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
const progress = episodeCount
? (episodeFileCount / episodeCount) * 100
: 100;
return progress + episodeCount / 1000000;
},
episodeCount: (item: Series, _direction: SortDirection) => {
return item.statistics?.totalEpisodeCount ?? 0;
},
seasonCount: (item: Series, _direction: SortDirection) => {
return item.statistics?.seasonCount ?? 0;
},
originalLanguage: (item: Series, _direction: SortDirection) => {
const { originalLanguage } = item;
return originalLanguage?.name ?? '';
},
ratings: (item: Series, _direction: SortDirection) => {
const { ratings } = item;
return ratings.value ?? 0;
},
monitorNewItems: (item: Series, _direction: SortDirection) => {
return item.monitorNewItems === 'all' ? 1 : 0;
},
} as const;
const FILTER_PREDICATES = {
episodeProgress: (item: Series, filterValue: number, type: FilterType) => {
const statistics = item.statistics;
const episodeCount = statistics?.episodeCount ?? 0;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
const progress = episodeCount
? (episodeFileCount / episodeCount) * 100
: 100;
const predicate = getFilterTypePredicate(type);
return predicate(progress, filterValue);
},
missing: (item: Series, _filterValue: boolean, _type: FilterType) => {
const statistics = item.statistics;
const episodeCount = statistics?.episodeCount ?? 0;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
return episodeCount - episodeFileCount > 0;
},
nextAiring: (item: Series, filterValue: string | Date, type: FilterType) => {
return dateFilterPredicate(item.nextAiring, filterValue, type);
},
previousAiring: (
item: Series,
filterValue: string | Date,
type: FilterType
) => {
return dateFilterPredicate(item.previousAiring, filterValue, type);
},
added: (item: Series, filterValue: string | Date, type: FilterType) => {
return dateFilterPredicate(item.added, filterValue, type);
},
ratings: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const value = item.ratings.value ?? 0;
return predicate(value * 10, filterValue);
},
ratingVotes: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const votes = item.ratings.votes ?? 0;
return predicate(votes, filterValue);
},
originalLanguage: (item: Series, filterValue: string, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const languageName = item.originalLanguage?.name ?? '';
return predicate(languageName, filterValue);
},
releaseGroups: (item: Series, filterValue: string[], type: FilterType) => {
const releaseGroups = item.statistics?.releaseGroups ?? [];
const predicate = getFilterTypePredicate(type);
return predicate(releaseGroups, filterValue);
},
seasonCount: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const seasonCount = item.statistics?.seasonCount ?? 0;
return predicate(seasonCount, filterValue);
},
sizeOnDisk: (item: Series, filterValue: number, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const sizeOnDisk = item.statistics?.sizeOnDisk ?? 0;
return predicate(sizeOnDisk, filterValue);
},
hasMissingSeason: (item: Series, filterValue: boolean, type: FilterType) => {
const predicate = getFilterTypePredicate(type);
const seasons = item.seasons ?? [];
const hasMissingSeason = seasons.some((season) => {
const { seasonNumber } = season;
const statistics = season.statistics;
const episodeFileCount = statistics?.episodeFileCount ?? 0;
const episodeCount = statistics?.episodeCount ?? 0;
const totalEpisodeCount = statistics?.totalEpisodeCount ?? 0;
return (
seasonNumber > 0 &&
totalEpisodeCount > 0 &&
episodeCount === totalEpisodeCount &&
episodeFileCount === 0
);
});
return predicate(hasMissingSeason, filterValue);
},
seasonsMonitoredStatus: (
item: Series,
filterValue: string,
type: FilterType
) => {
const predicate = getFilterTypePredicate(type);
const seasons = item.seasons ?? [];
const { monitoredCount, unmonitoredCount } = seasons.reduce(
(acc, { seasonNumber, monitored }) => {
if (seasonNumber <= 0) {
return acc;
}
if (monitored) {
acc.monitoredCount++;
} else {
acc.unmonitoredCount++;
}
return acc;
},
{ monitoredCount: 0, unmonitoredCount: 0 }
);
let seasonsMonitoredStatus = 'partial';
if (monitoredCount === 0) {
seasonsMonitoredStatus = 'none';
} else if (unmonitoredCount === 0) {
seasonsMonitoredStatus = 'all';
}
return predicate(seasonsMonitoredStatus, filterValue);
},
episodesMonitoredStatus: (
item: Series,
filterValue: string,
type: FilterType
) => {
const predicate = getFilterTypePredicate(type);
const { seasons, statistics = {} as Statistics } = item;
const { monitoredEpisodeCount = 0, totalEpisodeCount = 0 } = statistics;
const specials = seasons?.find((s) => s.seasonNumber === 0);
// The monitored count and total count include specials, but those areskipped
// for seasons monitored status so we should to exclude them here too.
const monitoredCount =
monitoredEpisodeCount -
(specials?.statistics?.monitoredEpisodeCount ?? 0);
const totalCount =
totalEpisodeCount - (specials?.statistics?.totalEpisodeCount ?? 0);
let episodesMonitoredStatus = 'partial';
if (monitoredCount === 0) {
episodesMonitoredStatus = 'none';
} else if (totalCount - monitoredCount === 0) {
episodesMonitoredStatus = 'all';
}
return predicate(episodesMonitoredStatus, filterValue);
},
} as const;
export const FILTER_BUILDER: FilterBuilderProp<Series>[] = [
{
name: 'monitored',
label: () => translate('Monitored'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL,
},
{
name: 'status',
label: () => translate('Status'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SERIES_STATUS,
},
{
name: 'seriesType',
label: () => translate('Type'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.SERIES_TYPES,
},
{
name: 'title',
label: () => translate('Title'),
type: filterBuilderTypes.STRING,
},
{
name: 'network',
label: () => translate('Network'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function (items: Series[]) {
const tagList = items.reduce<FilterBuilderTag<string, string>[]>(
(acc, series) => {
if (series.network) {
acc.push({
id: series.network,
name: series.network,
});
}
return acc;
},
[]
);
return tagList.sort(sortByProp('name'));
},
},
{
name: 'qualityProfileId',
label: () => translate('QualityProfile'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY_PROFILE,
},
{
name: 'nextAiring',
label: () => translate('NextAiring'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE,
},
{
name: 'previousAiring',
label: () => translate('PreviousAiring'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE,
},
{
name: 'added',
label: () => translate('Added'),
type: filterBuilderTypes.DATE,
valueType: filterBuilderValueTypes.DATE,
},
{
name: 'seasonCount',
label: () => translate('SeasonCount'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'episodeProgress',
label: () => translate('EpisodeProgress'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'path',
label: () => translate('Path'),
type: filterBuilderTypes.STRING,
},
{
name: 'rootFolderPath',
label: () => translate('RootFolderPath'),
type: filterBuilderTypes.EXACT,
},
{
name: 'sizeOnDisk',
label: () => translate('SizeOnDisk'),
type: filterBuilderTypes.NUMBER,
valueType: filterBuilderValueTypes.BYTES,
},
{
name: 'genres',
label: () => translate('Genres'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function (items: Series[]) {
const tagList = items.reduce<FilterBuilderTag<string, string>[]>(
(acc, series) => {
series.genres.forEach((genre) => {
acc.push({
id: genre,
name: genre,
});
});
return acc;
},
[]
);
return tagList.sort(sortByProp('name'));
},
},
{
name: 'originalLanguage',
label: () => translate('OriginalLanguage'),
type: filterBuilderTypes.EXACT,
optionsSelector: function (items: Series[]) {
const languageList = items.reduce<FilterBuilderTag<string, string>[]>(
(acc, series) => {
if (series.originalLanguage) {
acc.push({
id: series.originalLanguage.name,
name: series.originalLanguage.name,
});
}
return acc;
},
[]
);
return languageList.sort(sortByProp('name'));
},
},
{
name: 'releaseGroups',
label: () => translate('ReleaseGroups'),
type: filterBuilderTypes.ARRAY,
},
{
name: 'ratings',
label: () => translate('Rating'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'ratingVotes',
label: () => translate('RatingVotes'),
type: filterBuilderTypes.NUMBER,
},
{
name: 'certification',
label: () => translate('Certification'),
type: filterBuilderTypes.EXACT,
},
{
name: 'tags',
label: () => translate('Tags'),
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.TAG,
},
{
name: 'useSceneNumbering',
label: () => translate('SceneNumbering'),
type: filterBuilderTypes.EXACT,
},
{
name: 'hasMissingSeason',
label: () => translate('HasMissingSeason'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL,
},
{
name: 'seasonsMonitoredStatus',
label: () => translate('SeasonsMonitoredStatus'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.MONITORED_STATUS,
},
{
name: 'episodesMonitoredStatus',
label: () => translate('EpisodesMonitoredStatus'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.MONITORED_STATUS,
},
{
name: 'year',
label: () => translate('Year'),
type: filterBuilderTypes.NUMBER,
},
];
const DEFAULT_SERIES: Series[] = [];
const useSeries = () => {
const { data, ...result } = useApiQuery<Series[]>({
path: '/series',
queryOptions: {
staleTime: 5 * 60 * 1000,
gcTime: Infinity,
},
});
const seriesMap = useMemo(() => {
if (!data) {
return new Map<number, Series>();
}
return new Map<number, Series>(data.map((series) => [series.id, series]));
}, [data]);
return {
...result,
data: data ?? DEFAULT_SERIES,
seriesMap,
};
};
export default useSeries;
export const useSeriesIndex = () => {
const { selectedFilterKey, sortKey, sortDirection } = useSeriesOptions();
const { data: seriesData = [], ...queryResult } = useSeries();
const customFilters = useCustomFiltersList('series');
const data = useMemo(() => {
return clientSideFilterAndSort<
Series,
typeof FILTER_PREDICATES,
typeof SORT_PREDICATES
>(seriesData, {
selectedFilterKey,
filters: FILTERS,
filterPredicates: FILTER_PREDICATES,
customFilters,
sortKey: sortKey as keyof Series,
sortDirection,
secondarySortKey: 'sortTitle',
secondarySortDirection: 'ascending',
sortPredicates: SORT_PREDICATES,
});
}, [customFilters, seriesData, selectedFilterKey, sortKey, sortDirection]);
return {
...queryResult,
data: data.data,
totalItems: data.totalItems,
};
};
export const useHasSeries = () => {
const { data: seriesData = [] } = useSeries();
return useMemo(() => {
return seriesData.length > 0;
}, [seriesData]);
};
export const useSingleSeries = (seriesId?: number) => {
const { seriesMap } = useSeries();
return useMemo(() => {
if (!seriesId) {
return undefined;
}
return seriesMap.get(seriesId);
}, [seriesMap, seriesId]);
};
export const useMultipleSeries = (seriesIds: number[]) => {
const { seriesMap } = useSeries();
return useMemo(() => {
if (seriesIds.length === 0) {
return DEFAULT_SERIES;
}
return seriesIds.reduce((acc: Series[], seriesId) => {
const series = seriesMap.get(seriesId);
if (series) {
acc.push(series);
}
return acc;
}, []);
}, [seriesMap, seriesIds]);
};
interface SaveSeriesPayload extends Partial<Series> {
id: number;
}
interface DeleteSeriesPayload {
deleteFiles?: boolean;
addImportListExclusion?: boolean;
}
interface ToggleSeriesMonitoredPayload {
monitored: boolean;
}
interface ToggleSeasonMonitoredPayload {
seasonNumber: number;
monitored: boolean;
}
interface UpdateSeriesMonitorPayload {
series: {
id: number;
monitored?: boolean;
seasons?: {
seasonNumber: number;
monitored: boolean;
}[];
}[];
monitoringOptions?: {
monitor: string;
};
}
interface BulkDeleteSeriesPayload {
seriesIds: number[];
deleteFiles?: boolean;
addImportListExclusion?: boolean;
}
interface SaveSeriesEditorPayload {
seriesIds: number[];
monitored?: boolean;
qualityProfileId?: number;
seriesType?: string;
seasonFolder?: boolean;
rootFolderPath?: string;
tags?: number[];
}
export const useSaveSeries = (moveFiles?: boolean) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
Series,
SaveSeriesPayload
>({
path: '/series',
queryParams: {
moveFiles,
},
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
if (series.id === updatedSeries.id) {
return {
...series,
...updatedSeries,
};
}
return series;
});
});
},
},
});
return {
saveSeries: mutate,
isSaving: isPending,
saveError: error,
};
};
export const useDeleteSeries = (
seriesId: number,
options: DeleteSeriesPayload
) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<unknown, void>({
path: `/series/${seriesId}`,
queryParams: {
...options,
},
method: 'DELETE',
mutationOptions: {
onSuccess: () => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.filter((series) => series.id !== seriesId);
});
},
},
});
return {
deleteSeries: mutate,
isDeleting: isPending,
deleteError: error,
};
};
export const useToggleSeriesMonitored = (seriesId: number) => {
const queryClient = useQueryClient();
const series = useSingleSeries(seriesId);
const { mutate, isPending, error } = useApiMutation<
Series,
ToggleSeriesMonitoredPayload
>({
path: '/series',
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) =>
series.id === updatedSeries.id ? updatedSeries : series
);
});
},
},
});
const toggleSeriesMonitored = useCallback(
(payload: ToggleSeriesMonitoredPayload) => {
return mutate({ ...series, ...payload });
},
[series, mutate]
);
return {
toggleSeriesMonitored,
isTogglingSeriesMonitored: isPending,
toggleSeriesMonitoredError: error,
};
};
export const useToggleSeasonMonitored = (seriesId: number) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
Series,
ToggleSeasonMonitoredPayload
>({
path: `/series/${seriesId}/season`,
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
if (series.id === updatedSeries.id) {
return {
...series,
seasons: series.seasons.map((season) => {
const updatedSeason = updatedSeries.seasons.find(
(s) => s.seasonNumber === season.seasonNumber
);
if (updatedSeason) {
return {
...season,
...updatedSeason,
};
}
return season;
}),
};
}
return series;
});
});
},
},
});
return {
toggleSeasonMonitored: mutate,
isTogglingSeasonMonitored: isPending,
toggleSeasonMonitoredError: error,
};
};
export const useUpdateSeriesMonitor = (
shouldFetchEpisodesAfterUpdate = false
) => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
void,
UpdateSeriesMonitorPayload
>({
path: '/seasonPass',
method: 'POST',
mutationOptions: {
onSuccess: (_, variables) => {
if (shouldFetchEpisodesAfterUpdate) {
queryClient.invalidateQueries({ queryKey: ['/episode'] });
}
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
const updatedSeries = variables.series.find(
(s) => s.id === series.id
);
if (!updatedSeries) {
return series;
}
return {
...series,
monitored: updatedSeries.monitored ?? series.monitored,
seasons: series.seasons.map((season) => {
const updatedSeason = updatedSeries.seasons?.find(
(s) => s.seasonNumber === season.seasonNumber
);
if (updatedSeason) {
return {
...season,
monitored: updatedSeason.monitored,
};
}
return season;
}),
};
});
});
},
},
});
return {
updateSeriesMonitor: mutate,
isUpdatingSeriesMonitor: isPending,
updateSeriesMonitorError: error,
};
};
export const useSaveSeriesEditor = () => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
Series[],
SaveSeriesEditorPayload
>({
path: '/series/editor',
method: 'PUT',
mutationOptions: {
onSuccess: (updatedSeries) => {
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.map((series) => {
const updatedSeriesData = updatedSeries.find(
(updated) => updated.id === series.id
);
if (updatedSeriesData) {
const {
alternateTitles,
images,
rootFolderPath,
statistics,
...propsToUpdate
} = updatedSeriesData;
return { ...series, ...propsToUpdate };
}
return series;
});
});
},
},
});
return {
saveSeriesEditor: mutate,
isSavingSeriesEditor: isPending,
saveSeriesEditorError: error,
};
};
export const useBulkDeleteSeries = () => {
const queryClient = useQueryClient();
const { mutate, isPending, error } = useApiMutation<
void,
BulkDeleteSeriesPayload
>({
path: '/series/editor',
method: 'DELETE',
mutationOptions: {
onSuccess: (_, variables) => {
const seriesIds = new Set(variables.seriesIds);
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
if (!oldSeries) {
return oldSeries;
}
return oldSeries.filter((series) => !seriesIds.has(series.id));
});
},
},
});
return {
bulkDeleteSeries: mutate,
isBulkDeleting: isPending,
bulkDeleteError: error,
};
};