From a66c9d56da1e67b40f033ac35afc4887e694839b Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 17 Oct 2026 02:23:20 +0300 Subject: [PATCH] added live e2e for upgrades-disabled profiles --- .../live-arr-fast/seeker-upgrades.api.spec.ts | 224 ++++++++++++++++-- 1 file changed, 200 insertions(+), 24 deletions(-) diff --git a/e2e/tests/live-arr-fast/seeker-upgrades.api.spec.ts b/e2e/tests/live-arr-fast/seeker-upgrades.api.spec.ts index d903b3ef..f8e96ea8 100644 --- a/e2e/tests/live-arr-fast/seeker-upgrades.api.spec.ts +++ b/e2e/tests/live-arr-fast/seeker-upgrades.api.spec.ts @@ -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 { +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 { const movie = await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`); const profile = await RADARR.arr.get>(`/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 { .poll(async () => (await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`)).hasFile, { timeout: 90_000 }) .toBe(true); - const imported = await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`); - expect(imported.movieFile?.qualityCutoffNotMet, 'the imported file should not meet the cutoff').toBe(true); + return RADARR.arr.get(`/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 { +async function dropImportedMovie(): Promise { const movie = await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`); if (movie.movieFile) { @@ -88,6 +114,74 @@ async function dropImportedFile(): Promise { rmSync(movieFolder(movie), { recursive: true, force: true }); } +async function listEpisodes(): Promise { + return SONARR.arr.get(`/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 { + const series = await SONARR.arr.get & Series>(`/api/v3/series/${SONARR.itemId}`); + const profile = await SONARR.arr.get>(`/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 { + const series = await SONARR.arr.get(`/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 { + 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> = []; let profile: Record = {}; @@ -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> = []; + let profile: Record = {}; + + test.beforeEach(async () => { + await resetLiveArrState(); + library = await snapshotLibrary(SONARR); + profile = await SONARR.arr.get>(`/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); + }); });