mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added custom format gap strategy e2e
This commit is contained in:
1 parent
a9c39289df
commit
fea7e59669
3 files changed
+152
-66
No files matched your search
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user