mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
added live e2e for upgrades-disabled profiles
This commit is contained in:
1 parent
edafb30282
commit
a66c9d56da
1 file changed
+200
-24
@@ -1,9 +1,11 @@
|
||||
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 } from '../helpers/live-arr';
|
||||
import {
|
||||
RADARR,
|
||||
SONARR,
|
||||
arrangeInstance,
|
||||
expectNoSearch,
|
||||
firstSearchEvent,
|
||||
@@ -18,22 +20,30 @@ import { torznabSearchStub } from '../helpers/mocks/torznab-stubs';
|
||||
import { mkdirShared, writeFileShared } from '../helpers/shared-volume';
|
||||
|
||||
/**
|
||||
* Searching an item that already has a file, because its quality is too low.
|
||||
* Searching an item that already has a file, because its quality or custom format score is too low.
|
||||
*
|
||||
* The file is real: it is written into the mount Radarr scans, and Radarr
|
||||
* The file is real: it is written into the mount the arr scans, and the arr
|
||||
* imports it and parses its quality from the name.
|
||||
*/
|
||||
|
||||
/** Radarr's stock HD-1080p profile, whose cutoff is Bluray-1080p. */
|
||||
/** The stock HD-1080p profile in both arrs, whose cutoff is Bluray-1080p. */
|
||||
const HD_1080P_PROFILE = 4;
|
||||
|
||||
/** Allowed by that profile and below its cutoff, so the import counts as upgradable. */
|
||||
const IMPORTED_FILE = 'F1 (2025) WEBDL-1080p.mkv';
|
||||
const IMPORTED_MOVIE_FILE = 'F1 (2025) WEBDL-1080p.mkv';
|
||||
|
||||
/** Radarr ignores a file small enough to look like a sample. */
|
||||
/** S01E01's id equals the series id, which hides id mix-ups, so this uses S01E02. */
|
||||
const IMPORTED_EPISODE = { seasonNumber: 1, episodeNumber: 2 };
|
||||
const IMPORTED_EPISODE_FILE = 'Agatha All Along - S01E02 - WEBDL-1080p.mkv';
|
||||
|
||||
/** The arr ignores a file small enough to look like a sample. */
|
||||
const FILE_SIZE_BYTES = 300 * 1024 * 1024;
|
||||
|
||||
/** No custom format exists, so every file scores 0 and sits below this cutoff. */
|
||||
const CUSTOM_FORMAT_CUTOFF = 100;
|
||||
|
||||
const MOVIES_DIR = resolve(__dirname, '..', '..', 'test-data', 'radarr-movies');
|
||||
const TV_DIR = resolve(__dirname, '..', '..', 'test-data', 'sonarr-tv');
|
||||
const SETTLE_MS = 20_000;
|
||||
|
||||
interface Movie {
|
||||
@@ -44,28 +54,45 @@ interface Movie {
|
||||
movieFile?: { id: number; qualityCutoffNotMet: boolean };
|
||||
}
|
||||
|
||||
interface Series {
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface Episode {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
hasFile: boolean;
|
||||
episodeFileId: number;
|
||||
}
|
||||
|
||||
type ProfileChanges = { upgradeAllowed: boolean; cutoffFormatScore?: number };
|
||||
|
||||
function movieFolder(movie: Movie): string {
|
||||
return join(MOVIES_DIR, movie.path.replace(/^\/movies\/?/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the seeded movie a real file that sits below the profile cutoff.
|
||||
*
|
||||
* The stock profiles ship with upgrades turned off, and Radarr reports the
|
||||
* cutoff as met whenever it will not upgrade, so this turns them on.
|
||||
*/
|
||||
async function importLowQualityFile(): Promise<void> {
|
||||
function seriesFolder(series: Series): string {
|
||||
return join(TV_DIR, series.path.replace(/^\/tv\/?/, ''));
|
||||
}
|
||||
|
||||
function writeFakeVideo(folder: string, name: string): void {
|
||||
mkdirShared(folder);
|
||||
const file = join(folder, name);
|
||||
writeFileShared(file, Buffer.alloc(0));
|
||||
truncateSync(file, FILE_SIZE_BYTES);
|
||||
}
|
||||
|
||||
/** Gives the seeded movie a real file that sits below the profile cutoff. */
|
||||
async function importLowQualityMovie(profileChanges: ProfileChanges): Promise<Movie> {
|
||||
const movie = await RADARR.arr.get<Movie>(`/api/v3/movie/${RADARR.itemId}`);
|
||||
const profile = await RADARR.arr.get<Record<string, unknown>>(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`);
|
||||
|
||||
await RADARR.arr.put(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`, { ...profile, upgradeAllowed: true });
|
||||
await RADARR.arr.put(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`, { ...profile, ...profileChanges });
|
||||
await RADARR.arr.put(`/api/v3/movie/${RADARR.itemId}`, { ...movie, qualityProfileId: HD_1080P_PROFILE });
|
||||
|
||||
const folder = movieFolder(movie);
|
||||
mkdirShared(folder);
|
||||
const file = join(folder, IMPORTED_FILE);
|
||||
writeFileShared(file, Buffer.alloc(0));
|
||||
truncateSync(file, FILE_SIZE_BYTES);
|
||||
writeFakeVideo(movieFolder(movie), IMPORTED_MOVIE_FILE);
|
||||
|
||||
await RADARR.arr.post('/api/v3/command', { name: 'RescanMovie', movieIds: [RADARR.itemId] });
|
||||
|
||||
@@ -73,12 +100,11 @@ async function importLowQualityFile(): Promise<void> {
|
||||
.poll(async () => (await RADARR.arr.get<Movie>(`/api/v3/movie/${RADARR.itemId}`)).hasFile, { timeout: 90_000 })
|
||||
.toBe(true);
|
||||
|
||||
const imported = await RADARR.arr.get<Movie>(`/api/v3/movie/${RADARR.itemId}`);
|
||||
expect(imported.movieFile?.qualityCutoffNotMet, 'the imported file should not meet the cutoff').toBe(true);
|
||||
return RADARR.arr.get<Movie>(`/api/v3/movie/${RADARR.itemId}`);
|
||||
}
|
||||
|
||||
/** Runs even when no test imported anything, because a half-done import still leaves a file. */
|
||||
async function dropImportedFile(): Promise<void> {
|
||||
async function dropImportedMovie(): Promise<void> {
|
||||
const movie = await RADARR.arr.get<Movie>(`/api/v3/movie/${RADARR.itemId}`);
|
||||
|
||||
if (movie.movieFile) {
|
||||
@@ -88,6 +114,74 @@ async function dropImportedFile(): Promise<void> {
|
||||
rmSync(movieFolder(movie), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function listEpisodes(): Promise<Episode[]> {
|
||||
return SONARR.arr.get<Episode[]>(`/api/v3/episode?seriesId=${SONARR.itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives one seeded episode a real file and unmonitors the rest.
|
||||
*
|
||||
* The other episodes are missing, so a monitored one would make the season search for that reason instead.
|
||||
*/
|
||||
async function importLowQualityEpisode(profileChanges: ProfileChanges): Promise<void> {
|
||||
const series = await SONARR.arr.get<Record<string, unknown> & Series>(`/api/v3/series/${SONARR.itemId}`);
|
||||
const profile = await SONARR.arr.get<Record<string, unknown>>(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`);
|
||||
|
||||
await SONARR.arr.put(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`, { ...profile, ...profileChanges });
|
||||
await SONARR.arr.put(`/api/v3/series/${SONARR.itemId}`, { ...series, qualityProfileId: HD_1080P_PROFILE });
|
||||
|
||||
const episodes = await listEpisodes();
|
||||
const others = episodes.filter(
|
||||
(e) => e.seasonNumber !== IMPORTED_EPISODE.seasonNumber || e.episodeNumber !== IMPORTED_EPISODE.episodeNumber,
|
||||
);
|
||||
await SONARR.arr.put('/api/v3/episode/monitor', { episodeIds: others.map((e) => e.id), monitored: false });
|
||||
|
||||
writeFakeVideo(join(seriesFolder(series), 'Season 01'), IMPORTED_EPISODE_FILE);
|
||||
|
||||
await SONARR.arr.post('/api/v3/command', { name: 'RescanSeries', seriesId: SONARR.itemId });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await listEpisodes()).some(
|
||||
(e) =>
|
||||
e.hasFile &&
|
||||
e.seasonNumber === IMPORTED_EPISODE.seasonNumber &&
|
||||
e.episodeNumber === IMPORTED_EPISODE.episodeNumber,
|
||||
),
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function dropImportedEpisode(): Promise<void> {
|
||||
const series = await SONARR.arr.get<Series>(`/api/v3/series/${SONARR.itemId}`);
|
||||
const episodes = await listEpisodes();
|
||||
|
||||
for (const episode of episodes.filter((e) => e.hasFile)) {
|
||||
await SONARR.arr.delete(`/api/v3/episodefile/${episode.episodeFileId}`);
|
||||
}
|
||||
|
||||
await SONARR.arr.put('/api/v3/episode/monitor', { episodeIds: episodes.map((e) => e.id), monitored: true });
|
||||
rmSync(join(seriesFolder(series), 'Season 01'), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Caches the file's custom format score, which the Seeker reads instead of asking the arr. */
|
||||
async function syncCustomFormatScores(api: CleanuparrApi, instanceId: string): Promise<void> {
|
||||
const res = await api.jobs.trigger('CustomFormatScoreSyncer');
|
||||
expect(res.ok, `triggering the custom format score sync failed with ${res.status}`).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const body = await (await api.seeker.listCustomFormatScores({ instanceId })).json();
|
||||
return (body.items as Array<{ isBelowCutoff: boolean }>).some((e) => e.isBelowCutoff);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Seeker quality upgrades', () => {
|
||||
let library: Array<Record<string, unknown>> = [];
|
||||
let profile: Record<string, unknown> = {};
|
||||
@@ -101,7 +195,7 @@ test.describe('Seeker quality upgrades', () => {
|
||||
|
||||
test.afterEach(async ({ api }) => {
|
||||
await teardownInstances(api);
|
||||
await dropImportedFile();
|
||||
await dropImportedMovie();
|
||||
await RADARR.arr.put(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`, profile);
|
||||
await restoreLibrary(RADARR, library);
|
||||
await resetLiveArrState();
|
||||
@@ -110,7 +204,9 @@ test.describe('Seeker quality upgrades', () => {
|
||||
test('searches a movie whose file is below the quality cutoff', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await importLowQualityFile();
|
||||
// Radarr reports the cutoff as met whenever it will not upgrade.
|
||||
const imported = await importLowQualityMovie({ upgradeAllowed: true });
|
||||
expect(imported.movieFile?.qualityCutoffNotMet, 'the imported file should not meet the cutoff').toBe(true);
|
||||
|
||||
const instanceId = await arrangeInstance(api, RADARR, { instance: { useCutoff: true } });
|
||||
await triggerSeeker(api);
|
||||
@@ -125,9 +221,89 @@ test.describe('Seeker quality upgrades', () => {
|
||||
test('leaves a movie with a file alone when the cutoff check is off', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await importLowQualityFile();
|
||||
await importLowQualityMovie({ upgradeAllowed: true });
|
||||
|
||||
const instanceId = await arrangeInstance(api, RADARR, { instance: { useCutoff: false } });
|
||||
await expectNoSearch(api, instanceId, SETTLE_MS);
|
||||
});
|
||||
|
||||
test('searches a movie below the custom format cutoff', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await importLowQualityMovie({ upgradeAllowed: true, cutoffFormatScore: CUSTOM_FORMAT_CUTOFF });
|
||||
|
||||
const instanceId = await arrangeInstance(api, RADARR, {
|
||||
instance: { useCutoff: false, useCustomFormatScore: true },
|
||||
});
|
||||
await syncCustomFormatScores(api, instanceId);
|
||||
await triggerSeeker(api);
|
||||
|
||||
await expect.poll(async () => (await listSearchEvents(api, instanceId)).length, { timeout: SETTLE_MS }).toBe(1);
|
||||
|
||||
const event = await firstSearchEvent(api, instanceId);
|
||||
expect(event?.itemTitle).toBe(RADARR.seededTitle);
|
||||
expect(event?.searchReason).toBe('CustomFormatScoreBelowCutoff');
|
||||
});
|
||||
|
||||
test('leaves a movie below the custom format cutoff alone when its profile disallows upgrades', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await importLowQualityMovie({ upgradeAllowed: false, cutoffFormatScore: CUSTOM_FORMAT_CUTOFF });
|
||||
|
||||
const instanceId = await arrangeInstance(api, RADARR, {
|
||||
instance: { useCutoff: false, useCustomFormatScore: true },
|
||||
});
|
||||
await syncCustomFormatScores(api, instanceId);
|
||||
await expectNoSearch(api, instanceId, SETTLE_MS);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Seeker custom format upgrades on Sonarr', () => {
|
||||
let library: Array<Record<string, unknown>> = [];
|
||||
let profile: Record<string, unknown> = {};
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetLiveArrState();
|
||||
library = await snapshotLibrary(SONARR);
|
||||
profile = await SONARR.arr.get<Record<string, unknown>>(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`);
|
||||
await indexerMock.stub(torznabSearchStub(SONARR.searchMode, []));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ api }) => {
|
||||
await teardownInstances(api);
|
||||
await dropImportedEpisode();
|
||||
await SONARR.arr.put(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`, profile);
|
||||
await restoreLibrary(SONARR, library);
|
||||
await resetLiveArrState();
|
||||
});
|
||||
|
||||
test('searches a season whose episode is below the custom format cutoff', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await importLowQualityEpisode({ upgradeAllowed: true, cutoffFormatScore: CUSTOM_FORMAT_CUTOFF });
|
||||
|
||||
const instanceId = await arrangeInstance(api, SONARR, {
|
||||
instance: { useCutoff: false, useCustomFormatScore: true },
|
||||
});
|
||||
await syncCustomFormatScores(api, instanceId);
|
||||
await triggerSeeker(api);
|
||||
|
||||
await expect.poll(async () => (await listSearchEvents(api, instanceId)).length, { timeout: SETTLE_MS }).toBe(1);
|
||||
|
||||
const event = await firstSearchEvent(api, instanceId);
|
||||
expect(event?.itemTitle).toBe(`${SONARR.seededTitle} S01`);
|
||||
expect(event?.searchReason).toBe('CustomFormatScoreBelowCutoff');
|
||||
});
|
||||
|
||||
test('leaves an episode below the custom format cutoff alone when its profile disallows upgrades', async ({ api }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
await importLowQualityEpisode({ upgradeAllowed: false, cutoffFormatScore: CUSTOM_FORMAT_CUTOFF });
|
||||
|
||||
const instanceId = await arrangeInstance(api, SONARR, {
|
||||
instance: { useCutoff: false, useCustomFormatScore: true },
|
||||
});
|
||||
await syncCustomFormatScores(api, instanceId);
|
||||
await expectNoSearch(api, instanceId, SETTLE_MS);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user