diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 28a2b7376d..ebf58cb7b5 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -15,6 +15,7 @@ import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import OverrideRule from '@server/entity/OverrideRule'; +import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; @@ -350,6 +351,19 @@ async function seedTvMedia(tmdbId: number) { ); } +async function seedMediaSeasons( + tmdbId: number, + seasons: { seasonNumber: number; status: MediaStatus }[] +) { + const media = await seedTvMedia(tmdbId); + media.seasons = seasons.map( + ({ seasonNumber, status }) => + new Season({ seasonNumber, status, status4k: MediaStatus.UNKNOWN }) + ); + + return getRepository(Media).save(media); +} + async function seedTvRequest( requestedBy: User, seasons: number[], @@ -411,6 +425,102 @@ describe('PUT /request/:requestId (tv)', () => { }); }); +describe('PUT /request/:requestId (season availability)', () => { + it('does not add a season the media already has', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev'); + const mediaRequest = await seedTvRequest(owner, [1]); + await seedMediaSeasons(67890, [ + { seasonNumber: 2, status: MediaStatus.AVAILABLE }, + ]); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 3] + ); + }); + + it('returns 202 when every requested season is already covered', async () => { + const owner = await seedUser('friend@seerr.dev'); + const mediaRequest = await seedTvRequest(owner, [1]); + await seedMediaSeasons(67890, [ + { seasonNumber: 2, status: MediaStatus.AVAILABLE }, + ]); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [2], + }); + + assert.strictEqual(res.status, 202); + }); + + it('keeps the seasons it already holds once they are available', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev'); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + await seedMediaSeasons(67890, [ + { seasonNumber: 1, status: MediaStatus.AVAILABLE }, + { seasonNumber: 2, status: MediaStatus.PROCESSING }, + ]); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + serverId: 3, + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 2] + ); + assert.strictEqual(saved.serverId, 3); + }); + + it('does not charge quota for a season the media already has', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev', { tvQuotaLimit: 1 }); + const mediaRequest = await seedTvRequest(owner, [1]); + await seedMediaSeasons(67890, [ + { seasonNumber: 2, status: MediaStatus.AVAILABLE }, + ]); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber), + [1] + ); + }); +}); + describe('PUT /request/:requestId (quota)', () => { it('rejects adding seasons beyond the season limit', async () => { const requestRepo = getRepository(MediaRequest); diff --git a/server/routes/request.ts b/server/routes/request.ts index 9234d9ca5f..2708c48add 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -577,21 +577,43 @@ requestRoutes.put<{ requestId: string }>( return [...seasons, ...combinedSeasons]; }, [] as number[]); + const currentSeasons = request.seasons.map((s) => s.seasonNumber); + + // Seasons the media already covers cannot be requested again, while + // the ones this request holds stay on it + const coveredSeasons = (media.seasons ?? []) + .filter( + (season) => + season[request.is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN && + season[request.is4k ? 'status4k' : 'status'] !== + MediaStatus.DELETED + ) + .map((season) => season.seasonNumber) + .filter((sn) => !currentSeasons.includes(sn)); + const filteredSeasons = requestedSeasons.filter( (rs) => !existingSeasons.includes(rs) ); - if (filteredSeasons.length === 0) { + const keptSeasons = filteredSeasons.filter((sn) => + currentSeasons.includes(sn) + ); + + const newSeasons = filteredSeasons.filter( + (sn) => + !currentSeasons.includes(sn) && !coveredSeasons.includes(sn) + ); + + const resultingSeasonCount = keptSeasons.length + newSeasons.length; + + if (resultingSeasonCount === 0) { return next({ status: 202, message: 'No seasons available to request', }); } - const newSeasons = filteredSeasons.filter( - (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) - ); - if (!request.ignoreQuota) { const quotas = await requestUser.getQuota(); @@ -611,7 +633,7 @@ requestRoutes.put<{ requestId: string }>( const priorSeasonCount = countedAlready ? request.seasons.length : 0; - const requiredSeasons = filteredSeasons.length - priorSeasonCount; + const requiredSeasons = resultingSeasonCount - priorSeasonCount; if ( quotas.tv.limit && @@ -625,7 +647,7 @@ requestRoutes.put<{ requestId: string }>( } request.seasons = request.seasons.filter((rs) => - filteredSeasons.includes(rs.seasonNumber) + keptSeasons.includes(rs.seasonNumber) ); if (newSeasons.length > 0) {