From fea7e596695bae6c87b3e593e04812cb87eea215 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 23 Oct 2026 01:32:14 +0300 Subject: [PATCH] added custom format gap strategy e2e --- e2e/tests/helpers/seeker-live.ts | 66 ++++++++++++++- .../seeker-selection.api.spec.ts | 83 ++++++++++++++++++- .../live-arr-fast/seeker-upgrades.api.spec.ts | 69 ++------------- 3 files changed, 152 insertions(+), 66 deletions(-) diff --git a/e2e/tests/helpers/seeker-live.ts b/e2e/tests/helpers/seeker-live.ts index 1f48e670..d6a06ee1 100644 --- a/e2e/tests/helpers/seeker-live.ts +++ b/e2e/tests/helpers/seeker-live.ts @@ -1,4 +1,5 @@ -import { resolve } from 'node:path'; +import { rmSync, truncateSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import { expect } from '@playwright/test'; import { TEST_CONFIG } from './test-config'; import { buildSingleFileTorrent } from './torrent-fixtures'; @@ -8,6 +9,7 @@ import type { ArrType } from './api/arr'; import { LiveArr, indexerMock, liveRadarr, liveSonarr } from './live-arr'; import { QBittorrentDriver } from './torrent-clients/qbittorrent'; import { TORZNAB_MOVIE_CATEGORY, TORZNAB_TV_CATEGORY } from './mocks/torznab-stubs'; +import { mkdirShared, writeFileShared } from './shared-volume'; /** * Shared arrangement for the specs that drive the Seeker against a real arr. @@ -479,3 +481,65 @@ export async function triggerSeeker(api: CleanuparrApi): Promise { `Triggering the Seeker failed with ${res.status}. Did you start the stack with 'make up-arr-fast'?`, ).toBe(true); } + +/** The stock HD-1080p profile in both arrs, whose cutoff is Bluray-1080p. */ +export const HD_1080P_PROFILE = 4; + +/** Allowed by that profile and below its cutoff, so the import counts as upgradable. */ +const IMPORTED_MOVIE_FILE = 'F1 (2025) WEBDL-1080p.mkv'; + +/** The arr ignores a file small enough to look like a sample. */ +const FILE_SIZE_BYTES = 300 * 1024 * 1024; + +const MOVIES_DIR = resolve(__dirname, '..', '..', 'test-data', 'radarr-movies'); + +interface Movie { + id: number; + path: string; + hasFile: boolean; + qualityProfileId: number; + movieFile?: { id: number; qualityCutoffNotMet: boolean }; +} + +export type ProfileChanges = { upgradeAllowed: boolean; cutoffFormatScore?: number }; + +function movieFolder(movie: Movie): string { + return join(MOVIES_DIR, movie.path.replace(/^\/movies\/?/, '')); +} + +export 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. */ +export 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, ...profileChanges }); + await RADARR.arr.put(`/api/v3/movie/${RADARR.itemId}`, { ...movie, qualityProfileId: HD_1080P_PROFILE }); + + writeFakeVideo(movieFolder(movie), IMPORTED_MOVIE_FILE); + + await RADARR.arr.post('/api/v3/command', { name: 'RescanMovie', movieIds: [RADARR.itemId] }); + + await expect + .poll(async () => (await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`)).hasFile, { timeout: 90_000 }) + .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. */ +export async function dropImportedMovie(): Promise { + const movie = await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`); + + if (movie.movieFile) { + await RADARR.arr.delete(`/api/v3/moviefile/${movie.movieFile.id}`); + } + + rmSync(movieFolder(movie), { recursive: true, force: true }); +} diff --git a/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts b/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts index fb6d05a0..d3b8e0ae 100644 --- a/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts +++ b/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts @@ -1,9 +1,13 @@ import { test, expect } from '../fixtures/base'; +import type { CleanuparrApi } from '../helpers/api'; import { indexerMock } from '../helpers/live-arr'; import { + HD_1080P_PROFILE, RADARR, arrangeInstance, + dropImportedMovie, firstSearchEvent, + importLowQualityMovie, listSearchEvents, resetLiveArrState, restoreLibrary, @@ -16,8 +20,9 @@ import { torznabSearchStub } from '../helpers/mocks/torznab-stubs'; /** * Which candidate a strategy picks when the library has more than one. * - * Only the deterministic strategies are here. - * The weighted and random ones have no assertion that is not a coin flip. + * The deterministic strategies get exact picks. + * Custom Format Gap Weighted gets skewed odds over repeated previews. + * The other weighted strategies and Random have no assertion that is not a coin flip. * * The seed leaves the second movie unmonitored, so every other spec sees one * candidate. These tests monitor it and the teardown puts the library back. @@ -25,6 +30,21 @@ import { torznabSearchStub } from '../helpers/mocks/torznab-stubs'; const SETTLE_MS = 20_000; +/** At 80% odds per draw, 24 or more leads fails about once in a thousand runs. */ +const PREVIEW_DRAWS = 40; +const MIN_MISSING_LEADS = 24; + +interface Preview { + randomPick: boolean; + nextUp: Array<{ name: string }>; +} + +async function getPreview(api: CleanuparrApi, instanceId: string): Promise { + const res = await api.seeker.getPreview(instanceId); + expect(res.status, await res.clone().text()).toBe(200); + return (await res.json()) as Preview; +} + interface Candidate { id: number; title: string; @@ -61,15 +81,19 @@ function oldestSearched(candidates: Candidate[]): string[] { test.describe('Seeker selection strategies', () => { let library: Array> = []; + let profile: Record = {}; test.beforeEach(async () => { await resetLiveArrState(); library = await snapshotLibrary(RADARR); + profile = await RADARR.arr.get>(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`); await indexerMock.stub(torznabSearchStub(RADARR.searchMode, [])); }); test.afterEach(async ({ api }) => { await teardownInstances(api); + await dropImportedMovie(); + await RADARR.arr.put(`/api/v3/qualityprofile/${HD_1080P_PROFILE}`, profile); await restoreLibrary(RADARR, library); await resetLiveArrState(); }); @@ -145,4 +169,59 @@ test.describe('Seeker selection strategies', () => { const searched = (await listSearchEvents(api, instanceId)).map((event) => event.itemTitle); expect(new Set(searched).size).toBe(candidates.length); }); + + test('custom format gap weighted favors the missing movie over a zero-gap upgrade', async ({ api }) => { + test.setTimeout(240_000); + + const candidates = await monitorEveryMovie(); + expect(candidates).toHaveLength(2); + const missing = candidates.find((c) => c.id !== RADARR.itemId)!; + + // A WEBDL-1080p file misses the quality cutoff but meets a custom format cutoff of 0, so its gap is 0. + await importLowQualityMovie({ upgradeAllowed: true, cutoffFormatScore: 0 }); + + // With the newer search on the upgrade, the weights are 1.0 for the missing movie and 0.25 for the upgrade. + const before = await RADARR.arr.get>(`/api/v3/movie/${RADARR.itemId}`); + await RADARR.arr.post('/api/v3/command', { name: 'MoviesSearch', movieIds: [RADARR.itemId] }); + await expect + .poll( + async () => + searchedAt((await RADARR.arr.get>(`/api/v3/movie/${RADARR.itemId}`)).lastSearchTime as string), + { timeout: SETTLE_MS }, + ) + .toBeGreaterThan(searchedAt(before.lastSearchTime as string | undefined)); + + const instanceId = await arrangeInstance(api, RADARR, { + config: { selectionStrategy: 'CustomFormatGapWeighted' }, + instance: { useCutoff: true, useCustomFormatScore: true }, + }); + + let missingLeads = 0; + for (let draw = 0; draw < PREVIEW_DRAWS; draw++) { + const preview = await getPreview(api, instanceId); + expect(preview.nextUp.map((u) => u.name).sort()).toEqual([missing.title, RADARR.seededTitle].sort()); + if (preview.nextUp[0].name === missing.title) { + missingLeads++; + } + } + + expect(missingLeads).toBeGreaterThanOrEqual(MIN_MISSING_LEADS); + + await triggerSeeker(api); + await expect.poll(async () => (await listSearchEvents(api, instanceId)).length, { timeout: SETTLE_MS }).toBe(1); + }); + + test('custom format gap weighted falls back to balanced without custom format scores', async ({ api }) => { + const candidates = await monitorEveryMovie(); + + const instanceId = await arrangeInstance(api, RADARR, { + config: { selectionStrategy: 'CustomFormatGapWeighted' }, + instance: { useCustomFormatScore: false }, + }); + + const preview = await getPreview(api, instanceId); + + expect(preview.randomPick).toBe(true); + expect(preview.nextUp.map((u) => u.name).sort()).toEqual(candidates.map((c) => c.title).sort()); + }); }); 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 d41f9e88..2bd07c15 100644 --- a/e2e/tests/live-arr-fast/seeker-upgrades.api.spec.ts +++ b/e2e/tests/live-arr-fast/seeker-upgrades.api.spec.ts @@ -1,23 +1,27 @@ -import { rmSync, truncateSync } from 'node:fs'; +import { rmSync } 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 { + HD_1080P_PROFILE, RADARR, SONARR, + type ProfileChanges, arrangeInstance, + dropImportedMovie, expectNoSearch, firstSearchEvent, + importLowQualityMovie, listSearchEvents, resetLiveArrState, restoreLibrary, snapshotLibrary, teardownInstances, triggerSeeker, + writeFakeVideo, } from '../helpers/seeker-live'; 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 or custom format score is too low. @@ -26,34 +30,16 @@ import { mkdirShared, writeFileShared } from '../helpers/shared-volume'; * imports it and parses its quality from the name. */ -/** 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_MOVIE_FILE = 'F1 (2025) WEBDL-1080p.mkv'; - /** 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 { - id: number; - path: string; - hasFile: boolean; - qualityProfileId: number; - movieFile?: { id: number; qualityCutoffNotMet: boolean }; -} - interface Series { id: number; path: string; @@ -68,53 +54,10 @@ interface Episode { monitored: boolean; } -type ProfileChanges = { upgradeAllowed: boolean; cutoffFormatScore?: number }; - -function movieFolder(movie: Movie): string { - return join(MOVIES_DIR, movie.path.replace(/^\/movies\/?/, '')); -} - 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, ...profileChanges }); - await RADARR.arr.put(`/api/v3/movie/${RADARR.itemId}`, { ...movie, qualityProfileId: HD_1080P_PROFILE }); - - writeFakeVideo(movieFolder(movie), IMPORTED_MOVIE_FILE); - - await RADARR.arr.post('/api/v3/command', { name: 'RescanMovie', movieIds: [RADARR.itemId] }); - - await expect - .poll(async () => (await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`)).hasFile, { timeout: 90_000 }) - .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 dropImportedMovie(): Promise { - const movie = await RADARR.arr.get(`/api/v3/movie/${RADARR.itemId}`); - - if (movie.movieFile) { - await RADARR.arr.delete(`/api/v3/moviefile/${movie.movieFile.id}`); - } - - rmSync(movieFolder(movie), { recursive: true, force: true }); -} - async function listEpisodes(): Promise { return SONARR.arr.get(`/api/v3/episode?seriesId=${SONARR.itemId}`); }