mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
added e2e for sonarr search modes
This commit is contained in:
1 parent
fe184daa56
commit
570895f6d5
1 file changed
+183
@@ -0,0 +1,183 @@
|
||||
import { rmSync, truncateSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { test, expect } from '../fixtures/base';
|
||||
import type { CleanuparrApi } from '../helpers/api';
|
||||
import { indexerMock, type LiveArrCommand } from '../helpers/live-arr';
|
||||
import {
|
||||
SONARR,
|
||||
arrangeInstance,
|
||||
listSearchEvents,
|
||||
resetLiveArrState,
|
||||
restoreLibrary,
|
||||
snapshotLibrary,
|
||||
teardownInstances,
|
||||
triggerSeeker,
|
||||
} from '../helpers/seeker-live';
|
||||
import { torznabSearchStub } from '../helpers/mocks/torznab-stubs';
|
||||
import { mkdirShared, writeFileShared } from '../helpers/shared-volume';
|
||||
|
||||
/**
|
||||
* How the Sonarr search mode picks between a season search and an episode search.
|
||||
*
|
||||
* The seeded season has nine aired monitored episodes and no files.
|
||||
*/
|
||||
|
||||
const SETTLE_MS = 20_000;
|
||||
|
||||
/** A file on S01E01 leaves the season partly wanted. */
|
||||
const IMPORTED_EPISODE_FILE = 'Agatha All Along - S01E01 - WEBDL-1080p.mkv';
|
||||
|
||||
/** The arr ignores a file small enough to look like a sample. */
|
||||
const FILE_SIZE_BYTES = 300 * 1024 * 1024;
|
||||
|
||||
const TV_DIR = resolve(__dirname, '..', '..', 'test-data', 'sonarr-tv');
|
||||
|
||||
interface Episode {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
hasFile: boolean;
|
||||
episodeFileId: number;
|
||||
monitored: boolean;
|
||||
}
|
||||
|
||||
async function searchCommandsAfter(lastId: number): Promise<LiveArrCommand[]> {
|
||||
const commands = await SONARR.arr.commands();
|
||||
return commands.filter((c) => c.id > lastId && (c.name === 'EpisodeSearch' || c.name === 'SeasonSearch'));
|
||||
}
|
||||
|
||||
async function lastCommandId(): Promise<number> {
|
||||
const commands = await SONARR.arr.commands();
|
||||
return Math.max(0, ...commands.map((c) => c.id));
|
||||
}
|
||||
|
||||
async function listEpisodes(): Promise<Episode[]> {
|
||||
return SONARR.arr.get<Episode[]>(`/api/v3/episode?seriesId=${SONARR.itemId}`);
|
||||
}
|
||||
|
||||
function findEpisode(episodes: Episode[], episodeNumber: number): Episode {
|
||||
const episode = episodes.find((e) => e.seasonNumber === 1 && e.episodeNumber === episodeNumber);
|
||||
expect(episode, `the seed lost S01E${episodeNumber}`).toBeTruthy();
|
||||
return episode!;
|
||||
}
|
||||
|
||||
async function seasonFolder(): Promise<string> {
|
||||
const series = await SONARR.arr.get<{ path: string }>(`/api/v3/series/${SONARR.itemId}`);
|
||||
return join(TV_DIR, series.path.replace(/^\/tv\/?/, ''), 'Season 01');
|
||||
}
|
||||
|
||||
/** Gives S01E01 a real file, so the arr counts it in the season but no longer wants it. */
|
||||
async function importFirstEpisode(): Promise<void> {
|
||||
const folder = await seasonFolder();
|
||||
mkdirShared(folder);
|
||||
const file = join(folder, IMPORTED_EPISODE_FILE);
|
||||
writeFileShared(file, Buffer.alloc(0));
|
||||
truncateSync(file, FILE_SIZE_BYTES);
|
||||
|
||||
await SONARR.arr.post('/api/v3/command', { name: 'RescanSeries', seriesId: SONARR.itemId });
|
||||
|
||||
await expect
|
||||
.poll(async () => findEpisode(await listEpisodes(), 1).hasFile, { timeout: 90_000 })
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/** Runs even when no test imported anything, because a half-done import still leaves a file. */
|
||||
async function dropImportedEpisode(): Promise<void> {
|
||||
const first = findEpisode(await listEpisodes(), 1);
|
||||
|
||||
if (first.hasFile) {
|
||||
await SONARR.arr.delete(`/api/v3/episodefile/${first.episodeFileId}`);
|
||||
}
|
||||
|
||||
rmSync(await seasonFolder(), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Sonarr hands back an identical SeasonSearch still running instead of queueing a new one. */
|
||||
async function waitForIdleSonarr(): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await SONARR.arr.commands()).filter((c) => c.status === 'queued' || c.status === 'started').length, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
/** Runs the Seeker once and returns the one search command it sent. */
|
||||
async function searchOnce(api: CleanuparrApi, instanceId: string): Promise<LiveArrCommand> {
|
||||
const before = await lastCommandId();
|
||||
|
||||
await triggerSeeker(api);
|
||||
await expect.poll(async () => (await listSearchEvents(api, instanceId)).length, { timeout: SETTLE_MS }).toBe(1);
|
||||
|
||||
await expect.poll(async () => (await searchCommandsAfter(before)).length, { timeout: SETTLE_MS }).toBe(1);
|
||||
return (await searchCommandsAfter(before))[0];
|
||||
}
|
||||
|
||||
test.describe('Seeker Sonarr search mode', () => {
|
||||
let library: Array<Record<string, unknown>>;
|
||||
let episodes: Episode[];
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await waitForIdleSonarr();
|
||||
await resetLiveArrState();
|
||||
await indexerMock.stub(torznabSearchStub(SONARR.searchMode, []));
|
||||
library = await snapshotLibrary(SONARR);
|
||||
episodes = await listEpisodes();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ api }) => {
|
||||
await teardownInstances(api);
|
||||
await dropImportedEpisode();
|
||||
for (const monitored of [true, false]) {
|
||||
const episodeIds = episodes.filter((e) => e.monitored === monitored).map((e) => e.id);
|
||||
if (episodeIds.length > 0) {
|
||||
await SONARR.arr.put('/api/v3/episode/monitor', { episodeIds, monitored });
|
||||
}
|
||||
}
|
||||
await restoreLibrary(SONARR, library);
|
||||
await resetLiveArrState();
|
||||
});
|
||||
|
||||
test('auto searches a partly wanted season one episode at a time', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
await importFirstEpisode();
|
||||
|
||||
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Auto' } });
|
||||
const command = await searchOnce(api, instanceId);
|
||||
|
||||
expect(command.name).toBe('EpisodeSearch');
|
||||
expect(command.body?.episodeIds).toEqual([findEpisode(episodes, 2).id]);
|
||||
});
|
||||
|
||||
test('auto searches a fully wanted aired season as a whole', async ({ api }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Auto' } });
|
||||
const command = await searchOnce(api, instanceId);
|
||||
|
||||
expect(command.name).toBe('SeasonSearch');
|
||||
expect(command.body?.seasonNumber).toBe(1);
|
||||
});
|
||||
|
||||
test('season mode searches a partly wanted season as a whole', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
await importFirstEpisode();
|
||||
|
||||
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Season' } });
|
||||
const command = await searchOnce(api, instanceId);
|
||||
|
||||
expect(command.name).toBe('SeasonSearch');
|
||||
expect(command.body?.seasonNumber).toBe(1);
|
||||
});
|
||||
|
||||
test('episode mode searches a fully wanted season one episode at a time', async ({ api }) => {
|
||||
test.setTimeout(180_000);
|
||||
// S01E01's id equals the series id, which would hide an id mix-up.
|
||||
await SONARR.arr.put('/api/v3/episode/monitor', { episodeIds: [findEpisode(episodes, 1).id], monitored: false });
|
||||
|
||||
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Episode' } });
|
||||
const command = await searchOnce(api, instanceId);
|
||||
|
||||
expect(command.name).toBe('EpisodeSearch');
|
||||
expect(command.body?.episodeIds).toEqual([findEpisode(episodes, 2).id]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user