From 1e16bc8db6f62c8d6b321220f427327e4313a59f Mon Sep 17 00:00:00 2001 From: Dor Peled Date: Tue, 21 Jul 2026 22:38:42 +0300 Subject: [PATCH 1/4] fix(requests): reset orphaned season statuses when a request is deleted Seasons left PENDING/PROCESSING after their request was deleted could never be re-requested ("No seasons available to request"). Mirrors the existing decline-path season reset. fix #3278 --- server/subscriber/MediaRequestSubscriber.ts | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index a74c539b09..d1cc482b15 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -949,7 +949,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { const fullMedia = await manager.findOneOrFail(Media, { where: { id: entity.media.id }, - relations: { requests: true }, + relations: { requests: { seasons: true }, seasons: true }, }); const hasActive = fullMedia.requests.some( @@ -1001,6 +1001,32 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface + request.is4k === entity.is4k && + request.status !== MediaRequestStatus.COMPLETED && + request.status !== MediaRequestStatus.DECLINED + ) + .flatMap((request) => request.seasons.map((s) => s.seasonNumber)) + ); + + for (const season of fullMedia.seasons) { + if ( + (season[statusKey] === MediaStatus.PENDING || + season[statusKey] === MediaStatus.PROCESSING) && + !activeSeasonNumbers.has(season.seasonNumber) + ) { + season[statusKey] = MediaStatus.UNKNOWN; + await manager.save(season); + } + } + } } public async afterUpdate(event: UpdateEvent): Promise { From a9a5760495ca7ebea677b5c8dd8b1976dca9bd6d Mon Sep 17 00:00:00 2001 From: Dor Peled Date: Thu, 23 Jul 2026 18:24:31 +0300 Subject: [PATCH 2/4] fix(requests): avoid resetting seasons not covered by the deleted request The reset swept every PENDING/PROCESSING season with no remaining active request, including seasons an arr scan had set that were unrelated to the deleted request. Scope it to the seasons the request covered and batch the season saves into one manager.save call. --- server/subscriber/MediaRequestSubscriber.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index d1cc482b15..1eb8f1e827 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -1005,6 +1005,9 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface s.seasonNumber) + ); const activeSeasonNumbers = new Set( fullMedia.requests .filter( @@ -1016,16 +1019,21 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface request.seasons.map((s) => s.seasonNumber)) ); + const changedSeasons: Season[] = []; for (const season of fullMedia.seasons) { if ( (season[statusKey] === MediaStatus.PENDING || season[statusKey] === MediaStatus.PROCESSING) && + removedSeasonNumbers.has(season.seasonNumber) && !activeSeasonNumbers.has(season.seasonNumber) ) { season[statusKey] = MediaStatus.UNKNOWN; - await manager.save(season); + changedSeasons.push(season); } } + if (changedSeasons.length) { + await manager.save(changedSeasons); + } } } From 54e7685f3b804efe4d67f6ee6621b539c0e68d6e Mon Sep 17 00:00:00 2001 From: Dor Peled Date: Thu, 23 Jul 2026 18:25:40 +0300 Subject: [PATCH 3/4] test(requests): cover season status reset on request delete Covered season resets to UNKNOWN and is re-requestable, uncovered season untouched, season kept while a second active request covers it, and status4k untouched on a non-4K delete. Three of the four fail on develop without the fix. --- server/routes/request.test.ts | 209 ++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index d90f6d3b0c..4791b17d8c 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it, mock } from 'node:test'; +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbTvDetails, + TmdbTvSeasonResult, +} from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus, @@ -9,6 +14,8 @@ import { 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 { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; @@ -26,6 +33,23 @@ const sendNotificationMock = mock.method( async () => undefined ).mock; +// --- Mock TheMovieDb --- +let getTvShowImpl: (args: { + tvId: number; + language?: string; +}) => Promise = async () => { + throw new Error('404'); +}; + +Object.defineProperty(TheMovieDb.prototype, 'getTvShow', { + get() { + return async (args: { tvId: number; language?: string }) => + getTvShowImpl(args); + }, + set() {}, + configurable: true, +}); + let app: Express; function createApp() { @@ -535,3 +559,188 @@ describe('DELETE /request/:requestId, deleted media status restoration', () => { assert.strictEqual(updated.status, MediaStatus.PARTIALLY_AVAILABLE); }); }); + +function fakeTmdbShow( + tmdbId: number, + seasons: TmdbTvSeasonResult[] = [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 10, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ] +): 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: seasons.length, + origin_country: ['US'], + original_language: 'en', + original_name: 'Test Show', + overview: '', + popularity: 0, + production_companies: [], + production_countries: [], + spoken_languages: [], + seasons, + status: 'Ended', + type: 'Scripted', + vote_average: 0, + vote_count: 0, + aggregate_credits: { cast: [] }, + credits: { crew: [] }, + external_ids: {}, + keywords: { results: [] }, + videos: { results: [] }, + }; +} + +describe('DELETE /request/:requestId, orphaned season status reset', () => { + beforeEach(() => { + getTvShowImpl = async () => { + throw new Error('404'); + }; + }); + + async function seedTvShow( + tmdbId: number, + seasons: Partial[] + ): Promise { + const mediaRepo = getRepository(Media); + + return mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId, + status: MediaStatus.PROCESSING, + status4k: MediaStatus.UNKNOWN, + seasons: seasons.map((season) => new Season(season)), + }) + ); + } + + async function seedTvRequest( + media: Media, + seasonNumbers: number[] + ): Promise { + const userRepo = getRepository(User); + const requestRepo = getRepository(MediaRequest); + + const admin = await userRepo.findOneOrFail({ + where: { email: 'admin@seerr.dev' }, + }); + + return requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.APPROVED, + media, + requestedBy: admin, + is4k: false, + seasons: seasonNumbers.map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.APPROVED, + }) + ), + }) + ); + } + + it('resets a request-covered PROCESSING season to UNKNOWN so it can be re-requested', async () => { + const mediaRepo = getRepository(Media); + const media = await seedTvShow(99101, [ + { seasonNumber: 1, status: MediaStatus.PROCESSING }, + ]); + const tvRequest = await seedTvRequest(media, [1]); + + getTvShowImpl = async ({ tvId }) => fakeTmdbShow(tvId); + + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete(`/request/${tvRequest.id}`); + assert.strictEqual(res.status, 204); + + const updated = await mediaRepo.findOneOrFail({ + where: { id: media.id }, + }); + assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN); + + const friend = await loginAs('friend@seerr.dev', 'test1234'); + const reRequest = await friend.post('/request').send({ + mediaType: MediaType.TV, + mediaId: 99101, + seasons: [1], + }); + assert.strictEqual(reRequest.status, 201); + }); + + it('does not touch PROCESSING seasons the deleted request did not cover', async () => { + const mediaRepo = getRepository(Media); + const media = await seedTvShow(99102, [ + { seasonNumber: 1, status: MediaStatus.PROCESSING }, + { seasonNumber: 2, status: MediaStatus.PROCESSING }, + ]); + const tvRequest = await seedTvRequest(media, [1]); + + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete(`/request/${tvRequest.id}`); + assert.strictEqual(res.status, 204); + + const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } }); + const seasonOne = updated.seasons.find((s) => s.seasonNumber === 1); + const seasonTwo = updated.seasons.find((s) => s.seasonNumber === 2); + assert.strictEqual(seasonOne?.status, MediaStatus.UNKNOWN); + assert.strictEqual(seasonTwo?.status, MediaStatus.PROCESSING); + }); + + it('keeps a season PROCESSING while another active request still covers it', async () => { + const mediaRepo = getRepository(Media); + const media = await seedTvShow(99103, [ + { seasonNumber: 1, status: MediaStatus.PROCESSING }, + ]); + const firstRequest = await seedTvRequest(media, [1]); + await seedTvRequest(media, [1]); + + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete(`/request/${firstRequest.id}`); + assert.strictEqual(res.status, 204); + + const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } }); + assert.strictEqual(updated.seasons[0].status, MediaStatus.PROCESSING); + }); + + it('leaves season status4k untouched when deleting a non-4K request', async () => { + const mediaRepo = getRepository(Media); + const media = await seedTvShow(99104, [ + { + seasonNumber: 1, + status: MediaStatus.PROCESSING, + status4k: MediaStatus.PROCESSING, + }, + ]); + const tvRequest = await seedTvRequest(media, [1]); + + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete(`/request/${tvRequest.id}`); + assert.strictEqual(res.status, 204); + + const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } }); + assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN); + assert.strictEqual(updated.seasons[0].status4k, MediaStatus.PROCESSING); + }); +}); From d9c4c988b9b395609249cd255d45953cc7a41ad4 Mon Sep 17 00:00:00 2001 From: Dor Peled Date: Thu, 23 Jul 2026 18:41:43 +0300 Subject: [PATCH 4/4] test(requests): cover PENDING season reset and 4K request deletion Parameterize the covered-season reset over PENDING and PROCESSING, and add the inverse 4K case: deleting a 4K request resets status4k and leaves status untouched, proving the status key is selected per request type. --- server/routes/request.test.ts | 72 ++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 4791b17d8c..bee09cc2ba 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -635,7 +635,8 @@ describe('DELETE /request/:requestId, orphaned season status reset', () => { async function seedTvRequest( media: Media, - seasonNumbers: number[] + seasonNumbers: number[], + is4k = false ): Promise { const userRepo = getRepository(User); const requestRepo = getRepository(MediaRequest); @@ -650,7 +651,7 @@ describe('DELETE /request/:requestId, orphaned season status reset', () => { status: MediaRequestStatus.APPROVED, media, requestedBy: admin, - is4k: false, + is4k, seasons: seasonNumbers.map( (seasonNumber) => new SeasonRequest({ @@ -662,32 +663,37 @@ describe('DELETE /request/:requestId, orphaned season status reset', () => { ); } - it('resets a request-covered PROCESSING season to UNKNOWN so it can be re-requested', async () => { - const mediaRepo = getRepository(Media); - const media = await seedTvShow(99101, [ - { seasonNumber: 1, status: MediaStatus.PROCESSING }, - ]); - const tvRequest = await seedTvRequest(media, [1]); + const staleStatuses = [ + { label: 'PENDING', status: MediaStatus.PENDING }, + { label: 'PROCESSING', status: MediaStatus.PROCESSING }, + ] as const; - getTvShowImpl = async ({ tvId }) => fakeTmdbShow(tvId); + for (const { label, status } of staleStatuses) { + it(`resets a request-covered ${label} season to UNKNOWN so it can be re-requested`, async () => { + const mediaRepo = getRepository(Media); + const media = await seedTvShow(99101, [{ seasonNumber: 1, status }]); + const tvRequest = await seedTvRequest(media, [1]); - const admin = await loginAs('admin@seerr.dev', 'test1234'); - const res = await admin.delete(`/request/${tvRequest.id}`); - assert.strictEqual(res.status, 204); + getTvShowImpl = async ({ tvId }) => fakeTmdbShow(tvId); - const updated = await mediaRepo.findOneOrFail({ - where: { id: media.id }, - }); - assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN); + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete(`/request/${tvRequest.id}`); + assert.strictEqual(res.status, 204); + + const updated = await mediaRepo.findOneOrFail({ + where: { id: media.id }, + }); + assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN); - const friend = await loginAs('friend@seerr.dev', 'test1234'); - const reRequest = await friend.post('/request').send({ - mediaType: MediaType.TV, - mediaId: 99101, - seasons: [1], + const friend = await loginAs('friend@seerr.dev', 'test1234'); + const reRequest = await friend.post('/request').send({ + mediaType: MediaType.TV, + mediaId: 99101, + seasons: [1], + }); + assert.strictEqual(reRequest.status, 201); }); - assert.strictEqual(reRequest.status, 201); - }); + } it('does not touch PROCESSING seasons the deleted request did not cover', async () => { const mediaRepo = getRepository(Media); @@ -743,4 +749,24 @@ describe('DELETE /request/:requestId, orphaned season status reset', () => { assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN); assert.strictEqual(updated.seasons[0].status4k, MediaStatus.PROCESSING); }); + + it('resets season status4k and leaves status untouched when deleting a 4K request', async () => { + const mediaRepo = getRepository(Media); + const media = await seedTvShow(99105, [ + { + seasonNumber: 1, + status: MediaStatus.PROCESSING, + status4k: MediaStatus.PROCESSING, + }, + ]); + const tvRequest = await seedTvRequest(media, [1], true); + + const admin = await loginAs('admin@seerr.dev', 'test1234'); + const res = await admin.delete(`/request/${tvRequest.id}`); + assert.strictEqual(res.status, 204); + + const updated = await mediaRepo.findOneOrFail({ where: { id: media.id } }); + assert.strictEqual(updated.seasons[0].status4k, MediaStatus.UNKNOWN); + assert.strictEqual(updated.seasons[0].status, MediaStatus.PROCESSING); + }); });