diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 2c88e84205..e63133a42c 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -317,6 +317,35 @@ describe('PUT /request/:requestId (movie)', () => { assert.strictEqual(saved.rootFolder, '/updated/movies'); }); + it('clears stored overrides when the fields are omitted', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedRequest(); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + + await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.MOVIE, + serverId: 3, + profileId: 7, + rootFolder: '/updated/movies', + tags: [1, 2], + }); + + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.MOVIE, + serverId: 3, + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.strictEqual(saved.rootFolder, null); + assert.strictEqual(saved.profileId, null); + assert.strictEqual(saved.tags, null); + }); + it('refuses to modify a request that is no longer pending', async () => { const requestRepo = getRepository(MediaRequest); const mediaRequest = await seedRequest(MediaRequestStatus.APPROVED); diff --git a/server/routes/request.ts b/server/routes/request.ts index 9ee5697395..b4c6113045 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -514,19 +514,19 @@ requestRoutes.put<{ requestId: string }>( if (req.body.mediaType === MediaType.MOVIE) { request.serverId = req.body.serverId; - request.profileId = req.body.profileId; - request.rootFolder = req.body.rootFolder; - request.tags = req.body.tags; + request.profileId = req.body.profileId ?? null; + request.rootFolder = req.body.rootFolder ?? null; + request.tags = req.body.tags ?? null; request.requestedBy = requestUser as User; await requestRepository.save(request); } else if (req.body.mediaType === MediaType.TV) { const mediaRepository = getRepository(Media); request.serverId = req.body.serverId; - request.profileId = req.body.profileId; - request.rootFolder = req.body.rootFolder; - request.languageProfileId = req.body.languageProfileId; - request.tags = req.body.tags; + request.profileId = req.body.profileId ?? null; + request.rootFolder = req.body.rootFolder ?? null; + request.languageProfileId = req.body.languageProfileId ?? null; + request.tags = req.body.tags ?? null; request.requestedBy = requestUser as User; const requestedSeasons = req.body.seasons as number[] | undefined; diff --git a/server/subscriber/MediaRequestSubscriber.test.ts b/server/subscriber/MediaRequestSubscriber.test.ts new file mode 100644 index 0000000000..7a7fdeb4f1 --- /dev/null +++ b/server/subscriber/MediaRequestSubscriber.test.ts @@ -0,0 +1,230 @@ +import type { AddSeriesOptions } from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import MediaRequest from '@server/entity/MediaRequest'; +import Season from '@server/entity/Season'; +import SeasonRequest from '@server/entity/SeasonRequest'; +import { User } from '@server/entity/User'; +import type { SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber'; +import { setupTestDb } from '@server/test/db'; +import assert from 'node:assert/strict'; +import { beforeEach, describe, it, mock } from 'node:test'; + +function fakeTmdbShow( + tmdbId: number, + keywordIds: number[] = [] +): TmdbTvDetails { + return { + id: tmdbId, + content_ratings: { results: [] }, + created_by: [], + episode_run_time: [], + first_air_date: '2024-01-01', + genres: [], + homepage: '', + in_production: false, + languages: ['en'], + last_air_date: '2024-01-01', + name: 'Test Show', + networks: [], + number_of_episodes: 10, + number_of_seasons: 1, + origin_country: ['US'], + original_language: 'en', + original_name: 'Test Show', + overview: '', + popularity: 0, + production_companies: [], + production_countries: [], + spoken_languages: [], + seasons: [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 10, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ], + status: 'Ended', + type: 'Scripted', + vote_average: 0, + vote_count: 0, + aggregate_credits: { cast: [] }, + credits: { crew: [] }, + external_ids: { tvdb_id: 550 }, + keywords: { + results: keywordIds.map((id) => ({ id, name: `keyword-${id}` })), + }, + videos: { results: [] }, + } as unknown as TmdbTvDetails; +} + +let getTvShowImpl: () => Promise = async () => fakeTmdbShow(1); + +Object.defineProperty(TheMovieDb.prototype, 'getTvShow', { + set() {}, + get() { + return async () => getTvShowImpl(); + }, + configurable: true, +}); + +let addSeriesOptions: AddSeriesOptions | null = null; + +Object.defineProperty(SonarrAPI.prototype, 'addSeries', { + set() {}, + get() { + return async (options: AddSeriesOptions) => { + addSeriesOptions = options; + return { id: 1, titleSlug: 'test-show' }; + }; + }, + configurable: true, +}); + +mock.method(MediaRequest, 'sendNotification', async () => undefined); + +setupTestDb(); + +function configureSonarr(overrides: Partial = {}): void { + const settings = getSettings(); + settings.sonarr = [ + { + id: 0, + name: 'Sonarr', + hostname: 'localhost', + port: 8989, + apiKey: 'test-key', + baseUrl: '', + useSsl: false, + activeProfileId: 1, + activeDirectory: '/tv', + activeLanguageProfileId: 1, + activeAnimeProfileId: undefined, + activeAnimeDirectory: '', + activeAnimeLanguageProfileId: undefined, + animeTags: [], + is4k: false, + enableSeasonFolders: true, + tags: [], + isDefault: true, + syncEnabled: true, + preventSearch: false, + externalUrl: '', + ...overrides, + }, + ] as SonarrSettings[]; + settings.radarr = []; +} + +async function seedApprovedTvRequest( + overrides: Partial = {} +): Promise { + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + + const requestedBy = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + const media = await mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId: 1050, + tvdbId: 550, + status: MediaStatus.PROCESSING, + status4k: MediaStatus.UNKNOWN, + seasons: [ + new Season({ + seasonNumber: 1, + status: MediaStatus.PROCESSING, + status4k: MediaStatus.UNKNOWN, + }), + ], + }) + ); + + const created = await requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media, + requestedBy, + is4k: false, + seasons: [ + new SeasonRequest({ + seasonNumber: 1, + status: MediaRequestStatus.PENDING, + }), + ], + ...overrides, + }) + ); + + const request = await requestRepo.findOneOrFail({ + where: { id: created.id }, + relations: { requestedBy: true, media: true, seasons: true }, + }); + + // Flipped in memory so the subscriber is exercised directly rather than + // through a save that would re-enter it + request.status = MediaRequestStatus.APPROVED; + + return request; +} + +async function sendToSonarr(request: MediaRequest): Promise { + const subscriber = new MediaRequestSubscriber(); + await subscriber.sendToSonarr(request, getRepository(MediaRequest).manager); + // addSeries is dispatched detached from the send, so let it settle + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('MediaRequestSubscriber', () => { + beforeEach(() => { + addSeriesOptions = null; + getTvShowImpl = async () => fakeTmdbShow(1050, [ANIME_KEYWORD_ID]); + }); + + describe('sonarr anime routing', () => { + it('uses the anime directory and profile when the request stores no overrides', async () => { + configureSonarr({ + activeAnimeDirectory: '/anime', + activeAnimeProfileId: 7, + }); + + await sendToSonarr(await seedApprovedTvRequest()); + + assert.equal(addSeriesOptions?.rootFolderPath, '/anime'); + assert.equal(addSeriesOptions?.profileId, 7); + }); + + it('honors a stored override that matches the standard directory', async () => { + configureSonarr({ + activeAnimeDirectory: '/anime', + activeAnimeProfileId: 7, + }); + + await sendToSonarr( + await seedApprovedTvRequest({ rootFolder: '/tv', profileId: 1 }) + ); + + assert.equal(addSeriesOptions?.rootFolderPath, '/tv'); + assert.equal(addSeriesOptions?.profileId, 1); + }); + }); +}); diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index d19e70dd72..52912dfa7b 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -198,32 +198,47 @@ const AdvancedRequester = ({ } }, [data]); - useEffect(() => { - if (serverData) { - const defaultProfile = serverData.profiles.find( + const serverDefaults = useMemo(() => { + if (!serverData) { + return null; + } + + return { + profile: serverData.profiles.find( (profile) => profile.id === (isAnime && serverData.server.activeAnimeProfileId ? serverData.server.activeAnimeProfileId : serverData.server.activeProfileId) - ); - const defaultFolder = serverData.rootFolders.find( + ), + folder: serverData.rootFolders.find( (folder) => folder.path === (isAnime && serverData.server.activeAnimeDirectory ? serverData.server.activeAnimeDirectory : serverData.server.activeDirectory) - ); - const defaultLanguage = serverData.languageProfiles?.find( + ), + language: serverData.languageProfiles?.find( (language) => language.id === (isAnime && serverData.server.activeAnimeLanguageProfileId ? serverData.server.activeAnimeLanguageProfileId : serverData.server.activeLanguageProfileId) - ); - const defaultTags = isAnime + ), + tags: isAnime ? serverData.server.activeAnimeTags - : serverData.server.activeTags; + : serverData.server.activeTags, + }; + }, [serverData, isAnime]); + + useEffect(() => { + if (serverData && serverDefaults) { + const { + profile: defaultProfile, + folder: defaultFolder, + language: defaultLanguage, + tags: defaultTags, + } = serverDefaults; const applyOverrides = defaultOverrides && @@ -262,7 +277,7 @@ const AdvancedRequester = ({ setSelectedTags(defaultTags); } } - }, [serverData]); + }, [serverData, serverDefaults]); useEffect(() => { if (defaultOverrides && defaultOverrides.server != null) { @@ -310,12 +325,26 @@ const AdvancedRequester = ({ useEffect(() => { if (selectedServer !== null || selectedUser) { onChange({ - folder: selectedFolder !== '' ? selectedFolder : undefined, - profile: selectedProfile !== -1 ? selectedProfile : undefined, + folder: + selectedFolder !== '' && + selectedFolder !== serverDefaults?.folder?.path + ? selectedFolder + : undefined, + profile: + selectedProfile !== -1 && + selectedProfile !== serverDefaults?.profile?.id + ? selectedProfile + : undefined, server: selectedServer ?? undefined, user: selectedUser ?? undefined, - language: selectedLanguage !== -1 ? selectedLanguage : undefined, - tags: selectedTags, + language: + selectedLanguage !== -1 && + selectedLanguage !== serverDefaults?.language?.id + ? selectedLanguage + : undefined, + tags: !isEqual(selectedTags, serverDefaults?.tags ?? []) + ? selectedTags + : undefined, ignoreQuota: isIgnoreQuotaVisible && ignoreQuota ? true : undefined, }); } @@ -328,6 +357,7 @@ const AdvancedRequester = ({ selectedTags, ignoreQuota, isIgnoreQuotaVisible, + serverDefaults, ]); if (!data && !error) {