From a0a6a3d209ac3aef72ea2fd70f751086048c731c Mon Sep 17 00:00:00 2001
From: Mothball <158273459+Mothball7205@users.noreply.github.com>
Date: Sat, 20 Jun 2026 00:00:54 +0200
Subject: [PATCH 01/21] chore: migrate "add new studio" lookup and add flow to
tanstack query (#301)
---
frontend/src/Store/Actions/addMovieActions.js | 122 +---------
.../src/Studio/AddNewStudio/AddNewStudio.tsx | 5 +-
.../Studio/AddNewStudio/AddNewStudioModal.tsx | 3 +-
.../AddNewStudio/AddNewStudioModalContent.tsx | 7 +-
.../AddNewStudio/AddNewStudioSearchResult.tsx | 1 +
.../Studio/AddNewStudio/useAddNewStudio.ts | 222 +++++++-----------
.../Search/SearchController.cs | 22 +-
7 files changed, 118 insertions(+), 264 deletions(-)
diff --git a/frontend/src/Store/Actions/addMovieActions.js b/frontend/src/Store/Actions/addMovieActions.js
index 86ecf68351..f1184189a9 100644
--- a/frontend/src/Store/Actions/addMovieActions.js
+++ b/frontend/src/Store/Actions/addMovieActions.js
@@ -6,7 +6,6 @@ import createAjaxRequest from 'Utilities/createAjaxRequest';
import getNewPerformer from 'Utilities/Performer/getNewPerformer';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
-import getNewStudio from 'Utilities/Studio/getNewStudio';
import { set, update, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
@@ -28,8 +27,6 @@ export const defaultState = {
isAdded: false,
addError: null,
items: [],
- studiosWithStatus: [],
-
movieDefaults: {
rootFolderPath: '',
monitor: 'movieOnly',
@@ -65,31 +62,24 @@ export const persistState = [
//
// Actions Types
-export const LOOKUP_STUDIO = 'addMovie/lookupStudio';
export const LOOKUP_PERFORMER = 'addMovie/lookupPerformer';
export const ADD_PERFORMER = 'addMovie/addPerformer';
-export const ADD_STUDIO = 'addMovie/addStudio';
export const SET_ADD_MOVIE_VALUE = 'addMovie/setAddMovieValue';
export const SET_ADD_PERFORMER_VALUE = 'addMovie/setAddPerformerValue';
-export const SET_ADD_STUDIO_VALUE = 'addMovie/setAddStudioValue';
export const CLEAR_ADD_MOVIE = 'addMovie/clearAddMovie';
export const SET_ADD_MOVIE_DEFAULT = 'addMovie/setAddMovieDefault';
export const SET_ADD_PERFORMER_DEFAULT = 'addMovie/setAddPerformerDefault';
export const SET_ADD_STUDIO_DEFAULT = 'addMovie/setAddStudioDefault';
-export const SET_STUDIOS_WITH_STATUS = 'addMovie/setStudiosWithStatus';
//
// Action Creators
-export const lookupStudio = createThunk(LOOKUP_STUDIO);
export const lookupPerformer = createThunk(LOOKUP_PERFORMER);
export const addPerformer = createThunk(ADD_PERFORMER);
-export const addStudio = createThunk(ADD_STUDIO);
export const clearAddMovie = createAction(CLEAR_ADD_MOVIE);
export const setAddMovieDefault = createAction(SET_ADD_MOVIE_DEFAULT);
export const setAddPerformerDefault = createAction(SET_ADD_PERFORMER_DEFAULT);
export const setAddStudioDefault = createAction(SET_ADD_STUDIO_DEFAULT);
-export const setStudiosWithStatus = createAction(SET_STUDIOS_WITH_STATUS);
export const setAddMovieValue = createAction(SET_ADD_MOVIE_VALUE, (payload) => {
return {
@@ -106,67 +96,12 @@ export const setAddPerformerValue = createAction(
};
}
);
-export const setAddStudioValue = createAction(
- SET_ADD_STUDIO_VALUE,
- (payload) => {
- return {
- section,
- ...payload,
- };
- }
-);
+
//
// Action Handlers
export const actionHandlers = handleThunks({
- [LOOKUP_STUDIO]: function (getState, payload, dispatch) {
- dispatch(set({ section, isFetching: true }));
-
- if (abortCurrentRequest) {
- abortCurrentRequest();
- }
-
- const { request, abortRequest } = createAjaxRequest({
- url: '/lookup/studio',
- data: {
- term: payload.term,
- },
- });
-
- abortCurrentRequest = abortRequest;
-
- request.done((data) => {
- data = data.map((movie) => ({
- ...movie,
- internalId: movie.id,
- id: movie.foreignId,
- }));
- dispatch(
- batchActions([
- update({ section, data }),
-
- set({
- section,
- isFetching: false,
- isPopulated: true,
- error: null,
- }),
- ])
- );
- });
-
- request.fail((xhr) => {
- dispatch(
- set({
- section,
- isFetching: false,
- isPopulated: false,
- error: xhr.aborted ? null : xhr,
- })
- );
- });
- },
[LOOKUP_PERFORMER]: function (getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
@@ -270,55 +205,7 @@ export const actionHandlers = handleThunks({
});
},
- [ADD_STUDIO]: function (getState, payload, dispatch) {
- dispatch(set({ section, isAdding: true }));
-
- const foreignId = payload.foreignId;
- const items = getState().addMovie.items;
- const itemToAdd = _.find(items, { foreignId });
- const newStudio = getNewStudio(_.cloneDeep(itemToAdd.studio), payload);
- newStudio.id = 0;
-
- const promise = createAjaxRequest({
- url: '/studio',
- method: 'POST',
- dataType: 'json',
- contentType: 'application/json',
- data: JSON.stringify(newStudio),
- }).request;
-
- promise.done((data) => {
- const updatedItem = _.cloneDeep(data);
- updatedItem.internalId = updatedItem.id;
- updatedItem.id = updatedItem.foreignId;
- delete updatedItem.images;
-
- const actions = [
- updateItem({ section: 'studios', ...data }),
- updateItem({ section: 'addMovie', ...updatedItem }),
-
- set({
- section,
- isAdding: false,
- isAdded: true,
- addError: null,
- }),
- ];
- dispatch(batchActions(actions));
- });
-
- promise.fail((xhr) => {
- dispatch(
- set({
- section,
- isAdding: false,
- isAdded: false,
- addError: xhr,
- })
- );
- });
- },
});
//
@@ -328,7 +215,6 @@ export const reducers = createHandleActions(
{
[SET_ADD_MOVIE_VALUE]: createSetSettingValueReducer(section),
[SET_ADD_PERFORMER_VALUE]: createSetSettingValueReducer(section),
- [SET_ADD_STUDIO_VALUE]: createSetSettingValueReducer(section),
[SET_ADD_MOVIE_DEFAULT]: function (state, { payload }) {
const newState = getSectionState(state, section);
@@ -361,12 +247,6 @@ export const reducers = createHandleActions(
return updateSectionState(state, section, newState);
},
- [SET_STUDIOS_WITH_STATUS]: function (state, { payload }) {
- const newState = getSectionState(state, section);
- newState.studiosWithStatus = payload;
- return updateSectionState(state, section, newState);
- },
-
[CLEAR_ADD_MOVIE]: function (state) {
const {
movieDefaults,
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx
index b92d076b03..87a2edfe56 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudio.tsx
@@ -8,7 +8,6 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons, kinds } from 'Helpers/Props';
-import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewStudioSearchResult from './AddNewStudioSearchResult';
import useAddNewStudio from './useAddNewStudio';
@@ -84,7 +83,9 @@ function AddNewStudio(props: AddNewStudioProps) {
{translate('FailedLoadingSearchResults')}
- {getErrorMessage(error)}
+
+ {error?.statusBody?.message ?? error?.message ?? ''}
+
{translate('WhySearchesCouldBeFailing')}
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx
index 4a977ecd66..d8370bd463 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudioModal.tsx
@@ -1,11 +1,12 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
-import { Image } from 'Studio/Studio';
+import Studio, { Image } from 'Studio/Studio';
import AddNewStudioModalContent from './AddNewStudioModalContent';
interface AddNewStudioModalProps {
isOpen: boolean;
onModalClose: () => void;
+ studio: Studio;
foreignId: string;
title: string;
images: Image[];
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx
index be40092c3f..b4773fa78c 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudioModalContent.tsx
@@ -10,7 +10,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
-import { Image } from 'Studio/Studio';
+import Studio, { Image } from 'Studio/Studio';
import StudioLogo from 'Studio/StudioLogo';
import { EnhancedSelectInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
@@ -18,6 +18,7 @@ import { useAddNewStudioModalContent } from './useAddNewStudio';
import styles from './AddNewStudioModalContent.css';
interface AddNewStudioModalContentProps {
+ studio: Studio;
foreignId: string;
title: string;
images: Image[];
@@ -25,7 +26,7 @@ interface AddNewStudioModalContentProps {
}
function AddNewStudioModalContent(props: AddNewStudioModalContentProps) {
- const { foreignId, title, images, onModalClose } = props;
+ const { title, images, onModalClose, studio } = props;
const {
isAdding,
@@ -35,7 +36,7 @@ function AddNewStudioModalContent(props: AddNewStudioModalContentProps) {
settings,
onInputChange,
onAddStudioPress,
- } = useAddNewStudioModalContent(foreignId);
+ } = useAddNewStudioModalContent(studio);
const onQualityProfileIdChange = React.useCallback(
({ value }: EnhancedSelectInputChanged
) => {
diff --git a/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx b/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx
index 5414a49dcb..4c42f37060 100644
--- a/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx
+++ b/frontend/src/Studio/AddNewStudio/AddNewStudioSearchResult.tsx
@@ -134,6 +134,7 @@ function AddNewStudioSearchResult({
;
}
-interface AddMovieState {
- isPopulated: boolean;
- error: AppError | null;
- isAdding: boolean;
- isFetching: boolean;
- isAdded: boolean;
- addError: AppError | null;
- items: LookupStudioItem[];
- studiosWithStatus: StudioWithExistingStatus[];
- studioDefaults: StudioDefaults;
-}
-
-type RootState = AppState & {
- addMovie: AddMovieState;
-};
-
const defaultStudioDefaults: StudioDefaults = {
rootFolderPath: '',
monitored: true,
@@ -86,111 +61,75 @@ const defaultStudioDefaults: StudioDefaults = {
tags: [],
};
+interface SearchResource {
+ foreignId: string;
+ studio: Studio;
+ isExisting: boolean;
+}
+
function useAddNewStudio() {
const dispatch = useDispatch();
- const addMovie = useSelector((state: RootState) => state.addMovie);
const uiSettings = useSelector(createUISettingsSelector());
const existingStudiosCount = useSelector(
(state: AppState) => state.studios.items.length
);
const [term, setTerm] = useState('');
-
- const studioLookupTimeout = React.useRef | null>(null);
+ const [debouncedTerm, setDebouncedTerm] = useState('');
+ const timeoutRef = useRef | null>(null);
React.useEffect(() => {
dispatch(fetchRootFolders());
dispatch(fetchQueueDetails());
return () => {
- if (studioLookupTimeout.current) {
- clearTimeout(studioLookupTimeout.current);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
}
- dispatch(clearAddMovie());
dispatch(clearQueueDetails());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // When lookup results change, check which studios already exist
- React.useEffect(() => {
- if (addMovie?.items && addMovie.items.length > 0) {
- const foreignIds = addMovie.items
- .map((item: LookupStudioItem) => item.studio.foreignId)
- .filter((id: string | undefined) => id);
-
- if (foreignIds.length > 0) {
- const { request } = createAjaxRequest({
- url: '/studio/list',
- method: 'POST',
- contentType: 'application/json',
- data: JSON.stringify(foreignIds),
- });
-
- request.done((existingStudios: Studio[]) => {
- // Create a map of foreignId to full studio object
- const existingStudioMap = new Map(
- existingStudios.map((s) => [s.foreignId, s])
- );
-
- // Map over lookup items, using full studio data if available
- const mapped = addMovie.items.map((item: LookupStudioItem) => {
- const fullStudio = existingStudioMap.get(item.studio.foreignId);
- return {
- studio: fullStudio || item.studio,
- isExistingStudio: !!fullStudio,
- };
- });
-
- dispatch(setStudiosWithStatus(mapped));
- });
-
- request.fail(() => {
- // If the request fails, assume none exist
- const mapped = addMovie.items.map((item: LookupStudioItem) => ({
- studio: item.studio,
- isExistingStudio: false,
- }));
-
- dispatch(setStudiosWithStatus(mapped));
- });
- }
+ const {
+ data: searchResources = [],
+ isFetching,
+ error,
+ } = useApiQuery({
+ path: '/lookup/studio',
+ queryParams: { term: debouncedTerm },
+ queryOptions: { enabled: !!debouncedTerm.trim() },
+ });
+
+ const onStudioLookupChange = React.useCallback((value: string) => {
+ setTerm(value);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ if (value.trim() === '') {
+ setDebouncedTerm('');
} else {
- dispatch(setStudiosWithStatus([]));
+ timeoutRef.current = setTimeout(() => {
+ setDebouncedTerm(value);
+ }, 300);
}
- }, [addMovie?.items, addMovie?.isAdding, dispatch]);
-
- const onStudioLookupChange = React.useCallback(
- (value: string) => {
- setTerm(value);
- if (studioLookupTimeout.current) {
- clearTimeout(studioLookupTimeout.current);
- }
- if (value.trim() === '') {
- dispatch(clearAddMovie());
- } else {
- studioLookupTimeout.current = setTimeout(() => {
- dispatch(lookupStudio({ term: value }));
- }, 300);
- }
- },
- [dispatch]
- );
+ }, []);
const onClearStudioLookupPress = React.useCallback(() => {
setTerm('');
- dispatch(clearAddMovie());
- }, [dispatch]);
+ setDebouncedTerm('');
+ }, []);
return {
- isPopulated: addMovie?.isPopulated || false,
- error: addMovie?.error,
- isAdding: addMovie?.isAdding || false,
- isFetching: addMovie?.isFetching || false,
- isAdded: addMovie?.isAdded || false,
- addError: addMovie?.addError,
- items: addMovie?.items || [],
- studiosWithStatus: addMovie?.studiosWithStatus || [],
+ isPopulated: !!debouncedTerm.trim() && !isFetching,
+ error,
+ isAdding: false,
+ isFetching: isFetching && !!debouncedTerm.trim(),
+ isAdded: false,
+ addError: null,
+ items: searchResources,
+ studiosWithStatus: searchResources.map((r) => ({
+ studio: r.studio,
+ isExistingStudio: r.isExisting,
+ })),
term,
colorImpairedMode: uiSettings.enableColorImpairedMode,
hasExistingStudios: existingStudiosCount > 0,
@@ -211,25 +150,39 @@ export function useAddNewStudioSearchResult() {
};
}
-export function useAddNewStudioModalContent(foreignId: string) {
+export function useAddNewStudioModalContent(studio: Studio) {
const dispatch = useDispatch();
const { isSmallScreen } = useSelector(createDimensionsSelector());
const systemStatus = useSelector(createSystemStatusSelector());
const safeForWorkMode = useSelector(
(state: AppState) => state.settings.safeForWorkMode
);
- const addMovieState = useSelector((state: RootState) => state.addMovie);
- const {
- isAdding = false,
- addError,
- studioDefaults = defaultStudioDefaults,
- } = addMovieState || {};
+ const addMovieState = useSelector(
+ (
+ state: AppState & {
+ addMovie: { studioDefaults: StudioDefaults; addError?: ApiError };
+ }
+ ) => state.addMovie
+ );
+
+ const { studioDefaults = defaultStudioDefaults } = addMovieState || {};
+
+ const mutation = useApiMutation({
+ method: 'POST',
+ path: '/studio',
+ mutationOptions: {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['/studio/paged'] });
+ queryClient.invalidateQueries({ queryKey: ['/lookup/studio'] });
+ },
+ },
+ });
const { settings, validationErrors, validationWarnings } = selectSettings(
studioDefaults,
{},
- addError
+ mutation.error
) as {
settings: AddStudioSettings;
validationErrors: unknown[];
@@ -244,22 +197,21 @@ export function useAddNewStudioModalContent(foreignId: string) {
);
const onAddStudioPress = React.useCallback(() => {
- dispatch(
- addStudio({
- foreignId,
- rootFolderPath: settings.rootFolderPath.value,
- monitored: settings.monitored.value === true,
- moviesMonitored: settings.moviesMonitored.value === true,
- qualityProfileId: settings.qualityProfileId.value,
- searchForMovie: settings.searchForMovie.value,
- tags: settings.tags.value,
- })
- );
- }, [dispatch, foreignId, settings]);
+ const studioToAdd = getNewStudio(cloneDeep(studio) as object, {
+ rootFolderPath: settings.rootFolderPath.value,
+ monitored: settings.monitored.value === true,
+ moviesMonitored: settings.moviesMonitored.value === true,
+ qualityProfileId: settings.qualityProfileId.value,
+ searchForMovie: settings.searchForMovie.value,
+ tags: settings.tags.value,
+ }) as Studio;
+ studioToAdd.id = 0;
+ mutation.mutate(studioToAdd);
+ }, [studio, settings, mutation]);
return {
- addError,
- isAdding,
+ addError: mutation.error,
+ isAdding: mutation.isPending,
isSmallScreen,
isWindows: systemStatus.isWindows,
safeForWorkMode,
diff --git a/src/Whisparr.Api.V3/Search/SearchController.cs b/src/Whisparr.Api.V3/Search/SearchController.cs
index 1fee1f3ae0..d1abd1e9f3 100644
--- a/src/Whisparr.Api.V3/Search/SearchController.cs
+++ b/src/Whisparr.Api.V3/Search/SearchController.cs
@@ -27,6 +27,7 @@ public class SearchController : Controller
private readonly IConfigService _configService;
private readonly IImportListExclusionService _exclusionService;
private readonly IMovieService _movieService;
+ private readonly IStudioService _studioService;
public SearchController(ISearchForNewMovie searchProxy,
IBuildFileNames fileNameBuilder,
@@ -34,7 +35,8 @@ public SearchController(ISearchForNewMovie searchProxy,
IMapCoversToLocal coverMapper,
IConfigService configService,
IImportListExclusionService exclusionService,
- IMovieService movieService)
+ IMovieService movieService,
+ IStudioService studioService)
{
_searchProxy = searchProxy;
_fileNameBuilder = fileNameBuilder;
@@ -43,6 +45,7 @@ public SearchController(ISearchForNewMovie searchProxy,
_configService = configService;
_exclusionService = exclusionService;
_movieService = movieService;
+ _studioService = studioService;
}
[HttpGet("scene")]
@@ -67,7 +70,9 @@ public object SearchMovie([FromQuery] string term)
public object SearchStudio([FromQuery] string term)
{
var searchResults = _searchProxy.SearchForNewStudio(term);
- return MapToResource(searchResults).ToList();
+ var searchResources = MapToResource(searchResults).ToList();
+ MapToExistingStudios(searchResources);
+ return searchResources;
}
[HttpGet("performer")]
@@ -90,6 +95,19 @@ private void MapToExistingMovies(List searchResources)
}
}
+ private void MapToExistingStudios(List searchResources)
+ {
+ var matches = _studioService.FindByForeignIds(searchResources.Select(s => s.ForeignId).ToList());
+ foreach (var s in searchResources)
+ {
+ var match = matches.Where(m => m.ForeignId == s.ForeignId);
+ if (match.Any())
+ {
+ s.isExisting = true;
+ }
+ }
+ }
+
private IEnumerable MapToResource(IEnumerable