added custom format gap strategy e2e

This commit is contained in:
Flaminel committed 2026-10-23 01:32:14 +03:00
1 parent a9c39289df
commit fea7e59669
3 files changed
+152 -66

No files matched your search

+65 -1
View File
@@ -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<void> {
`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<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, ...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<Movie>(`/api/v3/movie/${RADARR.itemId}`)).hasFile, { timeout: 90_000 })
.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. */
export async function dropImportedMovie(): Promise<void> {
const movie = await RADARR.arr.get<Movie>(`/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 });
}
@@ -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<Preview> {
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<Record<string, unknown>> = [];
let profile: Record<string, unknown> = {};
test.beforeEach(async () => {
await resetLiveArrState();
library = await snapshotLibrary(RADARR);
profile = await RADARR.arr.get<Record<string, unknown>>(`/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<Record<string, unknown>>(`/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<Record<string, unknown>>(`/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());
});
});
@@ -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<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, ...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<Movie>(`/api/v3/movie/${RADARR.itemId}`)).hasFile, { timeout: 90_000 })
.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 dropImportedMovie(): Promise<void> {
const movie = await RADARR.arr.get<Movie>(`/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<Episode[]> {
return SONARR.arr.get<Episode[]>(`/api/v3/episode?seriesId=${SONARR.itemId}`);
}