fix(scanners): decline request when media resets to UNKNOWN (#3578)

This commit is contained in:
fallenbagel
2026-10-10 07:18:54 +08:00
committed by GitHub
parent 9e2021b829
commit 8f2eceeaa5
8 changed files with 1092 additions and 34 deletions
+217
View File
@@ -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);
});
});
});
+109 -32
View File
@@ -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 });
+80 -1
View File
@@ -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();
+324
View File
@@ -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'
);
});
});
});
+25 -1
View File
@@ -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) {
+186
View File
@@ -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);
});
});
});
+23
View File
@@ -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,
+128
View File
@@ -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);
});
});
});