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[] = [ { 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[]>( (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[]>( (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[]>( (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({ path: '/series', queryOptions: { staleTime: 5 * 60 * 1000, gcTime: Infinity, }, }); const seriesMap = useMemo(() => { if (!data) { return new Map(); } return new Map(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 { 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'], (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({ path: `/series/${seriesId}`, queryParams: { ...options, }, method: 'DELETE', mutationOptions: { onSuccess: () => { queryClient.setQueryData(['/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'], (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'], (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'], (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'], (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'], (oldSeries) => { if (!oldSeries) { return oldSeries; } return oldSeries.filter((series) => !seriesIds.has(series.id)); }); }, }, }); return { bulkDeleteSeries: mutate, isBulkDeleting: isPending, bulkDeleteError: error, }; };