Files
seerr/server/lib/scanners/sonarr/sonarr.test.ts

523 lines
15 KiB
TypeScript

import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbTvDetails,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import type { SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { setupTestDb } from '@server/test/db';
import assert from 'node:assert/strict';
import { beforeEach, describe, it } from 'node:test';
let getSeriesImpl: () => Promise<SonarrSeries[]> = async () => [];
Object.defineProperty(SonarrAPI.prototype, 'getSeries', {
set() {},
get() {
return async () => getSeriesImpl();
},
configurable: true,
});
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: 1,
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: [] },
};
}
let getShowByTvdbIdImpl: (args: {
tvdbId: number;
language?: string;
}) => Promise<TmdbTvDetails> = async () => fakeTmdbShow(1);
TheMovieDb.prototype.getShowByTvdbId = async function (args) {
return getShowByTvdbIdImpl(args);
};
let getTvShowImpl: (args: {
tvId: number;
language?: string;
}) => Promise<TmdbTvDetails> = async () => fakeTmdbShow(1);
Object.defineProperty(TheMovieDb.prototype, 'getTvShow', {
set() {},
get() {
return async (args: { tvId: number; language?: string }) =>
getTvShowImpl(args);
},
configurable: true,
});
import { sonarrScanner } from '@server/lib/scanners/sonarr';
setupTestDb();
function fakeSonarrSeries(overrides: Partial<SonarrSeries> = {}): SonarrSeries {
return {
tvdbId: 100,
id: 1,
title: 'Test Show',
titleSlug: 'test-show',
monitored: true,
seasons: [
{
seasonNumber: 1,
monitored: true,
statistics: {
episodeFileCount: 10,
totalEpisodeCount: 10,
episodeCount: 10,
percentOfEpisodes: 100,
sizeOnDisk: 0,
previousAiring: undefined,
},
},
],
...overrides,
} as SonarrSeries;
}
function configureSonarr(overrides: Partial<SonarrSettings>[] = [{}]): void {
const settings = getSettings();
settings.sonarr = overrides.map((o, i) => ({
id: i,
name: `Sonarr ${i}`,
hostname: 'localhost',
port: 8989,
apiKey: 'test-key',
baseUrl: '',
useSsl: false,
activeProfileId: 1,
activeDirectory: '/tv',
activeLanguageProfileId: 1,
activeAnimeProfileId: undefined,
activeAnimeDirectory: '',
activeAnimeLanguageProfileId: undefined,
animeTags: [],
is4k: false,
enableSeasonFolders: true,
tags: [],
isDefault: i === 0,
syncEnabled: true,
preventSearch: false,
externalUrl: '',
...o,
})) as SonarrSettings[];
settings.radarr = [];
}
describe('Sonarr Scanner', () => {
beforeEach(() => {
getSeriesImpl = async () => [];
getShowByTvdbIdImpl = async () => fakeTmdbShow(1);
getTvShowImpl = async () => fakeTmdbShow(1);
});
describe('orphaned show cleanup', () => {
it('skips cleanup when a standard server has sync disabled', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1050;
media.tvdbId = 550;
media.mediaType = MediaType.TV;
media.status = MediaStatus.PROCESSING;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.PROCESSING,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
configureSonarr([
{ syncEnabled: true, id: 0, hostname: 'server-a' },
{ syncEnabled: false, id: 1, hostname: 'server-b' },
]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1050 },
relations: ['seasons'],
});
assert.strictEqual(updated.status, MediaStatus.PROCESSING);
assert.strictEqual(updated.seasons[0].status, MediaStatus.PROCESSING);
});
it('resets PROCESSING to UNKNOWN when show is not in any Sonarr server', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1000;
media.tvdbId = 500;
media.mediaType = MediaType.TV;
media.status = MediaStatus.PROCESSING;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.PROCESSING,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1000 },
relations: ['seasons'],
});
assert.strictEqual(updated.status, MediaStatus.UNKNOWN);
assert.strictEqual(updated.seasons[0].status, MediaStatus.UNKNOWN);
});
it('does not reset AVAILABLE show when missing from Sonarr', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1001;
media.tvdbId = 501;
media.mediaType = MediaType.TV;
media.status = MediaStatus.AVAILABLE;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1001 },
relations: ['seasons'],
});
assert.strictEqual(updated.status, MediaStatus.AVAILABLE);
});
it('does not reset PROCESSING show that still exists in Sonarr', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1;
media.tvdbId = 200;
media.mediaType = MediaType.TV;
media.status = MediaStatus.PROCESSING;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.PROCESSING,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [
fakeSonarrSeries({
tvdbId: 200,
seasons: [
{
seasonNumber: 1,
monitored: true,
statistics: {
episodeFileCount: 0,
totalEpisodeCount: 10,
episodeCount: 10,
percentOfEpisodes: 0,
sizeOnDisk: 0,
previousAiring: undefined,
},
},
],
}),
];
getShowByTvdbIdImpl = async () => fakeTmdbShow(1);
getTvShowImpl = async () => fakeTmdbShow(1);
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1 },
relations: ['seasons'],
});
assert.strictEqual(updated.status, MediaStatus.PROCESSING);
});
it('only resets season statuses that are PROCESSING on orphaned shows', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1003;
media.tvdbId = 503;
media.mediaType = MediaType.TV;
media.status = MediaStatus.PROCESSING;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 2,
status: MediaStatus.PROCESSING,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1003 },
relations: ['seasons'],
});
assert.strictEqual(updated.status, MediaStatus.UNKNOWN);
const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
const s2 = updated.seasons.find((s) => s.seasonNumber === 2);
assert.strictEqual(s1?.status, MediaStatus.AVAILABLE);
assert.strictEqual(s2?.status, MediaStatus.UNKNOWN);
});
it('does not reset movie media that is missing from Sonarr', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1004;
media.mediaType = MediaType.MOVIE;
media.status = MediaStatus.PROCESSING;
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1004 },
});
assert.strictEqual(updated.status, MediaStatus.PROCESSING);
});
it('only resets orphaned shows not found across all servers', async () => {
const mediaRepository = getRepository(Media);
const orphan = new Media();
orphan.tmdbId = 1010;
orphan.tvdbId = 510;
orphan.mediaType = MediaType.TV;
orphan.status = MediaStatus.PROCESSING;
orphan.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.PROCESSING,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(orphan);
const existing = new Media();
existing.tmdbId = 2;
existing.tvdbId = 511;
existing.mediaType = MediaType.TV;
existing.status = MediaStatus.PROCESSING;
existing.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.PROCESSING,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(existing);
configureSonarr([
{ syncEnabled: true, id: 0, hostname: 'server-a' },
{ syncEnabled: true, id: 1, hostname: 'server-b' },
]);
let callCount = 0;
getSeriesImpl = async () => {
callCount++;
if (callCount === 2) {
return [fakeSonarrSeries({ tvdbId: 511 })];
}
return [];
};
getShowByTvdbIdImpl = async () => fakeTmdbShow(2);
getTvShowImpl = async () => fakeTmdbShow(2);
await sonarrScanner.run();
const updatedOrphan = await mediaRepository.findOneOrFail({
where: { tmdbId: 1010 },
relations: ['seasons'],
});
assert.strictEqual(updatedOrphan.status, MediaStatus.UNKNOWN);
const updatedExisting = await mediaRepository.findOneOrFail({
where: { tmdbId: 2 },
relations: ['seasons'],
});
assert.notStrictEqual(updatedExisting.status, MediaStatus.UNKNOWN);
});
it('skips shows without a tvdbId during cleanup', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1020;
media.mediaType = MediaType.TV;
media.status = MediaStatus.PROCESSING;
media.seasons = [];
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true }]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1020 },
});
assert.strictEqual(updated.status, MediaStatus.PROCESSING);
});
});
describe('4k orphaned show cleanup', () => {
it('resets 4k PROCESSING to UNKNOWN when show is not in any Sonarr server', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1030;
media.tvdbId = 530;
media.mediaType = MediaType.TV;
media.status = MediaStatus.UNKNOWN;
media.status4k = MediaStatus.PROCESSING;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.PROCESSING,
}),
];
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true, is4k: true }]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1030 },
relations: ['seasons'],
});
assert.strictEqual(updated.status4k, MediaStatus.UNKNOWN);
assert.strictEqual(updated.seasons[0].status4k, MediaStatus.UNKNOWN);
});
it('does not reset 4k AVAILABLE season when show is orphaned', async () => {
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1031;
media.tvdbId = 531;
media.mediaType = MediaType.TV;
media.status = MediaStatus.UNKNOWN;
media.status4k = MediaStatus.PROCESSING;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.AVAILABLE,
}),
new Season({
seasonNumber: 2,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.PROCESSING,
}),
];
await mediaRepository.save(media);
configureSonarr([{ syncEnabled: true, is4k: true }]);
getSeriesImpl = async () => [];
await sonarrScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1031 },
relations: ['seasons'],
});
const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
const s2 = updated.seasons.find((s) => s.seasonNumber === 2);
assert.strictEqual(s1?.status4k, MediaStatus.AVAILABLE);
assert.strictEqual(s2?.status4k, MediaStatus.UNKNOWN);
});
});
});