mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-11 22:49:57 +00:00
fix(scanners): decline request when media resets to UNKNOWN (#3578)
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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 { ProcessableSeason } from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it, mock } from 'node:test';
|
||||
|
||||
mock.method(MediaRequest, 'sendNotification', async () => undefined);
|
||||
|
||||
setupTestDb();
|
||||
|
||||
class HarnessScanner extends BaseScanner<unknown> {
|
||||
public constructor(declineRequestsOnStatusReset?: boolean) {
|
||||
super('Harness Scan');
|
||||
|
||||
if (declineRequestsOnStatusReset !== undefined) {
|
||||
this.declineRequestsOnStatusReset = declineRequestsOnStatusReset;
|
||||
}
|
||||
}
|
||||
|
||||
public async scanShow(
|
||||
tmdbId: number,
|
||||
seasons: ProcessableSeason[]
|
||||
): Promise<void> {
|
||||
await this.processShow(tmdbId, undefined, seasons, {
|
||||
title: 'Test Show',
|
||||
});
|
||||
await this.resolve();
|
||||
}
|
||||
|
||||
public scanAbandonedMovie(tmdbId: number): Promise<void> {
|
||||
return this.processMovie(tmdbId, {
|
||||
title: 'Test Movie',
|
||||
processing: false,
|
||||
hasFile: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Most permissive resolver possible, so a survivor means nothing was recorded.
|
||||
public resolve(): Promise<void> {
|
||||
return this.resolveStatusResets(() => true);
|
||||
}
|
||||
}
|
||||
|
||||
// Identical to what plex/index.ts and jellyfin/index.ts push for a season the library lacks.
|
||||
const LIBRARY_HAS_NO_EPISODES: ProcessableSeason[] = [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
episodes: 0,
|
||||
episodes4k: 0,
|
||||
totalEpisodes: 10,
|
||||
},
|
||||
];
|
||||
|
||||
async function seedInFlightShow(tmdbId: number): Promise<MediaRequest> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const requestedBy = await userRepository.findOneOrFail({ where: { id: 1 } });
|
||||
|
||||
const media = await mediaRepository.save(
|
||||
new Media({
|
||||
tmdbId,
|
||||
mediaType: MediaType.TV,
|
||||
status: MediaStatus.PROCESSING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
seasons: [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.PROCESSING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// sendToSonarr runs on insert and only bails when no server is configured,
|
||||
// so leftover settings from another test would reach TMDB and fail the request.
|
||||
const settings = getSettings();
|
||||
settings.radarr = [];
|
||||
settings.sonarr = [];
|
||||
|
||||
return requestRepository.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
seasons: [
|
||||
new SeasonRequest({
|
||||
seasonNumber: 1,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function seedInFlightMovie(tmdbId: number): Promise<MediaRequest> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const requestedBy = await userRepository.findOneOrFail({ where: { id: 1 } });
|
||||
|
||||
const media = await mediaRepository.save(
|
||||
new Media({
|
||||
tmdbId,
|
||||
mediaType: MediaType.MOVIE,
|
||||
status: MediaStatus.PROCESSING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
|
||||
// sendToRadarr runs on insert and only bails when no server is configured,
|
||||
// so leftover settings from another test would reach TMDB and fail the request.
|
||||
const settings = getSettings();
|
||||
settings.radarr = [];
|
||||
settings.sonarr = [];
|
||||
|
||||
return requestRepository.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('BaseScanner', () => {
|
||||
describe('declineRequestsOnStatusReset gate', () => {
|
||||
it('leaves the request alone for a scanner that has not opted in', async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedInFlightShow(7001);
|
||||
|
||||
await new HarnessScanner().scanShow(7001, LIBRARY_HAS_NO_EPISODES);
|
||||
|
||||
const updated = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('declines the request for a scanner that has opted in', async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedInFlightShow(7002);
|
||||
|
||||
await new HarnessScanner(true).scanShow(7002, LIBRARY_HAS_NO_EPISODES);
|
||||
|
||||
const updated = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.status, MediaRequestStatus.DECLINED);
|
||||
});
|
||||
|
||||
it('resets a movie in the loop for a scanner that has not opted in', async () => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedInFlightMovie(7003);
|
||||
|
||||
await new HarnessScanner().scanAbandonedMovie(7003);
|
||||
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 7003 },
|
||||
});
|
||||
const updated = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(media.status, MediaStatus.UNKNOWN);
|
||||
assert.strictEqual(updated.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('defers a movie reset to the resolve pass for a scanner that has opted in', async () => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedInFlightMovie(7004);
|
||||
const scanner = new HarnessScanner(true);
|
||||
|
||||
await scanner.scanAbandonedMovie(7004);
|
||||
|
||||
const duringScan = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 7004 },
|
||||
});
|
||||
assert.strictEqual(duringScan.status, MediaStatus.PROCESSING);
|
||||
|
||||
await scanner.resolve();
|
||||
|
||||
const afterResolve = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 7004 },
|
||||
});
|
||||
const updated = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(afterResolve.status, MediaStatus.UNKNOWN);
|
||||
assert.strictEqual(updated.status, MediaRequestStatus.DECLINED);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,12 @@ class BaseScanner<T> {
|
||||
protected enable4kShow = false;
|
||||
protected sessionId: string;
|
||||
protected running = false;
|
||||
// Only the *arr scanners know whether a title is still being pursued.
|
||||
protected declineRequestsOnStatusReset = false;
|
||||
private statusResetCandidates = new Map<
|
||||
string,
|
||||
{ mediaId: number; is4k: boolean }
|
||||
>();
|
||||
readonly asyncLock = new AsyncLock();
|
||||
readonly tmdb = new TheMovieDb();
|
||||
|
||||
@@ -120,17 +126,24 @@ class BaseScanner<T> {
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
const statusField = is4k ? 'status4k' : 'status';
|
||||
const previousStatus = existing[statusField];
|
||||
const abandonedEntry = !processing && !hasFile;
|
||||
// One server cannot speak for the others; settled after the whole run.
|
||||
const deferredReset =
|
||||
this.declineRequestsOnStatusReset &&
|
||||
abandonedEntry &&
|
||||
previousStatus === MediaStatus.PROCESSING;
|
||||
|
||||
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
|
||||
const statusField = is4k ? 'status4k' : 'status';
|
||||
const previousStatus = existing[statusField];
|
||||
if (deferredReset) {
|
||||
this.recordStatusReset(existing.id, is4k);
|
||||
}
|
||||
|
||||
if (previousStatus !== MediaStatus.AVAILABLE && !deferredReset) {
|
||||
existing[statusField] =
|
||||
!processing && hasFile
|
||||
? MediaStatus.AVAILABLE
|
||||
: !processing &&
|
||||
!hasFile &&
|
||||
previousStatus === MediaStatus.PROCESSING
|
||||
: abandonedEntry && previousStatus === MediaStatus.PROCESSING
|
||||
? MediaStatus.UNKNOWN
|
||||
: processing
|
||||
? previousStatus === MediaStatus.DELETED
|
||||
@@ -174,32 +187,35 @@ class BaseScanner<T> {
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
) {
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
// Letting an abandoned entry claim these would make them scan-order dependent.
|
||||
if (!deferredReset) {
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
) {
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
externalServiceId !== undefined &&
|
||||
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
externalServiceId
|
||||
) {
|
||||
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
externalServiceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
if (
|
||||
externalServiceId !== undefined &&
|
||||
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
externalServiceId
|
||||
) {
|
||||
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
externalServiceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
externalServiceSlug !== undefined &&
|
||||
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
externalServiceSlug
|
||||
) {
|
||||
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
externalServiceSlug;
|
||||
changedExisting = true;
|
||||
if (
|
||||
externalServiceSlug !== undefined &&
|
||||
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
externalServiceSlug
|
||||
) {
|
||||
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
externalServiceSlug;
|
||||
changedExisting = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
@@ -512,6 +528,9 @@ class BaseScanner<T> {
|
||||
seasons4kForRollup.length > 0 &&
|
||||
seasons4kForRollup.every((s) => s.status4k === MediaStatus.AVAILABLE);
|
||||
|
||||
const previousStatus = media.status;
|
||||
const previousStatus4k = media.status4k;
|
||||
|
||||
media.status = isAllStandardSeasonsAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
@@ -548,6 +567,20 @@ class BaseScanner<T> {
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
|
||||
if (
|
||||
previousStatus === MediaStatus.PROCESSING &&
|
||||
media.status === MediaStatus.UNKNOWN
|
||||
) {
|
||||
this.recordStatusReset(media.id, false);
|
||||
}
|
||||
|
||||
if (
|
||||
previousStatus4k === MediaStatus.PROCESSING &&
|
||||
media.status4k === MediaStatus.UNKNOWN
|
||||
) {
|
||||
this.recordStatusReset(media.id, true);
|
||||
}
|
||||
} else {
|
||||
// For new media, check actual newSeasons objects instead of scanner
|
||||
// input to determine overall availability status
|
||||
@@ -656,7 +689,8 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async declineOrphanedRequests(
|
||||
media: Media,
|
||||
is4k: boolean
|
||||
is4k: boolean,
|
||||
reason = 'not found in any Sonarr/Radarr server'
|
||||
): Promise<void> {
|
||||
if (media.requests === undefined) {
|
||||
throw new Error(
|
||||
@@ -680,12 +714,54 @@ class BaseScanner<T> {
|
||||
this.log(
|
||||
`Declined orphaned ${
|
||||
media.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request ${request.id} for ${media.tmdbId} not found in any Sonarr/Radarr server.`,
|
||||
} request ${request.id} for ${media.tmdbId} ${reason}.`,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private recordStatusReset(mediaId: number, is4k: boolean): void {
|
||||
if (!this.declineRequestsOnStatusReset) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusResetCandidates.set(`${mediaId}:${is4k}`, { mediaId, is4k });
|
||||
}
|
||||
|
||||
// Runs once the caller can answer for every server, not just the current one.
|
||||
protected async resolveStatusResets(
|
||||
isAbandoned: (media: Media, is4k: boolean) => boolean
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
for (const { mediaId, is4k } of this.statusResetCandidates.values()) {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: mediaId },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media || !isAbandoned(media, is4k)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const statusField = is4k ? 'status4k' : 'status';
|
||||
|
||||
if (media[statusField] === MediaStatus.PROCESSING) {
|
||||
media[statusField] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
} else if (media[statusField] !== MediaStatus.UNKNOWN) {
|
||||
// Something finished or deleted it mid-run; it is no longer ours to reset.
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.declineOrphanedRequests(
|
||||
media,
|
||||
is4k,
|
||||
'reset to UNKNOWN after the Sonarr/Radarr entry went unmonitored with nothing downloaded'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call startRun from child class whenever a run is starting to
|
||||
* ensure required values are set
|
||||
@@ -696,6 +772,7 @@ class BaseScanner<T> {
|
||||
const settings = getSettings();
|
||||
const sessionId = randomUUID();
|
||||
this.sessionId = sessionId;
|
||||
this.statusResetCandidates.clear();
|
||||
|
||||
this.log('Scan starting', 'info', { sessionId });
|
||||
|
||||
|
||||
@@ -9,10 +9,15 @@ import type {
|
||||
TmdbTvDetails,
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import Season from '@server/entity/Season';
|
||||
import { User } from '@server/entity/User';
|
||||
import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
|
||||
@@ -262,6 +267,80 @@ describe('Jellyfin Scanner', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('in-flight request handling', () => {
|
||||
it('leaves an approved request alone when the show has no episodes yet', async () => {
|
||||
configureJellyfinWithLibrary();
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const requestedBy = await userRepository.findOneOrFail({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const media = await mediaRepository.save(
|
||||
new Media({
|
||||
tmdbId: 6000,
|
||||
mediaType: MediaType.TV,
|
||||
status: MediaStatus.PROCESSING,
|
||||
jellyfinMediaId: 'jf-inflight-show-id',
|
||||
seasons: [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.PROCESSING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const settings = getSettings();
|
||||
settings.radarr = [];
|
||||
settings.sonarr = [];
|
||||
const request = await requestRepository.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
})
|
||||
);
|
||||
|
||||
getTvShowImpl = async () => fakeTmdbShow(6000);
|
||||
|
||||
getLibraryContentsImpl = async (id: string) =>
|
||||
id === 'test-library-id'
|
||||
? [fakeJellyfinSeriesItem('jf-inflight-show-id')]
|
||||
: [];
|
||||
|
||||
getItemDataImpl = async (id: string) =>
|
||||
id === 'jf-inflight-show-id'
|
||||
? fakeJellyfinShowMetadata('jf-inflight-show-id', '6000')
|
||||
: undefined;
|
||||
|
||||
getSeasonsImpl = async (seriesID: string) =>
|
||||
seriesID === 'jf-inflight-show-id'
|
||||
? [fakeJellyfinSeason(1, 'jf-inflight-s1-id')]
|
||||
: [];
|
||||
|
||||
getEpisodesImpl = async () => [];
|
||||
|
||||
await runWithMockTimers(() => jellyfinFullScanner.run());
|
||||
|
||||
const updatedRequest = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
updatedRequest.status,
|
||||
MediaRequestStatus.APPROVED,
|
||||
'A media server scan must not cancel a request Sonarr is still working on'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty TMDB season handling', () => {
|
||||
it('should mark show as available when all non-empty TMDB seasons are fully scanned and an empty placeholder season exists in the DB', async () => {
|
||||
configureJellyfinWithLibrary();
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import type {
|
||||
PlexLibrary,
|
||||
PlexLibraryItem,
|
||||
PlexMetadata,
|
||||
} from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbTvDetails,
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
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 { plexFullScanner } from '@server/lib/scanners/plex';
|
||||
import type { Library } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import { runWithMockTimers } from '@server/test/runWithMockTimers';
|
||||
import assert from 'node:assert/strict';
|
||||
import { beforeEach, describe, it, mock } from 'node:test';
|
||||
|
||||
Object.defineProperty(animeList, 'sync', {
|
||||
value: async () => {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
let getLibrariesImpl: () => Promise<PlexLibrary[]> = async () => [];
|
||||
let getLibraryContentsImpl: (
|
||||
id: string
|
||||
) => Promise<{ totalSize: number; items: PlexLibraryItem[] }> = async () => ({
|
||||
totalSize: 0,
|
||||
items: [],
|
||||
});
|
||||
let getMetadataImpl: (
|
||||
key: string
|
||||
) => Promise<PlexMetadata | undefined> = async () => undefined;
|
||||
let getChildrenMetadataImpl: (
|
||||
key: string
|
||||
) => Promise<PlexMetadata[]> = async () => [];
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getLibraries', {
|
||||
get() {
|
||||
return async () => getLibrariesImpl();
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getLibraryContents', {
|
||||
get() {
|
||||
return async (id: string) => getLibraryContentsImpl(id);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getMetadata', {
|
||||
get() {
|
||||
return async (key: string) => getMetadataImpl(key);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getChildrenMetadata', {
|
||||
get() {
|
||||
return async (key: string) => getChildrenMetadataImpl(key);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let getTvShowImpl: (args: {
|
||||
tvId: number;
|
||||
}) => Promise<TmdbTvDetails> = async () => fakeTmdbShow(1);
|
||||
|
||||
for (const method of ['getTvShow', 'getTvShowForScan'] as const) {
|
||||
Object.defineProperty(TheMovieDb.prototype, method, {
|
||||
get() {
|
||||
return async (args: { tvId: number }) => getTvShowImpl(args);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// plexFullScanner built its own TheMovieDb on import, before the stubs above.
|
||||
Object.defineProperty(plexFullScanner.tmdb, method, {
|
||||
value: async (args: { tvId: number }) => getTvShowImpl(args),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
mock.method(MediaRequest, 'sendNotification', async () => undefined);
|
||||
|
||||
setupTestDb();
|
||||
|
||||
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: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlexShowItem(ratingKey: string): PlexLibraryItem {
|
||||
return {
|
||||
ratingKey,
|
||||
title: 'Test Show',
|
||||
guid: 'plex://show/test-show',
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000000,
|
||||
type: 'show',
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlexShowMetadata(
|
||||
ratingKey: string,
|
||||
tmdbId: number,
|
||||
children: PlexMetadata[]
|
||||
): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
title: 'Test Show',
|
||||
guid: 'plex://show/test-show',
|
||||
type: 'show',
|
||||
Guid: [{ id: `tmdb://${tmdbId}` }],
|
||||
Children: { size: 12, Metadata: children },
|
||||
index: 1,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000000,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlexSeasonMetadata(
|
||||
seasonNumber: number,
|
||||
ratingKey: string
|
||||
): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
title: `Season ${seasonNumber}`,
|
||||
guid: `plex://season/test-season-${seasonNumber}`,
|
||||
type: 'season',
|
||||
Guid: [],
|
||||
index: seasonNumber,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000000,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
function configurePlexWithLibrary(
|
||||
libraries: Library[] = [
|
||||
{ id: 'test-library-id', name: 'TV Shows', enabled: true, type: 'show' },
|
||||
]
|
||||
): void {
|
||||
const settings = getSettings();
|
||||
settings.main.mediaServerType = MediaServerType.PLEX;
|
||||
settings.plex = {
|
||||
...settings.plex,
|
||||
ip: '127.0.0.1',
|
||||
port: 32400,
|
||||
libraries,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Plex Scanner', () => {
|
||||
beforeEach(() => {
|
||||
getLibrariesImpl = async () => [
|
||||
{
|
||||
key: 'test-library-id',
|
||||
title: 'TV Shows',
|
||||
type: 'show',
|
||||
agent: 'tv.plex.agents.series',
|
||||
},
|
||||
];
|
||||
getLibraryContentsImpl = async () => ({ totalSize: 0, items: [] });
|
||||
getMetadataImpl = async () => undefined;
|
||||
getChildrenMetadataImpl = async () => [];
|
||||
getTvShowImpl = async () => fakeTmdbShow(1);
|
||||
});
|
||||
|
||||
describe('in-flight request handling', () => {
|
||||
it('leaves an approved request alone when the show has no episodes yet', async () => {
|
||||
configurePlexWithLibrary();
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const requestedBy = await userRepository.findOneOrFail({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const media = await mediaRepository.save(
|
||||
new Media({
|
||||
tmdbId: 6100,
|
||||
mediaType: MediaType.TV,
|
||||
status: MediaStatus.PROCESSING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
ratingKey: 'plex-inflight-show-key',
|
||||
seasons: [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.PROCESSING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// sendToSonarr runs on insert and only bails when no server is configured,
|
||||
// so leftover settings from another test would reach TMDB and fail the request.
|
||||
const settings = getSettings();
|
||||
settings.radarr = [];
|
||||
settings.sonarr = [];
|
||||
|
||||
const request = await requestRepository.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
seasons: [
|
||||
new SeasonRequest({
|
||||
seasonNumber: 1,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
getTvShowImpl = async () => fakeTmdbShow(6100);
|
||||
|
||||
getLibraryContentsImpl = async (id: string) =>
|
||||
id === 'test-library-id'
|
||||
? {
|
||||
totalSize: 1,
|
||||
items: [fakePlexShowItem('plex-inflight-show-key')],
|
||||
}
|
||||
: { totalSize: 0, items: [] };
|
||||
|
||||
getMetadataImpl = async (key: string) =>
|
||||
key === 'plex-inflight-show-key'
|
||||
? fakePlexShowMetadata('plex-inflight-show-key', 6100, [
|
||||
fakePlexSeasonMetadata(1, 'plex-inflight-s1-key'),
|
||||
])
|
||||
: undefined;
|
||||
|
||||
getChildrenMetadataImpl = async () => [];
|
||||
|
||||
await runWithMockTimers(() => plexFullScanner.run());
|
||||
|
||||
const updatedRequest = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
updatedRequest.status,
|
||||
MediaRequestStatus.APPROVED,
|
||||
'A media server scan must not cancel a request Sonarr is still working on'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,15 @@ class RadarrScanner
|
||||
extends BaseScanner<RadarrMovie>
|
||||
implements RunnableScanner<SyncStatus>
|
||||
{
|
||||
protected declineRequestsOnStatusReset = true;
|
||||
private servers: RadarrSettings[];
|
||||
private currentServer: RadarrSettings;
|
||||
private radarrApi: RadarrAPI;
|
||||
private scannedTmdbIds: Set<number> = new Set();
|
||||
private scanned4kTmdbIds: Set<number> = new Set();
|
||||
// Distinct from the scanned sets, which also include unmonitored titles.
|
||||
private processingTmdbIds: Set<number> = new Set();
|
||||
private processing4kTmdbIds: Set<number> = new Set();
|
||||
private didScanStandard = false;
|
||||
private didScan4k = false;
|
||||
private serverReturnedEmpty = false;
|
||||
@@ -50,6 +54,8 @@ class RadarrScanner
|
||||
const sessionId = this.startRun();
|
||||
this.scannedTmdbIds.clear();
|
||||
this.scanned4kTmdbIds.clear();
|
||||
this.processingTmdbIds.clear();
|
||||
this.processing4kTmdbIds.clear();
|
||||
this.didScanStandard = false;
|
||||
this.didScan4k = false;
|
||||
this.serverReturnedEmpty = false;
|
||||
@@ -129,6 +135,15 @@ class RadarrScanner
|
||||
this.didScan4k = false;
|
||||
}
|
||||
|
||||
await this.resolveStatusResets((media, is4k) => {
|
||||
const scanComplete = is4k ? this.didScan4k : this.didScanStandard;
|
||||
const processingIds = is4k
|
||||
? this.processing4kTmdbIds
|
||||
: this.processingTmdbIds;
|
||||
|
||||
return scanComplete && !processingIds.has(media.tmdbId);
|
||||
});
|
||||
|
||||
await this.cleanupOrphanedMovies();
|
||||
this.log('Radarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
@@ -146,6 +161,15 @@ class RadarrScanner
|
||||
this.scannedTmdbIds.add(radarrMovie.tmdbId);
|
||||
}
|
||||
|
||||
const processing = !radarrMovie.hasFile && radarrMovie.monitored;
|
||||
if (processing) {
|
||||
if (server4k) {
|
||||
this.processing4kTmdbIds.add(radarrMovie.tmdbId);
|
||||
} else {
|
||||
this.processingTmdbIds.add(radarrMovie.tmdbId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.processMovie(radarrMovie.tmdbId, {
|
||||
is4k: server4k,
|
||||
@@ -153,7 +177,7 @@ class RadarrScanner
|
||||
externalServiceId: radarrMovie.id,
|
||||
externalServiceSlug: radarrMovie.titleSlug,
|
||||
title: radarrMovie.title,
|
||||
processing: !radarrMovie.hasFile && radarrMovie.monitored,
|
||||
processing,
|
||||
hasFile: radarrMovie.hasFile,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -114,6 +114,50 @@ describe('Radarr Scanner', () => {
|
||||
assert.strictEqual(updated.status, MediaStatus.UNKNOWN);
|
||||
});
|
||||
|
||||
it('declines the attached request so the title can be requested again', async () => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const requestedBy = await userRepository.findOneOrFail({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const media = await mediaRepository.save(
|
||||
new Media({
|
||||
tmdbId: 551,
|
||||
mediaType: MediaType.MOVIE,
|
||||
status: MediaStatus.PROCESSING,
|
||||
})
|
||||
);
|
||||
|
||||
const settings = getSettings();
|
||||
settings.radarr = [];
|
||||
settings.sonarr = [];
|
||||
const request = await requestRepository.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
})
|
||||
);
|
||||
|
||||
configureRadarr([{ syncEnabled: true }]);
|
||||
getMoviesImpl = async () => [
|
||||
fakeRadarrMovie({ tmdbId: 551, monitored: false, hasFile: false }),
|
||||
];
|
||||
|
||||
await runWithMockTimers(() => radarrScanner.run());
|
||||
|
||||
const updatedRequest = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updatedRequest.status, MediaRequestStatus.DECLINED);
|
||||
});
|
||||
|
||||
it('does not create new media entry when movie is unmonitored and has no file', async () => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
configureRadarr([{ syncEnabled: true }]);
|
||||
@@ -722,4 +766,146 @@ describe('Radarr Scanner', () => {
|
||||
assert.strictEqual(updated4k.status, MediaRequestStatus.DECLINED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-server reset handling', () => {
|
||||
async function seedProcessingRequest(tmdbId: number, serverId: number) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const requestedBy = await userRepository.findOneOrFail({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const media = await mediaRepository.save(
|
||||
new Media({
|
||||
tmdbId,
|
||||
mediaType: MediaType.MOVIE,
|
||||
status: MediaStatus.PROCESSING,
|
||||
serviceId: serverId,
|
||||
externalServiceId: 200,
|
||||
})
|
||||
);
|
||||
|
||||
const settings = getSettings();
|
||||
settings.radarr = [];
|
||||
settings.sonarr = [];
|
||||
|
||||
return requestRepository.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.MOVIE,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
serverId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// getMovies takes no server argument and is called once per server.
|
||||
function queueServerResponses(responses: RadarrMovie[][]): void {
|
||||
let call = 0;
|
||||
getMoviesImpl = async () => responses[call++] ?? [];
|
||||
}
|
||||
|
||||
// Distinct hostnames, or run()'s uniqWith collapses the two into one server.
|
||||
function configureTwoServers(): void {
|
||||
configureRadarr([
|
||||
{ id: 0, hostname: 'server-a' },
|
||||
{ id: 1, hostname: 'server-b' },
|
||||
]);
|
||||
}
|
||||
|
||||
const abandonedEntry = fakeRadarrMovie({
|
||||
tmdbId: 552,
|
||||
id: 100,
|
||||
titleSlug: 'abandoned-entry',
|
||||
monitored: false,
|
||||
hasFile: false,
|
||||
});
|
||||
|
||||
const downloadingEntry = fakeRadarrMovie({
|
||||
tmdbId: 552,
|
||||
id: 200,
|
||||
titleSlug: 'downloading-entry',
|
||||
monitored: true,
|
||||
hasFile: false,
|
||||
});
|
||||
|
||||
it('leaves the row untouched when the abandoned entry is seen first', async () => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedProcessingRequest(552, 1);
|
||||
|
||||
configureTwoServers();
|
||||
queueServerResponses([[abandonedEntry], [downloadingEntry]]);
|
||||
getLibraryMoviesByTmdbIdImpl = async () => [downloadingEntry];
|
||||
|
||||
await runWithMockTimers(() => radarrScanner.run());
|
||||
|
||||
const updatedMedia = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 552 },
|
||||
});
|
||||
const updatedRequest = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updatedMedia.status, MediaStatus.PROCESSING);
|
||||
assert.strictEqual(updatedMedia.serviceId, 1);
|
||||
assert.strictEqual(updatedMedia.externalServiceId, 200);
|
||||
assert.strictEqual(updatedRequest.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('leaves the row untouched when the abandoned entry is seen second', async () => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedProcessingRequest(552, 1);
|
||||
|
||||
configureTwoServers();
|
||||
queueServerResponses([[downloadingEntry], [abandonedEntry]]);
|
||||
getLibraryMoviesByTmdbIdImpl = async () => [downloadingEntry];
|
||||
|
||||
await runWithMockTimers(() => radarrScanner.run());
|
||||
|
||||
const updatedMedia = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 552 },
|
||||
});
|
||||
const updatedRequest = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updatedMedia.status, MediaStatus.PROCESSING);
|
||||
assert.strictEqual(updatedMedia.serviceId, 0);
|
||||
assert.strictEqual(updatedMedia.externalServiceId, 200);
|
||||
assert.strictEqual(updatedRequest.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('leaves the row untouched when a server has sync disabled', async () => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedProcessingRequest(552, 1);
|
||||
|
||||
configureRadarr([
|
||||
{ id: 0, hostname: 'server-a' },
|
||||
{ id: 1, hostname: 'server-b', syncEnabled: false },
|
||||
]);
|
||||
queueServerResponses([[abandonedEntry]]);
|
||||
getLibraryMoviesByTmdbIdImpl = async () => [abandonedEntry];
|
||||
|
||||
await runWithMockTimers(() => radarrScanner.run());
|
||||
|
||||
const updatedMedia = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 552 },
|
||||
});
|
||||
const updatedRequest = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updatedMedia.status, MediaStatus.PROCESSING);
|
||||
assert.strictEqual(updatedMedia.serviceId, 1);
|
||||
assert.strictEqual(updatedMedia.externalServiceId, 200);
|
||||
assert.strictEqual(updatedRequest.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,11 +30,15 @@ class SonarrScanner
|
||||
extends BaseScanner<SonarrSeries>
|
||||
implements RunnableScanner<SyncStatus>
|
||||
{
|
||||
protected declineRequestsOnStatusReset = true;
|
||||
private servers: SonarrSettings[];
|
||||
private currentServer: SonarrSettings;
|
||||
private sonarrApi: SonarrAPI;
|
||||
private scannedTvdbIds: Set<number> = new Set();
|
||||
private scanned4kTvdbIds: Set<number> = new Set();
|
||||
// Keyed on tmdbId: media.tvdbId can be null. Excludes unmonitored titles.
|
||||
private processingTmdbIds: Set<number> = new Set();
|
||||
private processing4kTmdbIds: Set<number> = new Set();
|
||||
private didScanStandard = false;
|
||||
private didScan4k = false;
|
||||
private serverReturnedEmpty = false;
|
||||
@@ -59,6 +63,8 @@ class SonarrScanner
|
||||
const sessionId = this.startRun();
|
||||
this.scannedTvdbIds.clear();
|
||||
this.scanned4kTvdbIds.clear();
|
||||
this.processingTmdbIds.clear();
|
||||
this.processing4kTmdbIds.clear();
|
||||
this.didScanStandard = false;
|
||||
this.didScan4k = false;
|
||||
this.serverReturnedEmpty = false;
|
||||
@@ -138,6 +144,15 @@ class SonarrScanner
|
||||
this.didScan4k = false;
|
||||
}
|
||||
|
||||
await this.resolveStatusResets((media, is4k) => {
|
||||
const scanComplete = is4k ? this.didScan4k : this.didScanStandard;
|
||||
const processingIds = is4k
|
||||
? this.processing4kTmdbIds
|
||||
: this.processingTmdbIds;
|
||||
|
||||
return scanComplete && !processingIds.has(media.tmdbId);
|
||||
});
|
||||
|
||||
await this.cleanupOrphanedShows();
|
||||
this.log('Sonarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
@@ -221,6 +236,14 @@ class SonarrScanner
|
||||
});
|
||||
}
|
||||
|
||||
if (processableSeasons.some((season) => season.processing)) {
|
||||
if (server4k) {
|
||||
this.processing4kTmdbIds.add(tmdbId);
|
||||
} else {
|
||||
this.processingTmdbIds.add(tmdbId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, {
|
||||
serviceId: this.currentServer.id,
|
||||
externalServiceId: sonarrSeries.id,
|
||||
|
||||
@@ -931,4 +931,132 @@ describe('Sonarr Scanner', () => {
|
||||
assert.strictEqual(updated4k.status, MediaRequestStatus.DECLINED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-server reset handling', () => {
|
||||
async function seedProcessingRequest(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
serverId: number
|
||||
) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const requestedBy = await userRepository.findOneOrFail({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const media = await mediaRepository.save(
|
||||
new Media({
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
mediaType: MediaType.TV,
|
||||
status: MediaStatus.PROCESSING,
|
||||
serviceId: serverId,
|
||||
externalServiceId: 200,
|
||||
seasons: [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.PROCESSING,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const settings = getSettings();
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
|
||||
return requestRepository.save(
|
||||
new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
media,
|
||||
requestedBy,
|
||||
is4k: false,
|
||||
serverId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// getSeries takes no server argument and is called once per server.
|
||||
function queueServerResponses(responses: SonarrSeries[][]): void {
|
||||
let call = 0;
|
||||
getSeriesImpl = async () => responses[call++] ?? [];
|
||||
}
|
||||
|
||||
// Distinct hostnames, or run()'s uniqWith collapses the two into one server.
|
||||
function configureTwoServers(): void {
|
||||
configureSonarr([
|
||||
{ id: 0, hostname: 'server-a' },
|
||||
{ id: 1, hostname: 'server-b' },
|
||||
]);
|
||||
}
|
||||
|
||||
function seriesWithEmptySeason(
|
||||
id: number,
|
||||
titleSlug: string,
|
||||
monitored: boolean
|
||||
): SonarrSeries {
|
||||
return fakeSonarrSeries({
|
||||
tvdbId: 700,
|
||||
id,
|
||||
titleSlug,
|
||||
seasons: [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
monitored,
|
||||
statistics: {
|
||||
episodeFileCount: 0,
|
||||
totalEpisodeCount: 10,
|
||||
episodeCount: 10,
|
||||
percentOfEpisodes: 0,
|
||||
sizeOnDisk: 0,
|
||||
previousAiring: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const abandonedOnA = seriesWithEmptySeason(100, 'abandoned-on-a', false);
|
||||
const downloadingOnB = seriesWithEmptySeason(200, 'downloading-on-b', true);
|
||||
|
||||
it('keeps the request approved when another server is still downloading, scanning A then B', async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedProcessingRequest(2100, 700, 1);
|
||||
|
||||
configureTwoServers();
|
||||
getTvShowImpl = async () => fakeTmdbShow(2100);
|
||||
queueServerResponses([[abandonedOnA], [downloadingOnB]]);
|
||||
getLibrarySeriesByTvdbIdImpl = async () => [downloadingOnB];
|
||||
|
||||
await runWithMockTimers(() => sonarrScanner.run());
|
||||
|
||||
const updated = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('keeps the request approved when another server is still downloading, scanning B then A', async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const request = await seedProcessingRequest(2100, 700, 1);
|
||||
|
||||
configureTwoServers();
|
||||
getTvShowImpl = async () => fakeTmdbShow(2100);
|
||||
queueServerResponses([[downloadingOnB], [abandonedOnA]]);
|
||||
getLibrarySeriesByTvdbIdImpl = async () => [downloadingOnB];
|
||||
|
||||
await runWithMockTimers(() => sonarrScanner.run());
|
||||
|
||||
const updated = await requestRepository.findOneOrFail({
|
||||
where: { id: request.id },
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.status, MediaRequestStatus.APPROVED);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user