From 1f6b150219d940766c64e79c49249f197224b447 Mon Sep 17 00:00:00 2001 From: Eder Date: Sat, 4 Jul 2026 11:36:23 -0400 Subject: [PATCH 1/3] Issue 32: replace full-library preload with paged movie index --- .../src/AddMovie/AddNewMovie/AddNewMovie.js | 6 +- .../AddNewMovie/AddNewMovieConnector.js | 10 +- .../Import/ImportMovieConnector.js | 5 + frontend/src/App/State/MoviesAppState.ts | 11 +- .../src/Collection/CollectionConnector.js | 6 + .../Builder/MovieFilterBuilderRowValue.tsx | 3 + .../Page/Header/MovieSearchInput.tsx | 236 ++--- frontend/src/Components/SignalRConnector.js | 5 - frontend/src/Helpers/Hooks/useAppPage.ts | 7 - .../Helpers/Hooks/useEnsureMoviesLoaded.ts | 19 + .../Movie/SelectMovieModalContent.tsx | 2 + frontend/src/Movie/Details/MovieDetails.tsx | 59 +- .../src/Movie/Details/MovieDetailsPage.tsx | 70 +- frontend/src/Movie/Index/MovieIndex.tsx | 100 +- .../src/Movie/Index/MovieIndexFilterModal.tsx | 3 + frontend/src/Movie/Index/MovieIndexFooter.tsx | 8 +- .../Index/MovieIndexRefreshMovieButton.tsx | 52 +- .../Movie/Index/MovieIndexSearchButton.tsx | 60 +- .../Select/Delete/DeleteMovieModalContent.tsx | 2 + .../Organize/OrganizeMoviesModalContent.tsx | 2 + .../Index/Select/Tags/TagsModalContent.tsx | 2 + ...EditQualityProfileModalContentConnector.js | 5 + .../Tags/Details/TagDetailsModalContent.tsx | 3 + frontend/src/Store/Actions/movieActions.js | 66 +- .../src/Store/Actions/movieIndexActions.js | 145 ++- src/Radarr.Api.V3/Movies/MovieController.cs | 85 +- .../Movies/MovieIndexQueryService.cs | 900 ++++++++++++++++++ 27 files changed, 1567 insertions(+), 305 deletions(-) create mode 100644 frontend/src/Helpers/Hooks/useEnsureMoviesLoaded.ts create mode 100644 src/Radarr.Api.V3/Movies/MovieIndexQueryService.cs diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js index 85ff4dd7da3..a36bc981c02 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js @@ -82,7 +82,8 @@ class AddNewMovie extends Component { const { error, items, - hasExistingMovies + hasExistingMovies, + areMoviesPopulated } = this.props; const term = this.state.term; @@ -188,7 +189,7 @@ class AddNewMovie extends Component { } { - !term && !hasExistingMovies ? + !term && areMoviesPopulated && !hasExistingMovies ?
{translate('HaveNotAddedMovies')} @@ -220,6 +221,7 @@ AddNewMovie.propTypes = { addError: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, hasExistingMovies: PropTypes.bool.isRequired, + areMoviesPopulated: PropTypes.bool.isRequired, onMovieLookupChange: PropTypes.func.isRequired, onClearMovieLookup: PropTypes.func.isRequired }; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js index 5a058657900..59ab0077f67 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { clearAddMovie, lookupMovie } from 'Store/Actions/addMovieActions'; +import { fetchMovies } from 'Store/Actions/movieActions'; import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; @@ -15,14 +16,16 @@ function createMapStateToProps() { return createSelector( (state) => state.addMovie, (state) => state.movies.items.length, + (state) => state.movies.isPopulated, (state) => state.router.location, - (addMovie, existingMoviesCount, location) => { + (addMovie, existingMoviesCount, areMoviesPopulated, location) => { const { params } = parseUrl(location.search); return { ...addMovie, term: params.term, - hasExistingMovies: existingMoviesCount > 0 + hasExistingMovies: existingMoviesCount > 0, + areMoviesPopulated }; } ); @@ -32,6 +35,7 @@ const mapDispatchToProps = { lookupMovie, clearAddMovie, fetchRootFolders, + fetchMovies, fetchQueueDetails, clearQueueDetails, fetchMovieFiles, @@ -51,6 +55,7 @@ class AddNewMovieConnector extends Component { componentDidMount() { this.props.fetchRootFolders(); + this.props.fetchMovies(); this.props.fetchQueueDetails(); } @@ -125,6 +130,7 @@ AddNewMovieConnector.propTypes = { lookupMovie: PropTypes.func.isRequired, clearAddMovie: PropTypes.func.isRequired, fetchRootFolders: PropTypes.func.isRequired, + fetchMovies: PropTypes.func.isRequired, fetchQueueDetails: PropTypes.func.isRequired, clearQueueDetails: PropTypes.func.isRequired, fetchMovieFiles: PropTypes.func.isRequired, diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js index 35972b39b3d..c3363c7da28 100644 --- a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieConnector.js @@ -6,6 +6,7 @@ import { createSelector } from 'reselect'; import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; import { setAddMovieDefault } from 'Store/Actions/addMovieActions'; import { clearImportMovie, importMovie, setImportMovieValue } from 'Store/Actions/importMovieActions'; +import { fetchMovies } from 'Store/Actions/movieActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import ImportMovie from './ImportMovie'; @@ -60,6 +61,7 @@ const mapDispatchToProps = { dispatchSetImportMovieValue: setImportMovieValue, dispatchImportMovie: importMovie, dispatchClearImportMovie: clearImportMovie, + dispatchFetchMovies: fetchMovies, dispatchFetchRootFolders: fetchRootFolders, dispatchSetAddMovieDefault: setAddMovieDefault }; @@ -74,10 +76,12 @@ class ImportMovieConnector extends Component { rootFolderId, qualityProfiles, defaultQualityProfileId, + dispatchFetchMovies, dispatchFetchRootFolders, dispatchSetAddMovieDefault } = this.props; + dispatchFetchMovies(); dispatchFetchRootFolders({ id: rootFolderId, timeout: false }); let setDefaults = false; @@ -146,6 +150,7 @@ ImportMovieConnector.propTypes = { dispatchSetImportMovieValue: PropTypes.func.isRequired, dispatchImportMovie: PropTypes.func.isRequired, dispatchClearImportMovie: PropTypes.func.isRequired, + dispatchFetchMovies: PropTypes.func.isRequired, dispatchFetchRootFolders: PropTypes.func.isRequired, dispatchSetAddMovieDefault: PropTypes.func.isRequired }; diff --git a/frontend/src/App/State/MoviesAppState.ts b/frontend/src/App/State/MoviesAppState.ts index d9b78d8caba..9a1677ce9b0 100644 --- a/frontend/src/App/State/MoviesAppState.ts +++ b/frontend/src/App/State/MoviesAppState.ts @@ -1,5 +1,6 @@ import AppSectionState, { AppSectionDeleteState, + PagedAppSectionState, AppSectionSaveState, } from 'App/State/AppSectionState'; import Column from 'Components/Table/Column'; @@ -7,9 +8,11 @@ import { SortDirection } from 'Helpers/Props/sortDirections'; import Movie from 'Movie/Movie'; import { Filter, FilterBuilderProp } from './AppState'; -export interface MovieIndexAppState { - sortKey: string; - sortDirection: SortDirection; +export interface MovieIndexAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState, + PagedAppSectionState { secondarySortKey: string; secondarySortDirection: SortDirection; view: string; @@ -49,7 +52,7 @@ export interface MovieIndexAppState { showSearchAction: boolean; }; - selectedFilterKey: string; + selectedFilterKey: string | number; filterBuilderProps: FilterBuilderProp[]; filters: Filter[]; columns: Column[]; diff --git a/frontend/src/Collection/CollectionConnector.js b/frontend/src/Collection/CollectionConnector.js index 7a1caeb8782..ace2965b128 100644 --- a/frontend/src/Collection/CollectionConnector.js +++ b/frontend/src/Collection/CollectionConnector.js @@ -11,6 +11,7 @@ import { setMovieCollectionsFilter, setMovieCollectionsSort } from 'Store/Actions/movieCollectionActions'; +import { fetchMovies } from 'Store/Actions/movieActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; import scrollPositions from 'Store/scrollPositions'; import createCollectionClientSideCollectionItemsSelector from 'Store/Selectors/createCollectionClientSideCollectionItemsSelector'; @@ -42,6 +43,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchMovieCollections() { dispatch(fetchMovieCollections()); }, + dispatchFetchMovies() { + dispatch(fetchMovies()); + }, dispatchFetchQueueDetails() { dispatch(fetchQueueDetails()); }, @@ -72,6 +76,7 @@ class CollectionConnector extends Component { componentDidMount() { this.props.dispatchFetchMovieCollections(); + this.props.dispatchFetchMovies(); this.props.dispatchFetchQueueDetails(); } @@ -117,6 +122,7 @@ CollectionConnector.propTypes = { view: PropTypes.string.isRequired, onUpdateSelectedPress: PropTypes.func.isRequired, dispatchFetchMovieCollections: PropTypes.func.isRequired, + dispatchFetchMovies: PropTypes.func.isRequired, dispatchFetchQueueDetails: PropTypes.func.isRequired, dispatchClearQueueDetails: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Filter/Builder/MovieFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/MovieFilterBuilderRowValue.tsx index 509d4e2a21a..8ea1a3063f1 100644 --- a/frontend/src/Components/Filter/Builder/MovieFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/MovieFilterBuilderRowValue.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import Movie from 'Movie/Movie'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import sortByProp from 'Utilities/Array/sortByProp'; @@ -7,6 +8,8 @@ import FilterBuilderRowValue from './FilterBuilderRowValue'; import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; function MovieFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + useEnsureMoviesLoaded(); + const allMovies: Movie[] = useSelector(createAllMoviesSelector()); const tagList = allMovies diff --git a/frontend/src/Components/Page/Header/MovieSearchInput.tsx b/frontend/src/Components/Page/Header/MovieSearchInput.tsx index 5cba4b6997e..84ead4a95d8 100644 --- a/frontend/src/Components/Page/Header/MovieSearchInput.tsx +++ b/frontend/src/Components/Page/Header/MovieSearchInput.tsx @@ -11,18 +11,14 @@ import React, { useState, } from 'react'; import Autosuggest from 'react-autosuggest'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; +import { useDispatch } from 'react-redux'; import { useDebouncedCallback } from 'use-debounce'; import { Tag } from 'App/State/TagsAppState'; import Icon from 'Components/Icon'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts'; import { icons } from 'Helpers/Props'; -import Movie from 'Movie/Movie'; -import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; -import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; import translate from 'Utilities/String/translate'; import MovieSearchResult from './MovieSearchResult'; import styles from './MovieSearchInput.css'; @@ -39,19 +35,15 @@ interface AddNewMovieSuggestion { title: string; } -export interface SuggestedMovie - extends Pick< - Movie, - | 'title' - | 'year' - | 'titleSlug' - | 'sortTitle' - | 'images' - | 'alternateTitles' - | 'tmdbId' - | 'imdbId' - > { - firstCharacter: string; +export interface SuggestedMovie { + title: string; + year: number; + titleSlug: string; + sortTitle: string; + images: Array; + alternateTitles: Array<{ title: string }>; + tmdbId: number; + imdbId: string; tags: Tag[]; } @@ -69,78 +61,26 @@ interface Section { suggestions: MovieSuggestion[] | AddNewMovieSuggestion[]; } -function createUnoptimizedSelector() { - return createSelector( - createAllMoviesSelector(), - createTagsSelector(), - (allMovies, allTags) => { - return allMovies.map((movie): SuggestedMovie => { - const { - title, - year, - titleSlug, - sortTitle, - images, - alternateTitles = [], - tmdbId, - imdbId, - tags = [], - } = movie; - - return { - title, - year, - titleSlug, - sortTitle, - images, - alternateTitles, - tmdbId, - imdbId, - firstCharacter: title.charAt(0).toLowerCase(), - tags: tags.reduce((acc, id) => { - const matchingTag = allTags.find((tag) => tag.id === id); - - if (matchingTag) { - acc.push(matchingTag); - } - - return acc; - }, []), - }; - }); - } - ); -} - -function createMoviesSelector() { - return createDeepEqualSelector( - createUnoptimizedSelector(), - (movies) => movies - ); -} - function MovieSearchInput() { - const movies = useSelector(createMoviesSelector()); const dispatch = useDispatch(); const { bindShortcut, unbindShortcut } = useKeyboardShortcuts(); const [value, setValue] = useState(''); - const [requestLoading, setRequestLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [suggestions, setSuggestions] = useState([]); const autosuggestRef = useRef(null); const inputRef = useRef(null); - const worker = useRef(null); - const isLoading = useRef(false); - const requestValue = useRef(null); + const latestQueryRef = useRef(''); + const abortRequestRef = useRef<(() => void) | null>(null); const suggestionGroups = useMemo(() => { const result: Section[] = []; - if (suggestions.length || isLoading.current) { + if (suggestions.length || isLoading) { result.push({ title: translate('ExistingMovies'), - loading: isLoading.current, + loading: isLoading, suggestions, }); } @@ -156,59 +96,73 @@ function MovieSearchInput() { }); return result; - }, [suggestions, value]); - - const handleSuggestionsReceived = useCallback( - (message: { data: { value: string; suggestions: MovieSuggestion[] } }) => { - const { value, suggestions } = message.data; - - if (!isLoading.current) { - requestValue.current = null; - setRequestLoading(false); - } else if (value === requestValue.current) { - setSuggestions(suggestions); - requestValue.current = null; - setRequestLoading(false); - isLoading.current = false; - // setLoading(false); - } else { - setSuggestions(suggestions); - setRequestLoading(true); + }, [isLoading, suggestions, value]); - const payload = { - value: requestValue, - movies, - }; + const requestSuggestions = useDebouncedCallback((query: string) => { + const trimmed = query.trim(); - worker.current?.postMessage(payload); - } - }, - [movies] - ); + abortRequestRef.current?.(); - const requestSuggestions = useDebouncedCallback((value: string) => { - if (!isLoading.current) { + if (!trimmed) { + setIsLoading(false); + setSuggestions([]); + latestQueryRef.current = ''; return; } - requestValue.current = value; - setRequestLoading(true); + latestQueryRef.current = trimmed; + setIsLoading(true); - if (!requestLoading) { - const payload = { - value, - movies, - }; + const { request, abortRequest } = createAjaxRequest({ + url: '/movie/search', + data: { + term: trimmed, + }, + traditional: true, + }); - worker.current?.postMessage(payload); - } + abortRequestRef.current = abortRequest; + + request.done((data) => { + if (latestQueryRef.current !== trimmed) { + return; + } + + const nextSuggestions = data.map((item) => { + return { + title: item.title, + indices: [], + item, + matches: [ + { + key: item.matchedKey, + refIndex: item.matchedIndex, + }, + ], + refIndex: item.matchedIndex, + }; + }); + + setSuggestions(nextSuggestions); + setIsLoading(false); + }); + + request.fail(() => { + if (latestQueryRef.current !== trimmed) { + return; + } + + setSuggestions([]); + setIsLoading(false); + }); }, 250); const reset = useCallback(() => { + abortRequestRef.current?.(); + latestQueryRef.current = ''; setValue(''); setSuggestions([]); - // setLoading(false); - isLoading.current = false; + setIsLoading(false); }, []); const focusInput = useCallback((event: ExtendedKeyboardEvent) => { @@ -225,13 +179,13 @@ function MovieSearchInput() {
{section.title} - {section.loading && ( + {section.loading ? ( - )} + ) : null}
); }, []); @@ -313,9 +267,6 @@ function MovieSearchInput() { return; } - // If a suggestion is not selected go to the first movie, - // otherwise go to the selected movie. - const selectedSuggestion = highlightedSuggestionIndex == null ? suggestions[0] @@ -330,7 +281,7 @@ function MovieSearchInput() { inputRef.current?.blur(); reset(); }, - [value, suggestions, dispatch, reset] + [dispatch, reset, suggestions, value] ); const handleBlur = useCallback(() => { @@ -339,16 +290,16 @@ function MovieSearchInput() { const handleSuggestionsFetchRequested = useCallback( ({ value }: { value: string }) => { - isLoading.current = true; - requestSuggestions(value); }, [requestSuggestions] ); const handleSuggestionsClearRequested = useCallback(() => { + abortRequestRef.current?.(); + latestQueryRef.current = ''; setSuggestions([]); - isLoading.current = false; + setIsLoading(false); }, []); const handleSuggestionSelected = useCallback( @@ -369,7 +320,7 @@ function MovieSearchInput() { ); } }, - [value, dispatch] + [dispatch, value] ); const inputProps = { @@ -394,42 +345,15 @@ function MovieSearchInput() { suggestionHighlighted: styles.highlighted, }; - useEffect(() => { - worker.current = new Worker(new URL('./fuse.worker.ts', import.meta.url)); - - return () => { - if (worker.current) { - worker.current.terminate(); - worker.current = null; - } - }; - }, []); - - useEffect(() => { - worker.current?.addEventListener( - 'message', - handleSuggestionsReceived, - false - ); - - return () => { - if (worker.current) { - worker.current.removeEventListener( - 'message', - handleSuggestionsReceived, - false - ); - } - }; - }, [handleSuggestionsReceived]); - useEffect(() => { bindShortcut('focusMovieSearchInput', focusInput); return () => { + requestSuggestions.cancel(); + abortRequestRef.current?.(); unbindShortcut('focusMovieSearchInput'); }; - }, [bindShortcut, unbindShortcut, focusInput]); + }, [bindShortcut, focusInput, requestSuggestions, unbindShortcut]); return (
diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 81c2f453f0c..8195e14b715 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -6,7 +6,6 @@ import { createSelector } from 'reselect'; import { setAppValue, setVersion } from 'Store/Actions/appActions'; import { removeItem, update, updateItem } from 'Store/Actions/baseActions'; import { fetchCommands, finishCommand, updateCommand } from 'Store/Actions/commandActions'; -import { fetchMovies } from 'Store/Actions/movieActions'; import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { fetchQualityDefinitions } from 'Store/Actions/settingsActions'; @@ -51,7 +50,6 @@ const mapDispatchToProps = { dispatchFetchQueue: fetchQueue, dispatchFetchQueueDetails: fetchQueueDetails, dispatchFetchRootFolders: fetchRootFolders, - dispatchFetchMovies: fetchMovies, dispatchFetchTags: fetchTags, dispatchFetchTagDetails: fetchTagDetails }; @@ -364,7 +362,6 @@ class SignalRConnector extends Component { const { dispatchFetchCommands, - dispatchFetchMovies, dispatchSetAppValue } = this.props; @@ -377,7 +374,6 @@ class SignalRConnector extends Component { // Repopulate the page (if a repopulator is set) to ensure things // are in sync after reconnecting. - dispatchFetchMovies(); dispatchFetchCommands(); repopulatePage(); }; @@ -417,7 +413,6 @@ SignalRConnector.propTypes = { dispatchFetchQueue: PropTypes.func.isRequired, dispatchFetchQueueDetails: PropTypes.func.isRequired, dispatchFetchRootFolders: PropTypes.func.isRequired, - dispatchFetchMovies: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTagDetails: PropTypes.func.isRequired }; diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts index b84b90b3959..82de9b8cfe5 100644 --- a/frontend/src/Helpers/Hooks/useAppPage.ts +++ b/frontend/src/Helpers/Hooks/useAppPage.ts @@ -4,7 +4,6 @@ import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import { fetchTranslations } from 'Store/Actions/appActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; -import { fetchMovies } from 'Store/Actions/movieActions'; import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions'; import { fetchImportLists, @@ -18,7 +17,6 @@ import { fetchTags } from 'Store/Actions/tagActions'; const createErrorsSelector = () => createSelector( - (state: AppState) => state.movies.error, (state: AppState) => state.movieCollections.error, (state: AppState) => state.customFilters.error, (state: AppState) => state.tags.error, @@ -30,7 +28,6 @@ const createErrorsSelector = () => (state: AppState) => state.system.status.error, (state: AppState) => state.app.translations.error, ( - moviesError, movieCollectionsError, customFiltersError, tagsError, @@ -43,7 +40,6 @@ const createErrorsSelector = () => translationsError ) => { const hasError = !!( - moviesError || movieCollectionsError || customFiltersError || tagsError || @@ -59,7 +55,6 @@ const createErrorsSelector = () => return { hasError, errors: { - moviesError, movieCollectionsError, customFiltersError, tagsError, @@ -80,7 +75,6 @@ const useAppPage = () => { const isPopulated = useSelector( (state: AppState) => - state.movies.isPopulated && state.movieCollections.isPopulated && state.customFilters.isPopulated && state.tags.isPopulated && @@ -109,7 +103,6 @@ const useAppPage = () => { }, []); useEffect(() => { - dispatch(fetchMovies()); dispatch(fetchMovieCollections()); dispatch(fetchCustomFilters()); dispatch(fetchTags()); diff --git a/frontend/src/Helpers/Hooks/useEnsureMoviesLoaded.ts b/frontend/src/Helpers/Hooks/useEnsureMoviesLoaded.ts new file mode 100644 index 00000000000..1b040dcd89c --- /dev/null +++ b/frontend/src/Helpers/Hooks/useEnsureMoviesLoaded.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { fetchMovies } from 'Store/Actions/movieActions'; + +function useEnsureMoviesLoaded(enabled = true) { + const dispatch = useDispatch(); + const { isFetching, isPopulated } = useSelector((state: AppState) => { + return state.movies; + }); + + useEffect(() => { + if (enabled && !isFetching && !isPopulated) { + dispatch(fetchMovies()); + } + }, [dispatch, enabled, isFetching, isPopulated]); +} + +export default useEnsureMoviesLoaded; diff --git a/frontend/src/InteractiveImport/Movie/SelectMovieModalContent.tsx b/frontend/src/InteractiveImport/Movie/SelectMovieModalContent.tsx index ebb9aac1338..76e49520d66 100644 --- a/frontend/src/InteractiveImport/Movie/SelectMovieModalContent.tsx +++ b/frontend/src/InteractiveImport/Movie/SelectMovieModalContent.tsx @@ -17,6 +17,7 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import Scroller from 'Components/Scroller/Scroller'; import Column from 'Components/Table/Column'; import VirtualTableRowButton from 'Components/Table/VirtualTableRowButton'; +import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import { scrollDirections } from 'Helpers/Props'; import Movie from 'Movie/Movie'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; @@ -101,6 +102,7 @@ function Row({ index, style, data }: ListChildComponentProps) { function SelectMovieModalContent(props: SelectMovieModalContentProps) { const { modalTitle, onMovieSelect, onModalClose } = props; + useEnsureMoviesLoaded(); const listRef = useRef>(null); const scrollerRef = useRef(null); diff --git a/frontend/src/Movie/Details/MovieDetails.tsx b/frontend/src/Movie/Details/MovieDetails.tsx index e4aec44d346..eeb36d68102 100644 --- a/frontend/src/Movie/Details/MovieDetails.tsx +++ b/frontend/src/Movie/Details/MovieDetails.tsx @@ -77,13 +77,12 @@ import { clearReleases, } from 'Store/Actions/releaseActions'; import { fetchImportListSchema } from 'Store/Actions/Settings/importLists'; -import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import fonts from 'Styles/Variables/fonts'; -import sortByProp from 'Utilities/Array/sortByProp'; import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; import formatRuntime from 'Utilities/Date/formatRuntime'; import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; import formatBytes from 'Utilities/Number/formatBytes'; @@ -160,7 +159,6 @@ function MovieDetails({ movieId }: MovieDetailsProps) { const history = useHistory(); const movie = useMovie(movieId); - const allMovies = useSelector(createAllMoviesSelector()); const { isMovieFilesFetching, movieFilesError, hasMovieFiles } = useSelector( createMovieFilesSelector() @@ -225,32 +223,10 @@ function MovieDetails({ movieId }: MovieDetailsProps) { }; }, [movieId, commands]); - const { nextMovie, previousMovie } = useMemo(() => { - const sortedMovies = [...allMovies].sort(sortByProp('sortTitle')); - const movieIndex = sortedMovies.findIndex((movie) => movie.id === movieId); - - if (movieIndex === -1) { - return { - nextMovie: undefined, - previousMovie: undefined, - }; - } - - const nextMovie = sortedMovies[movieIndex + 1] ?? sortedMovies[0]; - const previousMovie = - sortedMovies[movieIndex - 1] ?? sortedMovies[sortedMovies.length - 1]; - - return { - nextMovie: { - title: nextMovie.title, - titleSlug: nextMovie.titleSlug, - }, - previousMovie: { - title: previousMovie.title, - titleSlug: previousMovie.titleSlug, - }, - }; - }, [movieId, allMovies]); + const [navigation, setNavigation] = useState<{ + nextMovie?: { title: string; titleSlug: string }; + previousMovie?: { title: string; titleSlug: string }; + }>({}); const touchStart = useRef(null); const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false); @@ -264,6 +240,31 @@ function MovieDetails({ movieId }: MovieDetailsProps) { const [overviewRef, { height: overviewHeight }] = useMeasure(); const wasRefreshing = usePrevious(isRefreshing); const wasRenaming = usePrevious(isRenaming); + const { nextMovie, previousMovie } = navigation; + + useEffect(() => { + if (!movie?.titleSlug) { + setNavigation({}); + return; + } + + const { request, abortRequest } = createAjaxRequest({ + url: `/movie/slug/${movie.titleSlug}/navigation`, + traditional: true, + }); + + request.done((data) => { + setNavigation(data); + }); + + request.fail(() => { + setNavigation({}); + }); + + return () => { + abortRequest(); + }; + }, [movie?.titleSlug]); const handleOrganizePress = useCallback(() => { setIsOrganizeModalOpen(true); diff --git a/frontend/src/Movie/Details/MovieDetailsPage.tsx b/frontend/src/Movie/Details/MovieDetailsPage.tsx index bff9f6e79b1..eb148380b05 100644 --- a/frontend/src/Movie/Details/MovieDetailsPage.tsx +++ b/frontend/src/Movie/Details/MovieDetailsPage.tsx @@ -1,38 +1,68 @@ -import React, { useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { useHistory, useParams } from 'react-router'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import NotFound from 'Components/NotFound'; import usePrevious from 'Helpers/Hooks/usePrevious'; -import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import { fetchMovieByTitleSlug } from 'Store/Actions/movieActions'; import translate from 'Utilities/String/translate'; import MovieDetails from './MovieDetails'; +function createMovieIdSelector(titleSlug: string) { + return createSelector( + (state: AppState) => state.movies.items, + (movies) => { + return movies.find((movie) => movie.titleSlug === titleSlug)?.id; + } + ); +} + function MovieDetailsPage() { - const allMovies = useSelector(createAllMoviesSelector()); + const dispatch = useDispatch(); const { titleSlug } = useParams<{ titleSlug: string }>(); - const history = useHistory(); + const movieId = useSelector(createMovieIdSelector(titleSlug)); + const previousMovieId = usePrevious(movieId); + const [isLoading, setIsLoading] = useState(movieId == null); + const [isNotFound, setIsNotFound] = useState(false); - const movieIndex = allMovies.findIndex( - (movie) => movie.titleSlug === titleSlug - ); - - const previousIndex = usePrevious(movieIndex); + useEffect(() => { + setIsLoading(movieId == null); + setIsNotFound(false); + }, [movieId, titleSlug]); useEffect(() => { - if ( - movieIndex === -1 && - previousIndex !== -1 && - previousIndex !== undefined - ) { - history.push(`${window.Radarr.urlBase}/`); + if (movieId != null) { + return; } - }, [movieIndex, previousIndex, history]); - if (movieIndex === -1) { + const request = dispatch(fetchMovieByTitleSlug({ titleSlug })); + + request.done(() => { + setIsLoading(false); + setIsNotFound(false); + }); + + request.fail((xhr) => { + setIsLoading(false); + setIsNotFound(xhr.status === 404); + }); + }, [dispatch, movieId, titleSlug]); + + if (isLoading && movieId == null) { + return ; + } + + if (isNotFound || (movieId == null && previousMovieId != null)) { + return ; + } + + if (movieId == null) { return ; } - return ; + return ; } export default MovieDetailsPage; diff --git a/frontend/src/Movie/Index/MovieIndex.tsx b/frontend/src/Movie/Index/MovieIndex.tsx index 8ac8db9a32c..122c670b6ac 100644 --- a/frontend/src/Movie/Index/MovieIndex.tsx +++ b/frontend/src/Movie/Index/MovieIndex.tsx @@ -6,10 +6,9 @@ import React, { useState, } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useHistory } from 'react-router'; import { SelectProvider } from 'App/SelectContext'; -import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; -import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState'; +import AppState from 'App/State/AppState'; +import { MovieIndexAppState } from 'App/State/MoviesAppState'; import { RSS_SYNC } from 'Commands/commandNames'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -21,24 +20,33 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import withScrollPosition from 'Components/withScrollPosition'; import { align, icons, kinds } from 'Helpers/Props'; import { DESCENDING } from 'Helpers/Props/sortDirections'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import NoMovie from 'Movie/NoMovie'; import { executeCommand } from 'Store/Actions/commandActions'; -import { fetchMovies } from 'Store/Actions/movieActions'; import { + fetchMovieIndex, + gotoMovieIndexPage, setMovieFilter, setMovieSort, setMovieTableOption, setMovieView, } from 'Store/Actions/movieIndexActions'; import { fetchQueueDetails } from 'Store/Actions/queueActions'; -import scrollPositions from 'Store/scrollPositions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; +import scrollPositions from 'Store/scrollPositions'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; import translate from 'Utilities/String/translate'; import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; @@ -77,23 +85,26 @@ interface MovieIndexProps { } const MovieIndex = withScrollPosition((props: MovieIndexProps) => { - const history = useHistory(); + const requestCurrentPage = useCurrentPage(); const { isFetching, isPopulated, error, - totalItems, items, columns, selectedFilterKey, - filters, - customFilters, sortKey, sortDirection, view, - }: MoviesAppState & MovieIndexAppState & ClientSideCollectionAppState = - useSelector(createMovieClientSideCollectionItemsSelector('movieIndex')); + page, + totalPages, + totalRecords, + filters, + }: MovieIndexAppState = useSelector( + (state: AppState) => state.movieIndex + ); + const customFilters = useSelector(createCustomFiltersSelector('movieIndex')); const isRssSyncExecuting = useSelector( createCommandExecutingSelector(RSS_SYNC) @@ -108,17 +119,50 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { undefined ); const [isSelectMode, setIsSelectMode] = useState(false); + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoMovieIndexPage, + }); useEffect(() => { - if (history.action === 'PUSH') { - dispatch(fetchMovies()); + if (requestCurrentPage) { + dispatch(fetchMovieIndex()); + } else { + dispatch(gotoMovieIndexPage({ page: 1 })); } - }, [history, dispatch]); + }, [dispatch, requestCurrentPage]); useEffect(() => { - dispatch(fetchQueueDetails({ all: true })); + const repopulate = () => { + dispatch(fetchMovieIndex()); + }; + + registerPagePopulator(repopulate, [ + 'movieUpdated', + 'movieFileUpdated', + 'movieFileDeleted', + ]); + + return () => { + unregisterPagePopulator(repopulate); + }; }, [dispatch]); + useEffect(() => { + const movieIds = selectUniqueIds(items, 'id'); + + if (movieIds.length) { + dispatch(fetchQueueDetails({ movieIds })); + } + }, [items, dispatch]); + const onRssSyncPress = useCallback(() => { dispatch( executeCommand({ @@ -132,8 +176,12 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { }, [isSelectMode, setIsSelectMode]); const onTableOptionChange = useCallback( - (payload: unknown) => { + (payload: { pageSize?: number }) => { dispatch(setMovieTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoMovieIndexPage({ page: 1 })); + } }, [dispatch] ); @@ -234,7 +282,7 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { const ViewComponent = useMemo(() => getViewComponent(view), [view]); const isLoaded = !!(!error && isPopulated && items.length); - const hasNoMovie = !totalItems; + const hasNoMovie = !totalRecords; return ( @@ -369,11 +417,25 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { /> + +
) : null} {!error && isPopulated && !items.length ? ( - + ) : null} diff --git a/frontend/src/Movie/Index/MovieIndexFilterModal.tsx b/frontend/src/Movie/Index/MovieIndexFilterModal.tsx index aff5e7877d2..224f633878f 100644 --- a/frontend/src/Movie/Index/MovieIndexFilterModal.tsx +++ b/frontend/src/Movie/Index/MovieIndexFilterModal.tsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import FilterModal from 'Components/Filter/FilterModal'; +import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import { setMovieFilter } from 'Store/Actions/movieIndexActions'; function createMovieSelector() { @@ -30,6 +31,8 @@ interface MovieIndexFilterModalProps { export default function MovieIndexFilterModal( props: MovieIndexFilterModalProps ) { + useEnsureMoviesLoaded(props.isOpen); + const sectionItems = useSelector(createMovieSelector()); const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); const customFilterType = 'movieIndex'; diff --git a/frontend/src/Movie/Index/MovieIndexFooter.tsx b/frontend/src/Movie/Index/MovieIndexFooter.tsx index c7f56abebd4..be11bbeee19 100644 --- a/frontend/src/Movie/Index/MovieIndexFooter.tsx +++ b/frontend/src/Movie/Index/MovieIndexFooter.tsx @@ -3,10 +3,8 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; -import MoviesAppState from 'App/State/MoviesAppState'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; @@ -14,9 +12,9 @@ import styles from './MovieIndexFooter.css'; function createUnoptimizedSelector() { return createSelector( - createClientSideCollectionSelector('movies', 'movieIndex'), - (movies: MoviesAppState) => { - return movies.items.map((m) => { + (state) => state.movieIndex.items, + (movies) => { + return movies.map((m) => { const { monitored, status, hasFile, statistics } = m; return { diff --git a/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx b/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx index d0990c6e3d3..0793d311cbe 100644 --- a/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx +++ b/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx @@ -1,20 +1,19 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useSelect } from 'App/SelectContext'; -import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; -import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState'; +import AppState from 'App/State/AppState'; import { REFRESH_MOVIE } from 'Commands/commandNames'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchMovieIndexIds } from 'Store/Actions/movieIndexActions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; interface MovieIndexRefreshMovieButtonProps { isSelectMode: boolean; - selectedFilterKey: string; + selectedFilterKey: string | number; } function MovieIndexRefreshMovieButton( @@ -23,11 +22,7 @@ function MovieIndexRefreshMovieButton( const isRefreshing = useSelector( createCommandExecutingSelector(REFRESH_MOVIE) ); - const { - items, - totalItems, - }: MoviesAppState & MovieIndexAppState & ClientSideCollectionAppState = - useSelector(createMovieClientSideCollectionItemsSelector('movieIndex')); + const { totalRecords } = useSelector((state: AppState) => state.movieIndex); const dispatch = useDispatch(); const { isSelectMode, selectedFilterKey } = props; @@ -38,11 +33,6 @@ function MovieIndexRefreshMovieButton( return getSelectedIds(selectedState); }, [selectedState]); - const moviesToRefresh = - isSelectMode && selectedMovieIds.length > 0 - ? selectedMovieIds - : items.map((m) => m.id); - const refreshIndexLabel = selectedFilterKey === 'all' ? translate('UpdateAll') @@ -54,19 +44,37 @@ function MovieIndexRefreshMovieButton( : translate('UpdateAll'); const onPress = useCallback(() => { - dispatch( - executeCommand({ - name: REFRESH_MOVIE, - movieIds: moviesToRefresh, - }) - ); - }, [dispatch, moviesToRefresh]); + const request = + isSelectMode && selectedMovieIds.length + ? null + : dispatch(fetchMovieIndexIds()); + + if (!request) { + dispatch( + executeCommand({ + name: REFRESH_MOVIE, + movieIds: selectedMovieIds, + }) + ); + + return; + } + + request.done((movieIds) => { + dispatch( + executeCommand({ + name: REFRESH_MOVIE, + movieIds, + }) + ); + }); + }, [dispatch, isSelectMode, selectedMovieIds]); return ( diff --git a/frontend/src/Movie/Index/MovieIndexSearchButton.tsx b/frontend/src/Movie/Index/MovieIndexSearchButton.tsx index 79aa10f86d9..ff2cf62c42e 100644 --- a/frontend/src/Movie/Index/MovieIndexSearchButton.tsx +++ b/frontend/src/Movie/Index/MovieIndexSearchButton.tsx @@ -1,30 +1,26 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useSelect } from 'App/SelectContext'; -import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; -import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState'; +import AppState from 'App/State/AppState'; import { MOVIE_SEARCH } from 'Commands/commandNames'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons, kinds } from 'Helpers/Props'; import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchMovieIndexIds } from 'Store/Actions/movieIndexActions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; interface MovieIndexSearchButtonProps { isSelectMode: boolean; - selectedFilterKey: string; + selectedFilterKey: string | number; overflowComponent: React.FunctionComponent; } function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { const isSearching = useSelector(createCommandExecutingSelector(MOVIE_SEARCH)); - const { - items, - }: MoviesAppState & MovieIndexAppState & ClientSideCollectionAppState = - useSelector(createMovieClientSideCollectionItemsSelector('movieIndex')); + const { totalRecords } = useSelector((state: AppState) => state.movieIndex); const dispatch = useDispatch(); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); @@ -37,10 +33,10 @@ function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { return getSelectedIds(selectedState); }, [selectedState]); - const moviesToSearch = + const searchCount = isSelectMode && selectedMovieIds.length > 0 - ? selectedMovieIds - : items.map((m) => m.id); + ? selectedMovieIds.length + : totalRecords; const searchIndexLabel = selectedFilterKey === 'all' @@ -55,30 +51,48 @@ function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { const onPress = useCallback(() => { setIsConfirmModalOpen(false); - dispatch( - executeCommand({ - name: MOVIE_SEARCH, - movieIds: moviesToSearch, - }) - ); - }, [dispatch, moviesToSearch]); + const request = + isSelectMode && selectedMovieIds.length > 0 + ? null + : dispatch(fetchMovieIndexIds()); + + if (!request) { + dispatch( + executeCommand({ + name: MOVIE_SEARCH, + movieIds: selectedMovieIds, + }) + ); + + return; + } + + request.done((movieIds) => { + dispatch( + executeCommand({ + name: MOVIE_SEARCH, + movieIds, + }) + ); + }); + }, [dispatch, isSelectMode, selectedMovieIds]); const onConfirmPress = useCallback(() => { setIsConfirmModalOpen(true); - }, [setIsConfirmModalOpen]); + }, []); const onConfirmModalClose = useCallback(() => { setIsConfirmModalOpen(false); - }, [setIsConfirmModalOpen]); + }, []); return ( <> 5 ? onConfirmPress : onPress} + onPress={searchCount > 5 ? onConfirmPress : onPress} /> { const newPayload = { ...payload @@ -432,12 +437,71 @@ function getSaveAjaxOptions({ ajaxOptions, payload }) { return ajaxOptions; } +export function mergeMovies(existingMovies, incomingMovies) { + const movies = [...existingMovies]; + const movieIndexes = movies.reduce((acc, movie, index) => { + acc[movie.id] = index; + + return acc; + }, {}); + + incomingMovies.forEach((movie) => { + const index = movieIndexes[movie.id]; + + if (index == null) { + movieIndexes[movie.id] = movies.length; + movies.push(movie); + + return; + } + + movies[index] = movie; + }); + + return movies; +} + // // Action Handlers export const actionHandlers = handleThunks({ [FETCH_MOVIES]: createFetchHandler(section, '/movie'), + + [FETCH_MOVIES_BY_IDS]: (getState, payload, dispatch) => { + const { request } = createAjaxRequest({ + url: '/movie', + data: payload, + traditional: true + }); + + request.done((data) => { + dispatch(update({ + section, + data: mergeMovies(getState().movies.items, data) + })); + }); + + return request; + }, + + [FETCH_MOVIE_BY_TITLE_SLUG]: (getState, payload, dispatch) => { + const { titleSlug } = payload; + const { request } = createAjaxRequest({ + url: `/movie/slug/${titleSlug}`, + traditional: true + }); + + request.done((data) => { + dispatch(update({ + section, + data: mergeMovies(getState().movies.items, [data]) + })); + }); + + return request; + }, + [SAVE_MOVIE]: createSaveProviderHandler(section, '/movie', { getAjaxOptions: getSaveAjaxOptions }), [DELETE_MOVIE]: (getState, payload, dispatch) => { createRemoveItemHandler(section, '/movie')(getState, payload, dispatch); diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js index e36bee132a9..9c983093ca0 100644 --- a/frontend/src/Store/Actions/movieIndexActions.js +++ b/frontend/src/Store/Actions/movieIndexActions.js @@ -1,12 +1,21 @@ import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import createHandleActions from './Creators/createHandleActions'; -import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; -import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; -import { filterPredicates, filters, sortPredicates } from './movieActions'; +import { + filterPredicates, + filters, + mergeMovies, + sortPredicates, +} from './movieActions'; +import { set, update, updateServerSideCollection } from './baseActions'; // // Variables @@ -17,6 +26,14 @@ export const section = 'movieIndex'; // State export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + page: 1, + pageSize: 20, + totalPages: 1, + totalRecords: 0, + items: [], isSaving: false, saveError: null, isDeleting: false, @@ -584,10 +601,10 @@ export const defaultState = { }; export const persistState = [ + 'movieIndex.pageSize', 'movieIndex.sortKey', 'movieIndex.sortDirection', 'movieIndex.selectedFilterKey', - 'movieIndex.customFilters', 'movieIndex.view', 'movieIndex.columns', 'movieIndex.posterOptions', @@ -598,30 +615,140 @@ export const persistState = [ // // Actions Types +export const FETCH_MOVIE_INDEX = 'movieIndex/fetchMovieIndex'; +export const GOTO_FIRST_MOVIE_INDEX_PAGE = 'movieIndex/gotoMovieIndexFirstPage'; +export const GOTO_PREVIOUS_MOVIE_INDEX_PAGE = + 'movieIndex/gotoMovieIndexPreviousPage'; +export const GOTO_NEXT_MOVIE_INDEX_PAGE = 'movieIndex/gotoMovieIndexNextPage'; +export const GOTO_LAST_MOVIE_INDEX_PAGE = 'movieIndex/gotoMovieIndexLastPage'; +export const GOTO_MOVIE_INDEX_PAGE = 'movieIndex/gotoMovieIndexPage'; export const SET_MOVIE_SORT = 'movieIndex/setMovieSort'; export const SET_MOVIE_FILTER = 'movieIndex/setMovieFilter'; export const SET_MOVIE_VIEW = 'movieIndex/setMovieView'; export const SET_MOVIE_TABLE_OPTION = 'movieIndex/setMovieTableOption'; export const SET_MOVIE_POSTER_OPTION = 'movieIndex/setMoviePosterOption'; export const SET_MOVIE_OVERVIEW_OPTION = 'movieIndex/setMovieOverviewOption'; +export const FETCH_MOVIE_INDEX_IDS = 'movieIndex/fetchMovieIndexIds'; // // Action Creators -export const setMovieSort = createAction(SET_MOVIE_SORT); -export const setMovieFilter = createAction(SET_MOVIE_FILTER); +export const fetchMovieIndex = createThunk(FETCH_MOVIE_INDEX); +export const gotoMovieIndexFirstPage = createThunk(GOTO_FIRST_MOVIE_INDEX_PAGE); +export const gotoMovieIndexPreviousPage = createThunk( + GOTO_PREVIOUS_MOVIE_INDEX_PAGE +); +export const gotoMovieIndexNextPage = createThunk(GOTO_NEXT_MOVIE_INDEX_PAGE); +export const gotoMovieIndexLastPage = createThunk(GOTO_LAST_MOVIE_INDEX_PAGE); +export const gotoMovieIndexPage = createThunk(GOTO_MOVIE_INDEX_PAGE); +export const setMovieSort = createThunk(SET_MOVIE_SORT); +export const setMovieFilter = createThunk(SET_MOVIE_FILTER); export const setMovieView = createAction(SET_MOVIE_VIEW); export const setMovieTableOption = createAction(SET_MOVIE_TABLE_OPTION); export const setMoviePosterOption = createAction(SET_MOVIE_POSTER_OPTION); export const setMovieOverviewOption = createAction(SET_MOVIE_OVERVIEW_OPTION); +export const fetchMovieIndexIds = createThunk(FETCH_MOVIE_INDEX_IDS); + +function augmentFetchData(getState, payload, data) { + const movieIndex = getState().movieIndex; + const selectedFilterKey = + payload?.selectedFilterKey ?? movieIndex.selectedFilterKey; + + delete data.selectedFilterKey; + + if (typeof selectedFilterKey === 'number') { + data.customFilterId = selectedFilterKey; + } else { + data.filterKey = selectedFilterKey; + } +} // // Reducers -export const reducers = createHandleActions({ +handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/movie/paged', + fetchMovieIndex, + { + [serverSideCollectionHandlers.FETCH]: FETCH_MOVIE_INDEX, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MOVIE_INDEX_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: + GOTO_PREVIOUS_MOVIE_INDEX_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MOVIE_INDEX_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MOVIE_INDEX_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MOVIE_INDEX_PAGE, + [serverSideCollectionHandlers.SORT]: SET_MOVIE_SORT, + [serverSideCollectionHandlers.FILTER]: SET_MOVIE_FILTER, + }, + augmentFetchData + ), + + [FETCH_MOVIE_INDEX]: (getState, payload, dispatch) => { + dispatch(set({ section, isFetching: true })); + + const movieIndex = getState().movieIndex; + const data = { + page: payload.page || movieIndex.page || 1, + pageSize: movieIndex.pageSize, + sortKey: movieIndex.sortKey, + sortDirection: movieIndex.sortDirection, + }; - [SET_MOVIE_SORT]: createSetClientSideCollectionSortReducer(section), - [SET_MOVIE_FILTER]: createSetClientSideCollectionFilterReducer(section), + augmentFetchData(getState, payload, data); + + const promise = createAjaxRequest({ + url: '/movie/paged', + data, + traditional: true, + }).request; + + promise.done((response) => { + const nextMovies = mergeMovies(getState().movies.items, response.records); + + dispatch(batchActions([ + updateServerSideCollection({ section, data: response }), + update({ section: 'movies', data: nextMovies }), + set({ + section, + isFetching: false, + isPopulated: true, + error: null, + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr, + })); + }); + + return promise; + }, + + [FETCH_MOVIE_INDEX_IDS]: (getState, payload) => { + const movieIndex = getState().movieIndex; + const data = { + sortKey: payload?.sortKey ?? movieIndex.sortKey, + sortDirection: payload?.sortDirection ?? movieIndex.sortDirection, + }; + + augmentFetchData(getState, payload, data); + + return createAjaxRequest({ + url: '/movie/ids', + data, + traditional: true, + }).request; + }, +}); + +export const reducers = createHandleActions({ [SET_MOVIE_VIEW]: function(state, { payload }) { return Object.assign({}, state, { view: payload.view }); diff --git a/src/Radarr.Api.V3/Movies/MovieController.cs b/src/Radarr.Api.V3/Movies/MovieController.cs index 7bc3ea3c122..33fa264d055 100644 --- a/src/Radarr.Api.V3/Movies/MovieController.cs +++ b/src/Radarr.Api.V3/Movies/MovieController.cs @@ -47,6 +47,7 @@ public class MovieController : RestControllerWithSignalR, private readonly IRootFolderService _rootFolderService; private readonly IUpgradableSpecification _qualityUpgradableSpecification; private readonly IConfigService _configService; + private readonly IMovieIndexQueryService _movieIndexQueryService; public MovieController(IBroadcastSignalRMessage signalRBroadcaster, IMovieService moviesService, @@ -57,6 +58,7 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster, IManageCommandQueue commandQueueManager, IRootFolderService rootFolderService, IUpgradableSpecification qualityUpgradableSpecification, + IMovieIndexQueryService movieIndexQueryService, IConfigService configService, RootFolderValidator rootFolderValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator, @@ -79,6 +81,7 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster, _coverMapper = coverMapper; _commandQueueManager = commandQueueManager; _rootFolderService = rootFolderService; + _movieIndexQueryService = movieIndexQueryService; SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) .IsValidPath() @@ -114,7 +117,7 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster, } [HttpGet] - public List AllMovie(int? tmdbId, bool excludeLocalCovers = false, int? languageId = null) + public List AllMovie(int? tmdbId, [FromQuery(Name = "movieIds")] List movieIds = null, bool excludeLocalCovers = false, int? languageId = null) { var moviesResources = new List(); @@ -131,6 +134,37 @@ public List AllMovie(int? tmdbId, bool excludeLocalCovers = false moviesResources.AddIfNotNull(MapToResource(movie, translationLanguage)); } } + else if (movieIds is { Count: > 0 }) + { + var availDelay = _configService.AvailabilityDelay; + var translations = _movieTranslationService + .GetAllTranslationsForLanguage(translationLanguage) + .ToDictionaryIgnoreDuplicates((translation) => translation.MovieMetadataId); + var stats = _movieStatisticsService.MovieStatistics().ToDictionary((movieStat) => movieStat.MovieId); + var rootFolders = _rootFolderService.All(); + var movies = _moviesService.GetMovies(movieIds) + .OrderBy((movie) => movieIds.IndexOf(movie.Id)) + .ToList(); + + foreach (var movie in movies) + { + translations.TryGetValue(movie.MovieMetadataId, out var translation); + var resource = movie.ToResource(availDelay, translation, _qualityUpgradableSpecification); + + if (!excludeLocalCovers) + { + MapCoversToLocal(resource); + } + + if (stats.TryGetValue(movie.Id, out var movieStatistics)) + { + LinkMovieStatistics(resource, movieStatistics); + } + + resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path, rootFolders); + moviesResources.Add(resource); + } + } else { var movieStats = _movieStatisticsService.MovieStatistics(); @@ -171,6 +205,55 @@ public List AllMovie(int? tmdbId, bool excludeLocalCovers = false return moviesResources; } + [HttpGet("paged")] + [Produces("application/json")] + public PagingResource GetPagedMovies([FromQuery] MovieIndexPagingRequestResource paging) + { + return _movieIndexQueryService.GetPaged(paging); + } + + [HttpGet("ids")] + [Produces("application/json")] + public List GetMovieIds([FromQuery] MovieIndexPagingRequestResource paging) + { + return _movieIndexQueryService.GetMovieIds(paging); + } + + [HttpGet("search")] + [Produces("application/json")] + public List SearchMovies([FromQuery] string term, [FromQuery] int limit = 20) + { + return _movieIndexQueryService.Search(term, limit); + } + + [HttpGet("slug/{titleSlug}")] + [Produces("application/json")] + public ActionResult GetMovieByTitleSlug(string titleSlug) + { + var resource = _movieIndexQueryService.GetMovieByTitleSlug(titleSlug); + + if (resource == null) + { + return NotFound(); + } + + return resource; + } + + [HttpGet("slug/{titleSlug}/navigation")] + [Produces("application/json")] + public ActionResult GetMovieNavigation(string titleSlug) + { + var resource = _movieIndexQueryService.GetNavigation(titleSlug); + + if (resource == null) + { + return NotFound(); + } + + return resource; + } + protected override MovieResource GetResourceById(int id) { var movie = _moviesService.GetMovie(id); diff --git a/src/Radarr.Api.V3/Movies/MovieIndexQueryService.cs b/src/Radarr.Api.V3/Movies/MovieIndexQueryService.cs new file mode 100644 index 00000000000..0ef2ad7f73f --- /dev/null +++ b/src/Radarr.Api.V3/Movies/MovieIndexQueryService.cs @@ -0,0 +1,900 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.CustomFilters; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Languages; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Translations; +using NzbDrone.Core.MovieStats; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.Tags; +using Radarr.Http; + +namespace Radarr.Api.V3.Movies +{ + public class MovieIndexPagingRequestResource : PagingRequestResource + { + public string FilterKey { get; set; } + public int? CustomFilterId { get; set; } + } + + public class MovieNavigationItemResource + { + public string Title { get; set; } + public string TitleSlug { get; set; } + } + + public class MovieNavigationResource + { + public MovieNavigationItemResource PreviousMovie { get; set; } + public MovieNavigationItemResource NextMovie { get; set; } + } + + public class MovieSearchResultResource + { + public string Title { get; set; } + public int Year { get; set; } + public string TitleSlug { get; set; } + public string SortTitle { get; set; } + public List Images { get; set; } + public List AlternateTitles { get; set; } + public int TmdbId { get; set; } + public string ImdbId { get; set; } + public List Tags { get; set; } + public string MatchedKey { get; set; } + public int MatchedIndex { get; set; } + } + + public class TagLookupResource + { + public int Id { get; set; } + public string Label { get; set; } + } + + public interface IMovieIndexQueryService + { + PagingResource GetPaged(MovieIndexPagingRequestResource request); + List GetMovieIds(MovieIndexPagingRequestResource request); + MovieResource GetMovieByTitleSlug(string titleSlug); + MovieNavigationResource GetNavigation(string titleSlug); + List Search(string term, int limit = 20); + } + + public class MovieIndexQueryService : IMovieIndexQueryService + { + private const string DefaultFilterKey = "all"; + private const string DefaultSortKey = "sortTitle"; + + private readonly IMovieService _movieService; + private readonly IMovieTranslationService _movieTranslationService; + private readonly IMovieStatisticsService _movieStatisticsService; + private readonly ICustomFilterService _customFilterService; + private readonly IRootFolderService _rootFolderService; + private readonly ITagService _tagService; + private readonly IMapCoversToLocal _coverMapper; + private readonly IUpgradableSpecification _qualityUpgradableSpecification; + private readonly IConfigService _configService; + + public MovieIndexQueryService( + IMovieService movieService, + IMovieTranslationService movieTranslationService, + IMovieStatisticsService movieStatisticsService, + ICustomFilterService customFilterService, + IRootFolderService rootFolderService, + ITagService tagService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification qualityUpgradableSpecification, + IConfigService configService) + { + _movieService = movieService; + _movieTranslationService = movieTranslationService; + _movieStatisticsService = movieStatisticsService; + _customFilterService = customFilterService; + _rootFolderService = rootFolderService; + _tagService = tagService; + _coverMapper = coverMapper; + _qualityUpgradableSpecification = qualityUpgradableSpecification; + _configService = configService; + } + + public PagingResource GetPaged(MovieIndexPagingRequestResource request) + { + var paging = NormalizeRequest(request); + var context = BuildContext(); + var filtered = ApplyFilters(context.Items, paging, context); + var ordered = ApplySort(filtered, paging.SortKey, paging.SortDirection.Value); + var pageItems = ordered + .Skip(Math.Max(paging.Page.Value - 1, 0) * paging.PageSize.Value) + .Take(paging.PageSize.Value) + .ToList(); + + return new PagingResource(paging) + { + TotalRecords = filtered.Count, + Records = MapResources(pageItems, context) + }; + } + + public List GetMovieIds(MovieIndexPagingRequestResource request) + { + var paging = NormalizeRequest(request); + var context = BuildContext(); + + return ApplySort( + ApplyFilters(context.Items, paging, context), + paging.SortKey, + paging.SortDirection.Value) + .Select((item) => item.Movie.Id) + .ToList(); + } + + public MovieResource GetMovieByTitleSlug(string titleSlug) + { + if (!TryParseTitleSlug(titleSlug, out var tmdbId)) + { + return null; + } + + var context = BuildContext(); + var item = context.Items.FirstOrDefault((candidate) => candidate.Movie.TmdbId == tmdbId); + + if (item == null) + { + return null; + } + + return MapResources(new List { item }, context).FirstOrDefault(); + } + + public MovieNavigationResource GetNavigation(string titleSlug) + { + if (!TryParseTitleSlug(titleSlug, out var tmdbId)) + { + return null; + } + + var context = BuildContext(); + var ordered = ApplySort(context.Items, DefaultSortKey, NzbDrone.Core.Datastore.SortDirection.Ascending); + var index = ordered.FindIndex((item) => item.Movie.TmdbId == tmdbId); + + if (index < 0 || ordered.Count == 0) + { + return null; + } + + var previous = ordered[(index - 1 + ordered.Count) % ordered.Count]; + var next = ordered[(index + 1) % ordered.Count]; + + return new MovieNavigationResource + { + PreviousMovie = new MovieNavigationItemResource + { + Title = previous.Title, + TitleSlug = previous.TitleSlug + }, + NextMovie = new MovieNavigationItemResource + { + Title = next.Title, + TitleSlug = next.TitleSlug + } + }; + } + + public List Search(string term, int limit = 20) + { + if (term.IsNullOrWhiteSpace()) + { + return new List(); + } + + var context = BuildContext(); + var query = term.Trim(); + + var results = context.Items + .Select((item) => BuildSearchResult(item, context.TagLabels, query)) + .Where((result) => result != null) + .OrderBy((result) => result.SortTitle, StringComparer.InvariantCultureIgnoreCase) + .Take(limit) + .ToList(); + + return results; + } + + private MovieIndexPagingRequestResource NormalizeRequest(MovieIndexPagingRequestResource request) + { + request ??= new MovieIndexPagingRequestResource(); + request.Page ??= 1; + request.PageSize ??= 20; + request.FilterKey ??= DefaultFilterKey; + request.SortKey ??= DefaultSortKey; + + if (request.SortDirection == null || request.SortDirection == NzbDrone.Core.Datastore.SortDirection.Default) + { + request.SortDirection = NzbDrone.Core.Datastore.SortDirection.Ascending; + } + + return request; + } + + private MovieQueryContext BuildContext() + { + var language = (Language)_configService.MovieInfoLanguage; + var translations = _movieTranslationService.GetAllTranslationsForLanguage(language) + .GroupBy((translation) => translation.MovieMetadataId) + .ToDictionary((group) => group.Key, (group) => group.First()); + var statistics = _movieStatisticsService.MovieStatistics() + .ToDictionary((movieStat) => movieStat.MovieId); + var rootFolders = _rootFolderService.All(); + var tagLabels = _tagService.All() + .ToDictionary((tag) => tag.Id, (tag) => tag.Label); + var items = _movieService.GetAllMovies() + .Select((movie) => + { + statistics.TryGetValue(movie.Id, out var movieStatistics); + translations.TryGetValue(movie.MovieMetadataId, out var translation); + + return new MovieQueryItem(movie, translation, movieStatistics ?? new MovieStatistics(), _configService.AvailabilityDelay); + }) + .ToList(); + + return new MovieQueryContext + { + RootFolders = rootFolders, + Items = items, + TagLabels = tagLabels + }; + } + + private List MapResources(List items, MovieQueryContext context) + { + var resources = new List(items.Count); + + foreach (var item in items) + { + var resource = item.Movie.ToResource( + _configService.AvailabilityDelay, + item.Translation, + _qualityUpgradableSpecification); + + resource.Statistics = item.Statistics.ToResource(); + resource.HasFile = item.Statistics.MovieFileCount > 0; + resource.SizeOnDisk = item.Statistics.SizeOnDisk; + resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path, context.RootFolders); + + _coverMapper.ConvertToLocalUrls(resource.Id, resource.Images); + + resources.Add(resource); + } + + return resources; + } + + private List ApplyFilters( + List items, + MovieIndexPagingRequestResource request, + MovieQueryContext context) + { + var filters = GetBuiltInFilters(request.FilterKey); + + if (request.CustomFilterId.HasValue) + { + var customFilter = _customFilterService.Get(request.CustomFilterId.Value); + filters = DeserializeCustomFilters(customFilter); + } + + return items.Where((item) => filters.All((filter) => MatchesFilter(item, filter))).ToList(); + } + + private IEnumerable GetBuiltInFilters(string filterKey) + { + switch ((filterKey ?? DefaultFilterKey).ToLowerInvariant()) + { + case "monitored": + return new[] { new MovieQueryFilter("monitored", JToken.FromObject(true), "equal") }; + case "unmonitored": + return new[] { new MovieQueryFilter("monitored", JToken.FromObject(false), "equal") }; + case "missing": + return new[] + { + new MovieQueryFilter("monitored", JToken.FromObject(true), "equal"), + new MovieQueryFilter("hasFile", JToken.FromObject(false), "equal") + }; + case "wanted": + return new[] + { + new MovieQueryFilter("monitored", JToken.FromObject(true), "equal"), + new MovieQueryFilter("hasFile", JToken.FromObject(false), "equal"), + new MovieQueryFilter("isAvailable", JToken.FromObject(true), "equal") + }; + case "cutoffunmet": + return new[] + { + new MovieQueryFilter("monitored", JToken.FromObject(true), "equal"), + new MovieQueryFilter("hasFile", JToken.FromObject(true), "equal"), + new MovieQueryFilter("qualityCutoffNotMet", JToken.FromObject(true), "equal") + }; + default: + return Array.Empty(); + } + } + + private IEnumerable DeserializeCustomFilters(CustomFilter customFilter) + { + if (customFilter?.Filters.IsNullOrWhiteSpace() != false) + { + return Array.Empty(); + } + + return JArray.Parse(customFilter.Filters) + .OfType() + .Select((token) => new MovieQueryFilter( + token.Value("key"), + token["value"], + token.Value("type") ?? "equal")) + .ToList(); + } + + private bool MatchesFilter(MovieQueryItem item, MovieQueryFilter filter) + { + var type = (filter.Type ?? "equal").ToLowerInvariant(); + + switch (filter.Key) + { + case "monitored": + return MatchScalar(item.Movie.Monitored, filter.Value, type); + case "isAvailable": + return MatchScalar(item.IsAvailable, filter.Value, type); + case "hasFile": + return MatchScalar(item.HasFile, filter.Value, type); + case "minimumAvailability": + return MatchScalar(ToAvailabilityValue(item.Movie.MinimumAvailability), filter.Value, type); + case "title": + return MatchString(item.Title, filter.Value, type); + case "originalTitle": + return MatchString(item.Movie.MovieMetadata.Value.OriginalTitle, filter.Value, type); + case "originalLanguage": + return MatchString(item.Movie.MovieMetadata.Value.OriginalLanguage?.Name, filter.Value, type); + case "releaseGroups": + return MatchArray(item.Statistics.ReleaseGroups, filter.Value, type); + case "status": + return MatchScalar(ToStatusValue(item.Movie.MovieMetadata.Value.Status), filter.Value, type); + case "studio": + return MatchString(item.Movie.MovieMetadata.Value.Studio, filter.Value, type); + case "collection": + return MatchString(item.Movie.MovieMetadata.Value.CollectionTitle, filter.Value, type); + case "qualityProfileId": + return MatchScalar(item.Movie.QualityProfileId, filter.Value, type); + case "added": + return MatchDate(item.Movie.Added, filter.Value, type); + case "year": + return MatchScalar(item.Movie.Year, filter.Value, type); + case "inCinemas": + return MatchDate(item.Movie.MovieMetadata.Value.InCinemas, filter.Value, type); + case "physicalRelease": + return MatchDate(item.Movie.MovieMetadata.Value.PhysicalRelease, filter.Value, type); + case "digitalRelease": + return MatchDate(item.Movie.MovieMetadata.Value.DigitalRelease, filter.Value, type); + case "releaseDate": + return MatchDate(item.Movie.GetReleaseDate(), filter.Value, type); + case "runtime": + return MatchScalar(item.Movie.MovieMetadata.Value.Runtime, filter.Value, type); + case "path": + return MatchString(item.Movie.Path, filter.Value, type); + case "sizeOnDisk": + return MatchScalar(item.Statistics.SizeOnDisk, filter.Value, type); + case "genres": + return MatchArray(item.Movie.MovieMetadata.Value.Genres, filter.Value, type); + case "keywords": + return MatchArray(item.Movie.MovieMetadata.Value.Keywords, filter.Value, type); + case "tmdbRating": + return MatchScalar(item.Movie.MovieMetadata.Value.Ratings?.Tmdb?.Value * 10 ?? 0, filter.Value, type); + case "tmdbVotes": + return MatchScalar(item.Movie.MovieMetadata.Value.Ratings?.Tmdb?.Votes ?? 0, filter.Value, type); + case "imdbRating": + return MatchScalar(item.Movie.MovieMetadata.Value.Ratings?.Imdb?.Value ?? 0, filter.Value, type); + case "imdbVotes": + return MatchScalar(item.Movie.MovieMetadata.Value.Ratings?.Imdb?.Votes ?? 0, filter.Value, type); + case "rottenTomatoesRating": + return MatchScalar(item.Movie.MovieMetadata.Value.Ratings?.RottenTomatoes?.Value ?? 0, filter.Value, type); + case "traktRating": + return MatchScalar(item.Movie.MovieMetadata.Value.Ratings?.Trakt?.Value * 10 ?? 0, filter.Value, type); + case "traktVotes": + return MatchScalar(item.Movie.MovieMetadata.Value.Ratings?.Trakt?.Votes ?? 0, filter.Value, type); + case "popularity": + return MatchScalar(item.Movie.MovieMetadata.Value.Popularity, filter.Value, type); + case "certification": + return MatchString(item.Movie.MovieMetadata.Value.Certification, filter.Value, type); + case "tags": + return MatchArray(item.Movie.Tags.Select((tag) => tag.ToString(CultureInfo.InvariantCulture)).ToList(), filter.Value, type); + case "qualityCutoffNotMet": + return MatchScalar( + item.Movie.MovieFile != null && + _qualityUpgradableSpecification.QualityCutoffNotMet( + item.Movie.QualityProfile, + item.Movie.MovieFile.Quality), + filter.Value, + type); + default: + return true; + } + } + + private List ApplySort( + List items, + string sortKey, + NzbDrone.Core.Datastore.SortDirection sortDirection) + { + IOrderedEnumerable ordered; + var descending = sortDirection == NzbDrone.Core.Datastore.SortDirection.Descending; + + switch ((sortKey ?? DefaultSortKey).ToLowerInvariant()) + { + case "status": + ordered = OrderBy(items, (item) => GetStatusSortValue(item), descending); + break; + case "sorttitle": + ordered = OrderBy(items, (item) => item.SortTitle, descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "studio": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Studio ?? string.Empty, descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "qualityprofileid": + ordered = OrderBy(items, (item) => item.Movie.QualityProfileId, descending); + break; + case "added": + ordered = OrderBy(items, (item) => item.Movie.Added, descending); + break; + case "year": + ordered = OrderBy(items, (item) => item.Movie.Year, descending); + break; + case "incinemas": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.InCinemas ?? DateTime.MaxValue, descending); + break; + case "digitalrelease": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.DigitalRelease ?? DateTime.MaxValue, descending); + break; + case "physicalrelease": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.PhysicalRelease ?? DateTime.MaxValue, descending); + break; + case "releasedate": + ordered = OrderBy(items, (item) => item.Movie.GetReleaseDate() ?? DateTime.MaxValue, descending); + break; + case "tmdbrating": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Ratings?.Tmdb?.Value ?? 0, descending); + break; + case "imdbrating": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Ratings?.Imdb?.Value ?? 0, descending); + break; + case "rottentomatoesrating": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Ratings?.RottenTomatoes?.Value ?? -1, descending); + break; + case "traktrating": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Ratings?.Trakt?.Value ?? 0, descending); + break; + case "popularity": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Popularity, descending); + break; + case "path": + ordered = OrderBy(items, (item) => item.Movie.Path ?? string.Empty, descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "sizeondisk": + ordered = OrderBy(items, (item) => item.Statistics.SizeOnDisk, descending); + break; + case "certification": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Certification ?? string.Empty, descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "originaltitle": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.OriginalTitle ?? string.Empty, descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "originallanguage": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.OriginalLanguage?.Name ?? string.Empty, descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "tags": + ordered = OrderBy(items, (item) => string.Join("|", item.Movie.Tags.OrderBy((tag) => tag)), descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "collection": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.CollectionTitle ?? string.Empty, descending, StringComparer.InvariantCultureIgnoreCase); + break; + case "runtime": + ordered = OrderBy(items, (item) => item.Movie.MovieMetadata.Value.Runtime, descending); + break; + case "minimumavailability": + ordered = OrderBy(items, (item) => item.Movie.MinimumAvailability, descending); + break; + case "releasestatus": + case "moviestatus": + ordered = OrderBy(items, (item) => GetMovieStatusSortValue(item), descending); + break; + case "releasegroups": + ordered = OrderBy(items, (item) => string.Join("|", item.Statistics.ReleaseGroups), descending, StringComparer.InvariantCultureIgnoreCase); + break; + default: + ordered = OrderBy(items, (item) => item.SortTitle, descending, StringComparer.InvariantCultureIgnoreCase); + break; + } + + return ordered + .ThenBy((item) => item.SortTitle, StringComparer.InvariantCultureIgnoreCase) + .ToList(); + } + + private static IOrderedEnumerable OrderBy( + IEnumerable items, + Func selector, + bool descending) + { + return descending ? items.OrderByDescending(selector) : items.OrderBy(selector); + } + + private static IOrderedEnumerable OrderBy( + IEnumerable items, + Func selector, + bool descending, + IComparer comparer) + { + return descending ? items.OrderByDescending(selector, comparer) : items.OrderBy(selector, comparer); + } + + private static int GetStatusSortValue(MovieQueryItem item) + { + var value = 0; + + if (item.Movie.Monitored) + { + value += 4; + } + + switch (item.Movie.MovieMetadata.Value.Status) + { + case MovieStatusType.Announced: + value += 1; + break; + case MovieStatusType.InCinemas: + value += 2; + break; + case MovieStatusType.Released: + value += 3; + break; + } + + return value; + } + + private string GetMovieStatusSortValue(MovieQueryItem item) + { + var value = 0; + var qualityName = string.Empty; + + if (item.IsAvailable) + { + value++; + } + + if (item.Movie.Monitored) + { + value += 2; + } + + if (item.Movie.MovieFile != null) + { + value += _qualityUpgradableSpecification.QualityCutoffNotMet( + item.Movie.QualityProfile, + item.Movie.MovieFile.Quality) ? 4 : 8; + qualityName = item.Movie.MovieFile.Quality?.Quality?.Name ?? string.Empty; + } + + return value.ToString("D2", CultureInfo.InvariantCulture) + qualityName; + } + + private static bool MatchString(string itemValue, JToken filterValue, string type) + { + var value = filterValue?.ToString() ?? string.Empty; + itemValue ??= string.Empty; + + switch (type) + { + case "contains": + return itemValue.ContainsIgnoreCase(value); + case "notcontains": + return !itemValue.ContainsIgnoreCase(value); + case "startswith": + return itemValue.StartsWith(value, StringComparison.InvariantCultureIgnoreCase); + case "notstartswith": + return !itemValue.StartsWith(value, StringComparison.InvariantCultureIgnoreCase); + case "endswith": + return itemValue.EndsWith(value, StringComparison.InvariantCultureIgnoreCase); + case "notendswith": + return !itemValue.EndsWith(value, StringComparison.InvariantCultureIgnoreCase); + case "equal": + return string.Equals(itemValue, value, StringComparison.InvariantCultureIgnoreCase); + case "notequal": + return !string.Equals(itemValue, value, StringComparison.InvariantCultureIgnoreCase); + default: + return false; + } + } + + private static bool MatchArray(IEnumerable itemValues, JToken filterValue, string type) + { + var values = ToStringValues(filterValue); + var itemList = itemValues?.ToList() ?? new List(); + + if (!values.Any()) + { + return false; + } + + if (type == "notcontains" || type == "notequal") + { + return values.All((value) => itemList.All((item) => !string.Equals(item, value, StringComparison.InvariantCultureIgnoreCase))); + } + + return values.Any((value) => itemList.Any((item) => string.Equals(item, value, StringComparison.InvariantCultureIgnoreCase))); + } + + private static bool MatchScalar(T itemValue, JToken filterValue, string type) + where T : IComparable + { + if (filterValue == null) + { + return false; + } + + var values = filterValue is JArray + ? filterValue.Values().Cast().ToList() + : new List { filterValue.ToObject() }; + + if (!values.Any()) + { + return false; + } + + if (type == "notcontains" || type == "notequal") + { + return values.All((value) => itemValue.CompareTo((T)value) != 0); + } + + if (type == "contains" || type == "equal") + { + return values.Any((value) => itemValue.CompareTo((T)value) == 0); + } + + var first = (T)values[0]; + + switch (type) + { + case "greaterthan": + return itemValue.CompareTo(first) > 0; + case "greaterthanorequal": + return itemValue.CompareTo(first) >= 0; + case "lessthan": + return itemValue.CompareTo(first) < 0; + case "lessthanorequal": + return itemValue.CompareTo(first) <= 0; + default: + return false; + } + } + + private static bool MatchDate(DateTime? itemValue, JToken filterValue, string type) + { + if (!itemValue.HasValue) + { + return false; + } + + if (type == "lessthan" || type == "greaterthan") + { + var filterDate = filterValue?.ToObject()?.Date; + + if (!filterDate.HasValue) + { + return false; + } + + return type == "lessthan" + ? itemValue.Value.Date < filterDate.Value + : itemValue.Value.Date > filterDate.Value; + } + + if (filterValue is not JObject rangeToken) + { + return false; + } + + var unit = rangeToken.Value("time"); + var value = rangeToken.Value("value") ?? 0; + var now = DateTime.UtcNow; + var target = unit switch + { + "days" => now.AddDays(value), + "months" => now.AddMonths(value), + "years" => now.AddYears(value), + _ => now + }; + + return type switch + { + "inlast" => itemValue.Value >= target && itemValue.Value <= now, + "notinlast" => itemValue.Value < target, + "innext" => itemValue.Value >= now && itemValue.Value <= target, + "notinnext" => itemValue.Value > target, + _ => false + }; + } + + private static List ToStringValues(JToken token) + { + if (token is JArray array) + { + return array.Select((value) => value.ToString()).ToList(); + } + + if (token == null) + { + return new List(); + } + + return new List { token.ToString() }; + } + + private static string ToStatusValue(MovieStatusType status) + { + return status switch + { + MovieStatusType.TBA => "tba", + MovieStatusType.Announced => "announced", + MovieStatusType.InCinemas => "inCinemas", + MovieStatusType.Released => "released", + MovieStatusType.Deleted => "deleted", + _ => status.ToString() + }; + } + + private static string ToAvailabilityValue(MovieStatusType availability) + { + return availability switch + { + MovieStatusType.TBA => "tba", + MovieStatusType.Announced => "announced", + MovieStatusType.InCinemas => "inCinemas", + MovieStatusType.Released => "released", + _ => availability.ToString() + }; + } + + private static bool TryParseTitleSlug(string titleSlug, out int tmdbId) + { + return int.TryParse(titleSlug, NumberStyles.Integer, CultureInfo.InvariantCulture, out tmdbId); + } + + private static MovieSearchResultResource BuildSearchResult( + MovieQueryItem item, + Dictionary tagLabels, + string query) + { + if (item.Title.ContainsIgnoreCase(query)) + { + return item.ToSearchResult("title", 0, tagLabels); + } + + if ((item.Movie.MovieMetadata.Value.OriginalTitle ?? string.Empty).ContainsIgnoreCase(query)) + { + return item.ToSearchResult("originalTitle", 0, tagLabels); + } + + for (var i = 0; i < item.Movie.MovieMetadata.Value.AlternativeTitles.Count; i++) + { + if (item.Movie.MovieMetadata.Value.AlternativeTitles[i].Title.ContainsIgnoreCase(query)) + { + return item.ToSearchResult("alternateTitles.title", i, tagLabels); + } + } + + if (item.Movie.TmdbId.ToString(CultureInfo.InvariantCulture).Contains(query, StringComparison.InvariantCultureIgnoreCase)) + { + return item.ToSearchResult("tmdbId", 0, tagLabels); + } + + if ((item.Movie.ImdbId ?? string.Empty).ContainsIgnoreCase(query)) + { + return item.ToSearchResult("imdbId", 0, tagLabels); + } + + var tagIds = item.Movie.Tags.OrderBy((tagId) => tagId).ToList(); + + for (var i = 0; i < tagIds.Count; i++) + { + var tagId = tagIds[i]; + + if (tagLabels.TryGetValue(tagId, out var tagLabel) && tagLabel.ContainsIgnoreCase(query)) + { + return item.ToSearchResult("tags.label", i, tagLabels); + } + } + + return null; + } + + private sealed class MovieQueryContext + { + public List RootFolders { get; init; } + public List Items { get; init; } + public Dictionary TagLabels { get; init; } + } + + private sealed class MovieQueryFilter + { + public MovieQueryFilter(string key, JToken value, string type) + { + Key = key; + Value = value; + Type = type; + } + + public string Key { get; } + public JToken Value { get; } + public string Type { get; } + } + + private sealed class MovieQueryItem + { + public MovieQueryItem(Movie movie, MovieTranslation translation, MovieStatistics statistics, int availabilityDelay) + { + Movie = movie; + Translation = translation; + Statistics = statistics; + Title = translation?.Title ?? movie.Title; + SortTitle = MovieTitleNormalizer.Normalize(Title, movie.TmdbId); + TitleSlug = movie.TmdbId.ToString(CultureInfo.InvariantCulture); + IsAvailable = movie.IsAvailable(availabilityDelay); + HasFile = statistics.MovieFileCount > 0; + } + + public Movie Movie { get; } + public MovieTranslation Translation { get; } + public MovieStatistics Statistics { get; } + public string Title { get; } + public string SortTitle { get; } + public string TitleSlug { get; } + public bool IsAvailable { get; } + public bool HasFile { get; } + + public MovieSearchResultResource ToSearchResult( + string matchedKey, + int matchedIndex, + Dictionary tagLabels) + { + return new MovieSearchResultResource + { + Title = Title, + Year = Movie.Year, + TitleSlug = TitleSlug, + SortTitle = SortTitle, + Images = Movie.MovieMetadata.Value.Images.JsonClone(), + AlternateTitles = Movie.MovieMetadata.Value.AlternativeTitles.ToResource(), + TmdbId = Movie.TmdbId, + ImdbId = Movie.ImdbId, + Tags = Movie.Tags + .OrderBy((tagId) => tagId) + .Select((tagId) => new TagLookupResource + { + Id = tagId, + Label = tagLabels.TryGetValue(tagId, out var label) + ? label + : tagId.ToString(CultureInfo.InvariantCulture) + }) + .ToList(), + MatchedKey = matchedKey, + MatchedIndex = matchedIndex + }; + } + } + } +} From 55342bfef989e30b591500631dd27b362cac54e5 Mon Sep 17 00:00:00 2001 From: Eder Date: Sat, 4 Jul 2026 11:48:06 -0400 Subject: [PATCH 2/3] Issue 32: fix frontend CI failures --- .../src/Movie/Index/MovieIndexSearchButton.tsx | 5 +++-- .../Select/Delete/DeleteMovieModalContent.tsx | 2 +- .../Organize/OrganizeMoviesModalContent.tsx | 2 +- .../Index/Select/Tags/TagsModalContent.tsx | 2 +- .../Tags/Details/TagDetailsModalContent.tsx | 2 +- .../src/Store/Actions/movieIndexActions.js | 18 +++++++++--------- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/frontend/src/Movie/Index/MovieIndexSearchButton.tsx b/frontend/src/Movie/Index/MovieIndexSearchButton.tsx index ff2cf62c42e..89105f7c9c1 100644 --- a/frontend/src/Movie/Index/MovieIndexSearchButton.tsx +++ b/frontend/src/Movie/Index/MovieIndexSearchButton.tsx @@ -20,7 +20,8 @@ interface MovieIndexSearchButtonProps { function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { const isSearching = useSelector(createCommandExecutingSelector(MOVIE_SEARCH)); - const { totalRecords } = useSelector((state: AppState) => state.movieIndex); + const totalRecords = + useSelector((state: AppState) => state.movieIndex.totalRecords) ?? 0; const dispatch = useDispatch(); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); @@ -90,7 +91,7 @@ function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { 5 ? onConfirmPress : onPress} /> diff --git a/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx index 13daba40298..af352e7f5f1 100644 --- a/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx +++ b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx @@ -6,12 +6,12 @@ import AppState from 'App/State/AppState'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; -import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import Button from 'Components/Link/Button'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import { inputTypes, kinds } from 'Helpers/Props'; import Movie from 'Movie/Movie'; import { bulkDeleteMovie, setDeleteOption } from 'Store/Actions/movieActions'; diff --git a/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx index 697a1a96fd4..ff43af2e0cb 100644 --- a/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx +++ b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx @@ -4,12 +4,12 @@ import { useDispatch, useSelector } from 'react-redux'; import { RENAME_MOVIE } from 'Commands/commandNames'; import Alert from 'Components/Alert'; import Icon from 'Components/Icon'; -import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import Button from 'Components/Link/Button'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import { icons, kinds } from 'Helpers/Props'; import Movie from 'Movie/Movie'; import { executeCommand } from 'Store/Actions/commandActions'; diff --git a/frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx index 2fa98fd1e07..2c61d217534 100644 --- a/frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx +++ b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx @@ -6,13 +6,13 @@ import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; -import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import Label from 'Components/Label'; import Button from 'Components/Link/Button'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; import Movie from 'Movie/Movie'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx index 0d0c630cfcd..a664cd02d06 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx @@ -4,13 +4,13 @@ import { createSelector } from 'reselect'; import ModelBase from 'App/ModelBase'; import AppState from 'App/State/AppState'; import FieldSet from 'Components/FieldSet'; -import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import Label from 'Components/Label'; import Button from 'Components/Link/Button'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import useEnsureMoviesLoaded from 'Helpers/Hooks/useEnsureMoviesLoaded'; import { kinds } from 'Helpers/Props'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import translate from 'Utilities/String/translate'; diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js index 9c983093ca0..a74c3526f04 100644 --- a/frontend/src/Store/Actions/movieIndexActions.js +++ b/frontend/src/Store/Actions/movieIndexActions.js @@ -2,9 +2,9 @@ import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; -import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; import sortByProp from 'Utilities/Array/sortByProp'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import translate from 'Utilities/String/translate'; import createHandleActions from './Creators/createHandleActions'; import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; @@ -680,7 +680,7 @@ handleThunks({ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MOVIE_INDEX_PAGE, [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MOVIE_INDEX_PAGE, [serverSideCollectionHandlers.SORT]: SET_MOVIE_SORT, - [serverSideCollectionHandlers.FILTER]: SET_MOVIE_FILTER, + [serverSideCollectionHandlers.FILTER]: SET_MOVIE_FILTER }, augmentFetchData ), @@ -693,7 +693,7 @@ handleThunks({ page: payload.page || movieIndex.page || 1, pageSize: movieIndex.pageSize, sortKey: movieIndex.sortKey, - sortDirection: movieIndex.sortDirection, + sortDirection: movieIndex.sortDirection }; augmentFetchData(getState, payload, data); @@ -701,7 +701,7 @@ handleThunks({ const promise = createAjaxRequest({ url: '/movie/paged', data, - traditional: true, + traditional: true }).request; promise.done((response) => { @@ -714,7 +714,7 @@ handleThunks({ section, isFetching: false, isPopulated: true, - error: null, + error: null }) ])); }); @@ -724,7 +724,7 @@ handleThunks({ section, isFetching: false, isPopulated: false, - error: xhr, + error: xhr })); }); @@ -735,7 +735,7 @@ handleThunks({ const movieIndex = getState().movieIndex; const data = { sortKey: payload?.sortKey ?? movieIndex.sortKey, - sortDirection: payload?.sortDirection ?? movieIndex.sortDirection, + sortDirection: payload?.sortDirection ?? movieIndex.sortDirection }; augmentFetchData(getState, payload, data); @@ -743,7 +743,7 @@ handleThunks({ return createAjaxRequest({ url: '/movie/ids', data, - traditional: true, + traditional: true }).request; }, }); From 5a2d8d4665794606643fc6edb2e7e65bfd5fb7ec Mon Sep 17 00:00:00 2001 From: Eder Date: Sat, 4 Jul 2026 12:11:11 -0400 Subject: [PATCH 3/3] Issue 32: fix remaining frontend CI errors --- frontend/src/App/State/MoviesAppState.ts | 2 +- .../src/Collection/CollectionConnector.js | 2 +- .../Page/Header/MovieSearchInput.tsx | 12 +++++-- .../src/Components/Page/Header/fuse.worker.ts | 2 +- frontend/src/Movie/Details/MovieDetails.tsx | 12 ++++--- .../src/Movie/Details/MovieDetailsPage.tsx | 18 +++++++--- frontend/src/Movie/Index/MovieIndex.tsx | 12 +++---- frontend/src/Movie/Index/MovieIndexFooter.tsx | 33 +++++++++---------- .../Index/MovieIndexRefreshMovieButton.tsx | 14 +++++--- .../Movie/Index/MovieIndexSearchButton.tsx | 14 +++++--- .../src/Store/Actions/movieIndexActions.js | 6 ++-- frontend/src/Store/thunks.ts | 14 ++++++++ 12 files changed, 90 insertions(+), 51 deletions(-) diff --git a/frontend/src/App/State/MoviesAppState.ts b/frontend/src/App/State/MoviesAppState.ts index 9a1677ce9b0..e12fd0e21a6 100644 --- a/frontend/src/App/State/MoviesAppState.ts +++ b/frontend/src/App/State/MoviesAppState.ts @@ -1,7 +1,7 @@ import AppSectionState, { AppSectionDeleteState, - PagedAppSectionState, AppSectionSaveState, + PagedAppSectionState, } from 'App/State/AppSectionState'; import Column from 'Components/Table/Column'; import { SortDirection } from 'Helpers/Props/sortDirections'; diff --git a/frontend/src/Collection/CollectionConnector.js b/frontend/src/Collection/CollectionConnector.js index ace2965b128..1ab45b49e98 100644 --- a/frontend/src/Collection/CollectionConnector.js +++ b/frontend/src/Collection/CollectionConnector.js @@ -5,13 +5,13 @@ import { createSelector } from 'reselect'; import * as commandNames from 'Commands/commandNames'; import withScrollPosition from 'Components/withScrollPosition'; import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchMovies } from 'Store/Actions/movieActions'; import { fetchMovieCollections, saveMovieCollections, setMovieCollectionsFilter, setMovieCollectionsSort } from 'Store/Actions/movieCollectionActions'; -import { fetchMovies } from 'Store/Actions/movieActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; import scrollPositions from 'Store/scrollPositions'; import createCollectionClientSideCollectionItemsSelector from 'Store/Selectors/createCollectionClientSideCollectionItemsSelector'; diff --git a/frontend/src/Components/Page/Header/MovieSearchInput.tsx b/frontend/src/Components/Page/Header/MovieSearchInput.tsx index 84ead4a95d8..859e77531f1 100644 --- a/frontend/src/Components/Page/Header/MovieSearchInput.tsx +++ b/frontend/src/Components/Page/Header/MovieSearchInput.tsx @@ -18,6 +18,7 @@ import Icon from 'Components/Icon'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts'; import { icons } from 'Helpers/Props'; +import { Image } from 'Movie/Movie'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import translate from 'Utilities/String/translate'; import MovieSearchResult from './MovieSearchResult'; @@ -40,13 +41,18 @@ export interface SuggestedMovie { year: number; titleSlug: string; sortTitle: string; - images: Array; + images: Image[]; alternateTitles: Array<{ title: string }>; tmdbId: number; imdbId: string; tags: Tag[]; } +interface MovieSearchResponseItem extends SuggestedMovie { + matchedKey: string; + matchedIndex: number; +} + interface MovieSuggestion { title: string; indices: number[]; @@ -123,12 +129,12 @@ function MovieSearchInput() { abortRequestRef.current = abortRequest; - request.done((data) => { + request.done((data: MovieSearchResponseItem[]) => { if (latestQueryRef.current !== trimmed) { return; } - const nextSuggestions = data.map((item) => { + const nextSuggestions: MovieSuggestion[] = data.map((item) => { return { title: item.title, indices: [], diff --git a/frontend/src/Components/Page/Header/fuse.worker.ts b/frontend/src/Components/Page/Header/fuse.worker.ts index 065659ca077..ab78de20f9b 100644 --- a/frontend/src/Components/Page/Header/fuse.worker.ts +++ b/frontend/src/Components/Page/Header/fuse.worker.ts @@ -17,7 +17,7 @@ function getSuggestions(movies: SuggestedMovie[], value: string) { if (value.length === 1) { for (let i = 0; i < movies.length; i++) { const m = movies[i]; - if (m.firstCharacter === value.toLowerCase()) { + if (m.sortTitle.charAt(0).toLowerCase() === value.toLowerCase()) { suggestions.push({ item: movies[i], indices: [[0, 0]], diff --git a/frontend/src/Movie/Details/MovieDetails.tsx b/frontend/src/Movie/Details/MovieDetails.tsx index eeb36d68102..cea5be5b356 100644 --- a/frontend/src/Movie/Details/MovieDetails.tsx +++ b/frontend/src/Movie/Details/MovieDetails.tsx @@ -100,6 +100,11 @@ import MovieTags from './MovieTags'; import MovieTitlesTable from './Titles/MovieTitlesTable'; import styles from './MovieDetails.css'; +interface MovieNavigation { + nextMovie?: { title: string; titleSlug: string }; + previousMovie?: { title: string; titleSlug: string }; +} + const defaultFontSize = parseInt(fonts.defaultFontSize); const lineHeight = parseFloat(fonts.lineHeight); @@ -223,10 +228,7 @@ function MovieDetails({ movieId }: MovieDetailsProps) { }; }, [movieId, commands]); - const [navigation, setNavigation] = useState<{ - nextMovie?: { title: string; titleSlug: string }; - previousMovie?: { title: string; titleSlug: string }; - }>({}); + const [navigation, setNavigation] = useState({}); const touchStart = useRef(null); const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false); @@ -253,7 +255,7 @@ function MovieDetails({ movieId }: MovieDetailsProps) { traditional: true, }); - request.done((data) => { + request.done((data: MovieNavigation) => { setNavigation(data); }); diff --git a/frontend/src/Movie/Details/MovieDetailsPage.tsx b/frontend/src/Movie/Details/MovieDetailsPage.tsx index eb148380b05..54e3a419e81 100644 --- a/frontend/src/Movie/Details/MovieDetailsPage.tsx +++ b/frontend/src/Movie/Details/MovieDetailsPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector, useStore } from 'react-redux'; import { useParams } from 'react-router'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; @@ -7,9 +7,14 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import NotFound from 'Components/NotFound'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { fetchMovieByTitleSlug } from 'Store/Actions/movieActions'; +import { executeThunk } from 'Store/thunks'; import translate from 'Utilities/String/translate'; import MovieDetails from './MovieDetails'; +interface RequestFailure { + status?: number; +} + function createMovieIdSelector(titleSlug: string) { return createSelector( (state: AppState) => state.movies.items, @@ -21,6 +26,7 @@ function createMovieIdSelector(titleSlug: string) { function MovieDetailsPage() { const dispatch = useDispatch(); + const store = useStore(); const { titleSlug } = useParams<{ titleSlug: string }>(); const movieId = useSelector(createMovieIdSelector(titleSlug)); const previousMovieId = usePrevious(movieId); @@ -37,18 +43,22 @@ function MovieDetailsPage() { return; } - const request = dispatch(fetchMovieByTitleSlug({ titleSlug })); + const request = executeThunk( + fetchMovieByTitleSlug({ titleSlug }), + dispatch, + store.getState + ); request.done(() => { setIsLoading(false); setIsNotFound(false); }); - request.fail((xhr) => { + request.fail((xhr: RequestFailure) => { setIsLoading(false); setIsNotFound(xhr.status === 404); }); - }, [dispatch, movieId, titleSlug]); + }, [dispatch, movieId, store, titleSlug]); if (isLoading && movieId == null) { return ; diff --git a/frontend/src/Movie/Index/MovieIndex.tsx b/frontend/src/Movie/Index/MovieIndex.tsx index 122c670b6ac..8cf2c092188 100644 --- a/frontend/src/Movie/Index/MovieIndex.tsx +++ b/frontend/src/Movie/Index/MovieIndex.tsx @@ -22,8 +22,8 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; import usePaging from 'Components/Table/usePaging'; -import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import withScrollPosition from 'Components/withScrollPosition'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import { align, icons, kinds } from 'Helpers/Props'; import { DESCENDING } from 'Helpers/Props/sortDirections'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; @@ -38,10 +38,10 @@ import { setMovieView, } from 'Store/Actions/movieIndexActions'; import { fetchQueueDetails } from 'Store/Actions/queueActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import scrollPositions from 'Store/scrollPositions'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import scrollPositions from 'Store/scrollPositions'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import { registerPagePopulator, @@ -101,9 +101,7 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { totalPages, totalRecords, filters, - }: MovieIndexAppState = useSelector( - (state: AppState) => state.movieIndex - ); + }: MovieIndexAppState = useSelector((state: AppState) => state.movieIndex); const customFilters = useSelector(createCustomFiltersSelector('movieIndex')); const isRssSyncExecuting = useSelector( @@ -434,7 +432,7 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { {!error && isPopulated && !items.length ? ( ) : null} diff --git a/frontend/src/Movie/Index/MovieIndexFooter.tsx b/frontend/src/Movie/Index/MovieIndexFooter.tsx index be11bbeee19..dbc2dd1e19d 100644 --- a/frontend/src/Movie/Index/MovieIndexFooter.tsx +++ b/frontend/src/Movie/Index/MovieIndexFooter.tsx @@ -1,40 +1,37 @@ import classNames from 'classnames'; import React from 'react'; import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; +import AppState from 'App/State/AppState'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Movie from 'Movie/Movie'; import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './MovieIndexFooter.css'; -function createUnoptimizedSelector() { - return createSelector( - (state) => state.movieIndex.items, - (movies) => { - return movies.map((m) => { - const { monitored, status, hasFile, statistics } = m; +interface MovieIndexFooterMovie { + hasFile: boolean; + monitored: boolean; + statistics?: Movie['statistics']; +} +function createMovieSelector() { + return createDeepEqualSelector( + (state: AppState) => state.movieIndex.items, + (movies: Movie[]): MovieIndexFooterMovie[] => { + return movies.map((movie) => { return { - monitored, - status, - hasFile, - statistics, + hasFile: movie.hasFile, + monitored: movie.monitored, + statistics: movie.statistics, }; }); } ); } -function createMovieSelector() { - return createDeepEqualSelector( - createUnoptimizedSelector(), - (movies) => movies - ); -} - export default function MovieIndexFooter() { const movies = useSelector(createMovieSelector()); const count = movies.length; diff --git a/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx b/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx index 0793d311cbe..e66f8aae1f8 100644 --- a/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx +++ b/frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector, useStore } from 'react-redux'; import { useSelect } from 'App/SelectContext'; import AppState from 'App/State/AppState'; import { REFRESH_MOVIE } from 'Commands/commandNames'; @@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props'; import { executeCommand } from 'Store/Actions/commandActions'; import { fetchMovieIndexIds } from 'Store/Actions/movieIndexActions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeThunk } from 'Store/thunks'; import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; @@ -25,6 +26,7 @@ function MovieIndexRefreshMovieButton( const { totalRecords } = useSelector((state: AppState) => state.movieIndex); const dispatch = useDispatch(); + const store = useStore(); const { isSelectMode, selectedFilterKey } = props; const [selectState] = useSelect(); const { selectedState } = selectState; @@ -47,7 +49,11 @@ function MovieIndexRefreshMovieButton( const request = isSelectMode && selectedMovieIds.length ? null - : dispatch(fetchMovieIndexIds()); + : executeThunk( + fetchMovieIndexIds(), + dispatch, + store.getState + ); if (!request) { dispatch( @@ -60,7 +66,7 @@ function MovieIndexRefreshMovieButton( return; } - request.done((movieIds) => { + request.done((movieIds: number[]) => { dispatch( executeCommand({ name: REFRESH_MOVIE, @@ -68,7 +74,7 @@ function MovieIndexRefreshMovieButton( }) ); }); - }, [dispatch, isSelectMode, selectedMovieIds]); + }, [dispatch, isSelectMode, selectedMovieIds, store]); return ( state.movieIndex.totalRecords) ?? 0; const dispatch = useDispatch(); + const store = useStore(); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const { isSelectMode, selectedFilterKey } = props; @@ -55,7 +57,11 @@ function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { const request = isSelectMode && selectedMovieIds.length > 0 ? null - : dispatch(fetchMovieIndexIds()); + : executeThunk( + fetchMovieIndexIds(), + dispatch, + store.getState + ); if (!request) { dispatch( @@ -68,7 +74,7 @@ function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { return; } - request.done((movieIds) => { + request.done((movieIds: number[]) => { dispatch( executeCommand({ name: MOVIE_SEARCH, @@ -76,7 +82,7 @@ function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { }) ); }); - }, [dispatch, isSelectMode, selectedMovieIds]); + }, [dispatch, isSelectMode, selectedMovieIds, store]); const onConfirmPress = useCallback(() => { setIsConfirmModalOpen(true); diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js index a74c3526f04..7cc3ed33c35 100644 --- a/frontend/src/Store/Actions/movieIndexActions.js +++ b/frontend/src/Store/Actions/movieIndexActions.js @@ -6,6 +6,7 @@ import sortByProp from 'Utilities/Array/sortByProp'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import translate from 'Utilities/String/translate'; +import { set, update, updateServerSideCollection } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; @@ -13,9 +14,8 @@ import { filterPredicates, filters, mergeMovies, - sortPredicates, + sortPredicates } from './movieActions'; -import { set, update, updateServerSideCollection } from './baseActions'; // // Variables @@ -745,7 +745,7 @@ handleThunks({ data, traditional: true }).request; - }, + } }); export const reducers = createHandleActions({ diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts index fd277211e07..accc64053c8 100644 --- a/frontend/src/Store/thunks.ts +++ b/frontend/src/Store/thunks.ts @@ -2,12 +2,18 @@ import { Dispatch } from 'redux'; import AppState from 'App/State/AppState'; type GetState = () => AppState; +type ThunkAction = (dispatch: Dispatch, getState: GetState) => unknown; type Thunk = ( getState: GetState, identityFn: never, dispatch: Dispatch ) => unknown; +export interface ThunkRequest { + done(callback: (data: TResponse) => void): ThunkRequest; + fail(callback: (error: { status?: number }) => void): ThunkRequest; +} + const thunks: Record = {}; function identity(payload: T): TResult { @@ -30,6 +36,14 @@ export function createThunk(type: string, identityFunction = identity) { }; } +export function executeThunk( + thunk: ThunkAction, + dispatch: Dispatch, + getState: GetState +) { + return thunk(dispatch, getState) as ThunkRequest; +} + export function handleThunks(handlers: Record) { const types = Object.keys(handlers);