Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions server/routes/request.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand All @@ -26,6 +33,23 @@ const sendNotificationMock = mock.method(
async () => undefined
).mock;

// --- Mock TheMovieDb ---
let getTvShowImpl: (args: {
tvId: number;
language?: string;
}) => Promise<TmdbTvDetails> = 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() {
Expand Down Expand Up @@ -535,3 +559,214 @@ 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<Season>[]
): Promise<Media> {
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[],
is4k = false
): Promise<MediaRequest> {
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,
seasons: seasonNumbers.map(
(seasonNumber) =>
new SeasonRequest({
seasonNumber,
status: MediaRequestStatus.APPROVED,
})
),
})
);
}

const staleStatuses = [
{ label: 'PENDING', status: MediaStatus.PENDING },
{ label: 'PROCESSING', status: MediaStatus.PROCESSING },
] as const;

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]);

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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
});
});
36 changes: 35 additions & 1 deletion server/subscriber/MediaRequestSubscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
): Promise<void> {
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(
Expand Down Expand Up @@ -1001,6 +1001,40 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe

await manager.save(cleanMedia);
}

// Reset stale seasons or re-requests fail ("No seasons available to request")
if (fullMedia.mediaType === MediaType.TV) {
const statusKey = entity.is4k ? 'status4k' : 'status';
const removedSeasonNumbers = new Set(
entity.seasons.map((s) => s.seasonNumber)
);
const activeSeasonNumbers = new Set(
fullMedia.requests
.filter(
(request) =>
request.is4k === entity.is4k &&
request.status !== MediaRequestStatus.COMPLETED &&
request.status !== MediaRequestStatus.DECLINED
)
.flatMap((request) => 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)
Comment thread
Knat-Dev marked this conversation as resolved.
) {
season[statusKey] = MediaStatus.UNKNOWN;
changedSeasons.push(season);
}
}
if (changedSeasons.length) {
await manager.save(changedSeasons);
}
}
}

public async afterUpdate(event: UpdateEvent<MediaRequest>): Promise<void> {
Expand Down