fix: bulk-prefetch Seerr requests for rule evaluation (#3152) (#3170)

* fix: bulk-prefetch Seerr requests for rule evaluation (#3152)

Rules seeded on a Seerr property (e.g. "Seerr - Requested") matched
almost nothing on large libraries. The getter made a per-item
GET /movie|/tv call per item; under a whole-library run those calls
rate-limited, the getter returned a value the comparator skipped, and
Seerr-seeded rules silently degraded to near-zero matches.

- SeerrApiService now fetches all requests in one paginated /request
  sweep and builds a run-scoped index grouped by media.tmdbId, with
  in-flight dedup, cached in a dedicated non-persistent cache that is
  flushed (and rebuilt) per rule-group run. Page size is 100 (Seerr
  imposes no take cap and OFFSET re-scans), reducing round-trips.
- SeerrGetterService reads that index for every request-derived property
  (isRequested, amountRequested, requestDate, addUser, approvalDate,
  mediaAddedAt) at movie and season/episode level.
- A failed sweep yields undefined (transient — the comparator protects
  the item), never null; releaseDate keeps its per-item fallback.
- Add tools/dev/fake-seerr.mjs and a shared scale-library fixture, and
  seed Seerr rule groups, for end-to-end testing.
- Consolidate request-service naming to "Seerr" across code comments, UI
  strings and docs (the legacy /overseerr and /jellyseerr route aliases
  stay for backward compatibility).

* fix: order Seerr requestDate oldest-first and drop no-op sort param (#3152)

Review follow-up to #3152:
- buildRequestIndex sorts each title's requests createdAt-ascending, so
  requestDate (and addUser/season order) returns the first request, matching
  the pre-#3152 getMovie ordering rather than the newest re-request.
- Drop the `sort=added` query param: Seerr has no `added` sort (it falls back
  to request.id DESC); the index now normalises order itself.
- getRequestsForMedia returns a deep copy (lodash cloneDeep) so callers cannot
  mutate the cached run-scoped index (useClones is off); cloneDeep over
  structuredClone so an unexpected non-cloneable value can't throw.
- Seed the remaining Seerr property rule groups and add focused tests.
This commit is contained in:
enoch85
2026-06-25 18:53:23 +02:00
committed by GitHub
parent 8648a3fafc
commit 9844b58c9c
27 changed files with 1801 additions and 424 deletions
@@ -34,7 +34,7 @@ When implementing against any external API or SDK (Plex, Jellyfin, TypeORM, etc.
#### Request management services
- Seerr/Overseerr/Jellyseerr: https://docs.seerr.dev/
- Seerr: https://docs.seerr.dev/
#### Media server services
+3 -4
View File
@@ -16,7 +16,7 @@ yarn test | tail -20
## Project Overview
Maintainerr is a media management application that helps users automatically manage their media libraries by creating rules to handle unused or unwatched content. It integrates with Plex, Jellyfin, Emby, \*arr applications (Radarr/Sonarr), Overseerr/Jellyseerr, Tautulli, and Streamystats (Jellyfin only) to provide comprehensive media lifecycle management.
Maintainerr is a media management application that helps users automatically manage their media libraries by creating rules to handle unused or unwatched content. It integrates with Plex, Jellyfin, Emby, \*arr applications (Radarr/Sonarr), Seerr, Tautulli, and Streamystats (Jellyfin only) to provide comprehensive media lifecycle management.
For the broader system architecture map, see [ARCHITECTURE.md](ARCHITECTURE.md).
@@ -305,7 +305,7 @@ The application integrates with several external services:
- **Plex**: Media server API for collections and metadata
- **Jellyfin/Emby**: Media server APIs through the shared media-server abstraction
- **Radarr/Sonarr**: Movie/TV show management APIs
- **Overseerr/Jellyseerr**: Request management systems
- **Seerr**: Request management system
- **Tautulli**: Plex analytics and statistics
- **Streamystats**: Jellyfin item-level analytics surfaced on media details
@@ -328,8 +328,7 @@ Reference the following OpenAPI specifications and API documentation when workin
### Request Management Services
- **Overseerr**: [API Documentation](https://api-docs.overseerr.dev/overseerr-api.yml)
- **Jellyseerr**: [OpenAPI Specification](https://github.com/fallenbagel/jellyseerr/blob/develop/jellyseerr-api.yml)
- **Seerr**: [Documentation](https://docs.seerr.dev/) | [Source](https://github.com/seerr-team/seerr)
### Media Server Services
+2 -2
View File
@@ -272,6 +272,6 @@ See `CONTRIBUTING.md` for setup, branching, and pull request expectations.
shared code.
- Rule group: A configured set of rules that selects media and links it to a
Maintainerr collection.
- Seerr: The request-management integration family covering Overseerr,
Jellyseerr, and Seerr-compatible APIs.
- Seerr: The request-management integration (github.com/seerr-team/seerr) used
for request cleanup.
- SSE: Server-sent events used for live rule and collection job updates.
+1 -1
View File
@@ -189,4 +189,4 @@ If branch sync needs to be rerun manually, use:
## Attribution
This contribution guide was inspired by the [Overseerr](https://github.com/sct/overseerr) contribution guide.
This contribution guide was inspired by the [Seerr](https://github.com/seerr-team/seerr) contribution guide.
+1 -1
View File
@@ -279,4 +279,4 @@ The overlay system was built by [@gssariev](https://github.com/gssariev), with [
<sub>Made with [contrib.rocks](https://contrib.rocks).</sub>
Maintainerr is heavily inspired by Seerr (Overseerr / Jellyseerr). Some parts of Maintainerr's code are direct copies. Big thanks to the Seerr team!
Maintainerr is heavily inspired by Seerr. Some parts of Maintainerr's code are direct copies. Big thanks to the Seerr team!
+16
View File
@@ -7,6 +7,7 @@ type AvailableCacheIds =
| 'plexwatchhistory'
| 'plextv'
| 'seerr'
| 'seerrrequests'
| 'plexcommunity'
| 'tautulli'
| 'streamystats'
@@ -94,6 +95,21 @@ class CacheManager {
),
plextv: new Cache('plextv', 'Plex.tv', 'plextv'),
seerr: new Cache('seerr', 'Seerr API', 'seerr'),
// Holds the run-scoped request index built by SeerrApiService.getRequestsForMedia
// (one bulk /request sweep grouped by tmdbId). useClones is off because the
// value is a Map — per-item reads copy the per-title array out. Unlike
// plexwatchhistory this is NOT persistent: request data changes between runs,
// so flushAll() at each rule-group start rebuilds it (freshness over reuse).
// Long TTL so a single long run can't expire it mid-sweep.
seerrrequests: new Cache(
'seerrrequests',
'Seerr requests',
'seerrrequests',
{
stdTtl: 3600, // 1 hour
useClones: false,
},
),
plexcommunity: new Cache(
'plexcommunity',
'community.Plex.tv',
@@ -603,7 +603,7 @@ export class EmbyAdapterService implements IMediaServerService {
options?: RecentlyAddedOptions,
): Promise<MediaItem[]> {
if (!this.http) return [];
// Emby uses /Users/{userId}/Items/Latest (per Jellyseerr precedent),
// Emby uses /Users/{userId}/Items/Latest (per Seerr precedent),
// whereas Jellyfin exposes /Items/Latest. The user-scoped endpoint is the
// documented path for Emby.
if (!this.embyUserId) {
@@ -721,7 +721,7 @@ export class EmbyAdapterService implements IMediaServerService {
// Watch State
// ============================================================================
// TODO(emby-server-test): Emby lacks a central watch-history endpoint; per
// Jellyseerr precedent, iterate over users via /Users/{id}/Items with
// Seerr precedent, iterate over users via /Users/{id}/Items with
// IsPlayed=true filter. The implementations below mirror the Jellyfin
// adapter's shape but use Emby endpoint paths.
@@ -31,7 +31,7 @@ export const EMBY_CACHE_KEYS = {
export const EMBY_TICKS_PER_MS = 10000;
// Emby's authorization header requires a pinned client Version string of
// '1.0.0'. Newer values are rejected by some endpoints. See Jellyseerr
// '1.0.0'. Newer values are rejected by some endpoints. See Seerr
// server/api/jellyfin.ts where mediaServerType === 'emby' hardcodes the same.
export const EMBY_CLIENT_INFO = {
name: 'Maintainerr',
@@ -1646,7 +1646,7 @@ export class PlexApiService {
const plextv = plexTvUsers?.find((tvEl) => Number(tvEl.$?.id) === el.id);
const ownerUser = owner?.username === el.name ? owner : undefined;
// use the username from plex.tv if available, since Overseerr also does this
// use the username from plex.tv if available, since Seerr also does this
if (ownerUser) {
const uuid = this.extractPlexAvatarUuid(ownerUser.thumb);
return {
@@ -0,0 +1,14 @@
// Shared id of the Seerr requests NodeCache (see modules/api/lib/cache.ts).
export const SEERR_REQUESTS_CACHE_ID = 'seerrrequests';
// Key under which the run-scoped request index (Map<tmdbId, SeerrRequest[]>) is
// stored in that cache. The cache is flushed between rule-group runs
// (CacheManager.flushAll), so the index is rebuilt each run from a single bulk
// /request sweep and reused across items within the run.
export const SEERR_REQUESTS_CACHE_KEY = 'request-index';
// Page size for the /request pagination sweep. Seerr imposes no upper bound on
// `take` (verified against the Seerr API source and a live instance) and its
// OFFSET pagination re-scans skipped rows, so a larger page than getUsers' 50
// means fewer round-trips and less server-side re-scan for one bulk sweep.
export const SEERR_REQUESTS_PAGE_SIZE = 100;
@@ -1,6 +1,12 @@
import { Mocked, TestBed } from '@suites/unit';
import { SettingsDataService } from '../../settings/settings-data.service';
import { SeerrApiService, SeerrRequestStatus } from './seerr-api.service';
import cacheManager from '../lib/cache';
import { SEERR_REQUESTS_CACHE_ID } from './seerr-api.constants';
import {
SeerrApiService,
SeerrRequest,
SeerrRequestStatus,
} from './seerr-api.service';
describe('SeerrApiService', () => {
let service: SeerrApiService;
@@ -207,4 +213,212 @@ describe('SeerrApiService', () => {
undefined,
);
});
const requestWithTmdb = (
id: number,
tmdbId: number,
createdAt = '2026-01-01',
): SeerrRequest =>
({
id,
type: 'movie',
status: SeerrRequestStatus.APPROVED,
createdAt,
updatedAt: '2026-01-01',
requestedBy: {} as never,
modifiedBy: {} as never,
is4k: false,
serverId: 1,
profileId: 1,
rootFolder: '/',
media: {
id: tmdbId,
tmdbId,
tvdbId: 0,
status: 5,
updatedAt: '2026-01-01',
mediaAddedAt: '2026-01-01',
},
}) as unknown as SeerrRequest;
const page = (results: SeerrRequest[], pageNum: number, pages: number) => ({
pageInfo: { page: pageNum, pages, pageSize: 100, results: pages * 100 },
results,
});
describe('getRequests', () => {
it('paginates until page === pages and accumulates all results', async () => {
const getWithoutCache = jest
.fn()
.mockResolvedValueOnce(page([requestWithTmdb(1, 100)], 1, 3))
.mockResolvedValueOnce(page([requestWithTmdb(2, 200)], 2, 3))
.mockResolvedValueOnce(page([requestWithTmdb(3, 300)], 3, 3));
(service as unknown as { api: unknown }).api = { getWithoutCache };
const result = await service.getRequests();
expect(getWithoutCache).toHaveBeenCalledTimes(3);
expect(getWithoutCache).toHaveBeenNthCalledWith(
1,
'/request?take=100&skip=0&filter=all',
);
expect(getWithoutCache).toHaveBeenNthCalledWith(
2,
'/request?take=100&skip=100&filter=all',
);
expect(getWithoutCache).toHaveBeenNthCalledWith(
3,
'/request?take=100&skip=200&filter=all',
);
expect(result).toHaveLength(3);
});
it('returns [] (reachable, empty) when Seerr has no requests', async () => {
const getWithoutCache = jest.fn().mockResolvedValue(page([], 1, 0));
(service as unknown as { api: unknown }).api = { getWithoutCache };
await expect(service.getRequests()).resolves.toEqual([]);
expect(getWithoutCache).toHaveBeenCalledTimes(1);
});
it('returns undefined (not []) when the first page fails', async () => {
const getWithoutCache = jest.fn().mockResolvedValue(undefined);
(service as unknown as { api: unknown }).api = { getWithoutCache };
await expect(service.getRequests()).resolves.toBeUndefined();
});
it('returns undefined when a truthy response is missing pageInfo', async () => {
// A genuine empty result still carries pageInfo; a response object without
// it means the sweep failed and must be treated as transient, not empty.
const getWithoutCache = jest.fn().mockResolvedValue({ results: [] });
(service as unknown as { api: unknown }).api = { getWithoutCache };
await expect(service.getRequests()).resolves.toBeUndefined();
});
it('returns undefined when a later page fails mid-sweep', async () => {
const getWithoutCache = jest
.fn()
.mockResolvedValueOnce(page([requestWithTmdb(1, 100)], 1, 3))
.mockResolvedValueOnce(undefined);
(service as unknown as { api: unknown }).api = { getWithoutCache };
await expect(service.getRequests()).resolves.toBeUndefined();
});
});
describe('getRequestsForMedia (run-scoped index)', () => {
beforeEach(() => {
cacheManager.getCache(SEERR_REQUESTS_CACHE_ID)?.data.flushAll();
});
it('groups the flat request list by media.tmdbId and returns copies', async () => {
const getWithoutCache = jest
.fn()
.mockResolvedValue(
page(
[
requestWithTmdb(1, 100),
requestWithTmdb(2, 100),
requestWithTmdb(3, 200),
],
1,
1,
),
);
(service as unknown as { api: unknown }).api = { getWithoutCache };
await expect(service.getRequestsForMedia(100)).resolves.toHaveLength(2);
await expect(service.getRequestsForMedia(200)).resolves.toHaveLength(1);
await expect(service.getRequestsForMedia(999)).resolves.toEqual([]);
// One sweep total — later lookups are served from the cached index.
expect(getWithoutCache).toHaveBeenCalledTimes(1);
// Returned values are deep copies: neither reshaping the array nor
// mutating a request object may corrupt the cached index.
const copy = await service.getRequestsForMedia(100);
copy.push(requestWithTmdb(99, 100));
copy[0].media.tmdbId = -1;
const fresh = await service.getRequestsForMedia(100);
expect(fresh).toHaveLength(2);
expect(fresh[0].media.tmdbId).toBe(100);
});
it('returns each title oldest-first regardless of the sweep order', async () => {
// The /request sweep is newest-first; the index must normalise to
// createdAt-ascending so the getter's requests[0] is the oldest request.
const getWithoutCache = jest
.fn()
.mockResolvedValue(
page(
[
requestWithTmdb(3, 100, '2026-03-01'),
requestWithTmdb(1, 100, '2026-01-01'),
requestWithTmdb(2, 100, '2026-02-01'),
],
1,
1,
),
);
(service as unknown as { api: unknown }).api = { getWithoutCache };
const requests = await service.getRequestsForMedia(100);
expect(requests?.map((r) => r.createdAt)).toEqual([
'2026-01-01',
'2026-02-01',
'2026-03-01',
]);
});
it('skips requests whose media.tmdbId is not a number', async () => {
const noTmdb = requestWithTmdb(2, 100);
(noTmdb.media as { tmdbId?: number }).tmdbId = undefined;
const getWithoutCache = jest
.fn()
.mockResolvedValue(page([requestWithTmdb(1, 100), noTmdb], 1, 1));
(service as unknown as { api: unknown }).api = { getWithoutCache };
await expect(service.getRequestsForMedia(100)).resolves.toHaveLength(1);
});
it('builds the index once for a concurrent first batch (in-flight dedup)', async () => {
let resolveSweep: (v: unknown) => void;
const getWithoutCache = jest.fn().mockImplementation(
() =>
new Promise((res) => {
resolveSweep = res;
}),
);
(service as unknown as { api: unknown }).api = { getWithoutCache };
const batch = Promise.all([
service.getRequestsForMedia(100),
service.getRequestsForMedia(200),
service.getRequestsForMedia(300),
service.getRequestsForMedia(400),
]);
resolveSweep(page([requestWithTmdb(1, 100)], 1, 1));
const [r100, r200] = await batch;
// Eight concurrent items would otherwise trigger eight sweeps.
expect(getWithoutCache).toHaveBeenCalledTimes(1);
expect(r100).toHaveLength(1);
expect(r200).toEqual([]);
});
it('returns undefined on a failed sweep and retries on the next call', async () => {
const getWithoutCache = jest.fn().mockResolvedValueOnce(undefined);
(service as unknown as { api: unknown }).api = { getWithoutCache };
await expect(service.getRequestsForMedia(100)).resolves.toBeUndefined();
// The failed sweep is not cached, so a later batch retries and recovers.
getWithoutCache.mockResolvedValueOnce(
page([requestWithTmdb(1, 100)], 1, 1),
);
await expect(service.getRequestsForMedia(100)).resolves.toHaveLength(1);
expect(getWithoutCache).toHaveBeenCalledTimes(2);
});
});
});
@@ -1,5 +1,6 @@
import { BasicResponseDto } from '@maintainerr/contracts';
import { Injectable } from '@nestjs/common';
import { cloneDeep } from 'lodash';
import { SettingsDataService } from '../../../modules/settings/settings-data.service';
import {
CONNECTION_TEST_TIMEOUT_MS,
@@ -10,6 +11,12 @@ import {
MaintainerrLogger,
MaintainerrLoggerFactory,
} from '../../logging/logs.service';
import cacheManager from '../lib/cache';
import {
SEERR_REQUESTS_CACHE_ID,
SEERR_REQUESTS_CACHE_KEY,
SEERR_REQUESTS_PAGE_SIZE,
} from './seerr-api.constants';
import { SeerrApi } from './helpers/seerr-api.helper';
interface SeerrMediaInfo {
@@ -160,10 +167,26 @@ interface SeerrUserResponseResult {
displayName: string;
}
interface SeerrRequestPageResponse {
pageInfo: {
pages: number;
pageSize: number;
results: number;
page: number;
};
results: SeerrRequest[];
}
@Injectable()
export class SeerrApiService {
api: SeerrApi;
// Deduplicates concurrent callers (the first batch of rule-evaluation items)
// onto a single /request sweep while the run-scoped index is being built.
private requestIndexPromise?: Promise<
Map<number, SeerrRequest[]> | undefined
>;
constructor(
private readonly settings: SettingsDataService,
private readonly logger: MaintainerrLogger,
@@ -283,6 +306,153 @@ export class SeerrApiService {
}
}
/**
* Fetches every request in a single paginated sweep, mirroring getUsers()'s
* pagination. Unlike getUsers() (which collapses errors to []), this returns
* `undefined` on failure so the index build can tell a genuinely empty Seerr
* (definitive: nothing requested) from an unreachable one (transient: protect
* items). `[]` therefore means "Seerr reachable, no requests".
*/
public async getRequests(): Promise<SeerrRequest[] | undefined> {
try {
const size = SEERR_REQUESTS_PAGE_SIZE;
let hasNext = true;
let skip = 0;
const requests: SeerrRequest[] = [];
while (hasNext) {
// Seerr has no `added` sort value (only `modified` → request.updatedAt;
// anything else falls back to the default `request.id DESC`), so we omit
// `sort` and let buildRequestIndex normalise ordering instead of relying
// on the sweep order. `filter=all` keeps every request status.
const resp = await this.api.getWithoutCache<SeerrRequestPageResponse>(
`/request?take=${size}&skip=${skip}&filter=all`,
);
// The HTTP helper swallows request failures and returns undefined; a
// genuine empty result still carries pageInfo. A missing pageInfo means
// the sweep failed — surface that (transient), don't read it as empty.
if (!resp?.pageInfo) {
return undefined;
}
requests.push(...(resp.results ?? []));
if (resp.pageInfo.page < resp.pageInfo.pages) {
skip = skip + size;
} else {
hasNext = false;
}
}
return requests;
} catch (error) {
this.logger.warn(
`Couldn't fetch Seerr requests. Is the application running?`,
);
this.logger.debug(
`Couldn't fetch Seerr requests. Is the application running?`,
error,
);
return undefined;
}
}
/**
* Run-scoped lookup of the Seerr requests for a single tmdbId, backed by one
* bulk /request sweep per rule-group run (issue #3152). The per-item
* getMovie/getShow calls this replaces rate-limited under whole-library runs,
* making Seerr-seeded rules silently match almost nothing.
*
* Returns a deep copy of the title's request list (the cache holds the Map by
* reference with useClones off), so callers may read or mutate it freely
* without corrupting the shared index. `[]` means the sweep succeeded and the
* title has no request (definitive). `undefined` means the sweep failed —
* Seerr is unreachable — so the getter returns `undefined` (transient) and the
* comparator protects the item rather than treating it as "not requested".
*/
public async getRequestsForMedia(
tmdbId: number,
): Promise<SeerrRequest[] | undefined> {
const index = await this.getRequestIndex();
if (index === undefined) {
return undefined;
}
const requests = index.get(tmdbId);
// cloneDeep, not structuredClone: it never throws on an unexpected
// non-cloneable value (which would surface as a per-item warn + skip).
return requests ? cloneDeep(requests) : [];
}
private async getRequestIndex(): Promise<
Map<number, SeerrRequest[]> | undefined
> {
const cache = cacheManager.getCache(SEERR_REQUESTS_CACHE_ID)?.data;
const cached = cache?.get<Map<number, SeerrRequest[]>>(
SEERR_REQUESTS_CACHE_KEY,
);
if (cached) {
return cached;
}
// Collapse the first concurrent batch of callers onto one sweep.
this.requestIndexPromise ??= this.buildRequestIndex().finally(() => {
this.requestIndexPromise = undefined;
});
return this.requestIndexPromise;
}
private async buildRequestIndex(): Promise<
Map<number, SeerrRequest[]> | undefined
> {
const requests = await this.getRequests();
// Don't cache a failed sweep: a later batch in the same run retries, giving
// a transient Seerr blip a chance to recover instead of poisoning the run.
if (requests === undefined) {
return undefined;
}
// requestDate reads requests[0].createdAt and the legacy per-item
// getMovie/getShow path returned mediaInfo.requests oldest-first. The bulk
// /request sweep is newest-first, so sort ascending by createdAt (tie-break
// on id) — requestDate, addUser and the season ordering then match the
// pre-#3152 behaviour regardless of how Seerr happened to page the sweep.
requests.sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ||
a.id - b.id,
);
// Group by media.tmdbId: Seerr keys every media row by tmdbId (non-null,
// indexed — tvdbId/imdbId are optional extras), and the metadata service
// resolves each library item to that tmdbId via all its providers (with
// tvdb/imdb -> tmdb bridging), so tmdbId is the canonical join key (and
// matches the per-item getMovie/getShow path this replaces). media.requests
// is not populated on the list endpoint (it would be circular), so each
// title's request set is rebuilt here.
const index = new Map<number, SeerrRequest[]>();
for (const request of requests) {
const tmdbId = request.media?.tmdbId;
if (typeof tmdbId !== 'number') {
continue;
}
const existing = index.get(tmdbId);
if (existing) {
existing.push(request);
} else {
index.set(tmdbId, [request]);
}
}
cacheManager
.getCache(SEERR_REQUESTS_CACHE_ID)
?.data.set(SEERR_REQUESTS_CACHE_KEY, index);
this.logger.log(
`Seerr request prefetch complete: ${requests.length} requests across ${index.size} titles.`,
);
return index;
}
public async deleteRequest(requestId: string) {
try {
const response: SeerrBasicApiResponse = await this.api.delete(
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ import { MediaServerFactory } from '../../api/media-server/media-server.factory'
import {
SeerrApiService,
SeerrMovieResponse,
SeerrRequest,
SeerrSeasonRequest,
SeerrSeasonResponse,
SeerrTVRequest,
@@ -42,9 +43,6 @@ export class SeerrGetterService {
async get(id: number, libItem: MediaItem, dataType?: MediaItemType) {
try {
let origLibItem: MediaItem = undefined;
let seasonMediaResponse: SeerrSeasonResponse = undefined;
let tvMediaResponse: SeerrTVResponse = undefined;
let movieMediaResponse: SeerrMovieResponse = undefined;
// get original show in case of season / episode
if (dataType === 'season' || dataType === 'episode') {
@@ -63,39 +61,53 @@ export class SeerrGetterService {
);
const tmdbId = resolvedIds?.tmdb as number | undefined;
if (tmdbId) {
if (libItem.type === 'movie') {
movieMediaResponse = await this.seerrApi.getMovie(tmdbId.toString());
} else {
tvMediaResponse = await this.seerrApi.getShow(tmdbId.toString());
if (dataType === 'season' || dataType === 'episode') {
const seasonNumber =
dataType === 'season'
? origLibItem.index
: origLibItem.parentIndex;
seasonMediaResponse = await this.seerrApi.getSeason(
tmdbId.toString(),
seasonNumber?.toString(),
);
if (!seasonMediaResponse) {
this.logger.debug(
`Couldn't fetch season data for '${libItem.title}' season ${seasonNumber} from Seerr. As a result, unreliable results are expected.`,
);
}
}
}
} else {
if (!tmdbId) {
this.logger.debug(
`Couldn't find tmdb id for media '${libItem.title}' with id '${libItem.id}'. As a result, no Seerr query could be made.`,
);
return null;
}
const mediaResponse: SeerrTVResponse | SeerrMovieResponse =
tvMediaResponse ?? movieMediaResponse;
// releaseDate (movie releaseDate / tv firstAirDate / season|episode
// airDate) is not carried by the /request list endpoint, so it keeps the
// per-item getMovie/getShow/getSeason fallback. Accepted limitation —
// releaseDate rules were not part of #3152.
if (prop?.name === 'releaseDate') {
return await this.getReleaseDate(
libItem,
origLibItem,
dataType,
tmdbId,
);
}
// Every other Seerr property derives from the request set. Read the
// run-scoped request index (one bulk /request sweep, deduped + cached)
// instead of a per-item getMovie/getShow — the per-item path rate-limited
// under whole-library runs and silently degraded matches to near-zero
// (#3152).
const requestsForMedia = await this.seerrApi.getRequestsForMedia(tmdbId);
// undefined => the bulk sweep failed (Seerr unreachable). Transient: skip
// so the comparator protects the item rather than treating it as "not
// requested" (mirrors #3125).
if (requestsForMedia === undefined) {
return undefined;
}
// Reconstruct the per-title view the property logic expects. When the
// title has no request the synthetic mediaInfo carries an empty request
// list, so the switch yields the definitive "not requested" values
// (0 / [] / null) — the core #3152 fix (these items previously
// rate-limited to null and were skipped).
const mediaResponse = this.toMediaResponse(requestsForMedia);
const tvMediaResponse =
libItem.type === 'movie'
? undefined
: (mediaResponse as SeerrTVResponse);
const requests = mediaResponse?.mediaInfo?.requests ?? [];
if (mediaResponse?.mediaInfo) {
switch (prop.name) {
switch (prop?.name) {
case 'addUser': {
try {
const userNames: string[] = [];
@@ -150,28 +162,6 @@ export class SeerrGetterService {
? new Date(mediaResponse?.mediaInfo?.requests[0]?.createdAt)
: null;
}
case 'releaseDate': {
if (libItem.type === 'movie') {
return movieMediaResponse?.releaseDate
? new Date(movieMediaResponse?.releaseDate)
: null;
} else {
if (dataType === 'episode') {
const ep = seasonMediaResponse?.episodes?.find(
(el) => el.episodeNumber === origLibItem.index,
);
return ep?.airDate ? new Date(ep.airDate) : null;
} else if (dataType === 'season') {
return seasonMediaResponse?.airDate
? new Date(seasonMediaResponse.airDate)
: null;
} else {
return tvMediaResponse?.firstAirDate
? new Date(tvMediaResponse.firstAirDate)
: null;
}
}
}
case 'approvalDate': {
if (dataType === 'season' || dataType === 'episode') {
const season = this.getSeasonRequests(
@@ -228,9 +218,9 @@ export class SeerrGetterService {
}
}
} else {
this.logger.debug(
`Couldn't fetch Seerr metadata for media '${libItem.title}' with id '${libItem.id}'. As a result, no Seerr query could be made.`,
);
// Defensive only: toMediaResponse always yields a mediaInfo (empty
// request list when the title has none), so this branch is unreachable
// for the index path.
return null;
}
} catch (error) {
@@ -245,6 +235,98 @@ export class SeerrGetterService {
}
}
/**
* Resolves the Seerr release/air date via the per-item getMovie/getShow/
* getSeason endpoints. The bulk /request index carries request data, not the
* TMDB release/air dates, so this property keeps the per-item fallback —
* releaseDate-seeded rules were not part of #3152. A communication failure
* returns `undefined` (transient skip, mirrors #3125); an untracked title
* (no mediaInfo) returns `null`, preserving the prior behavior.
*/
private async getReleaseDate(
libItem: MediaItem,
origLibItem: MediaItem | undefined,
dataType: MediaItemType | undefined,
tmdbId: number,
): Promise<Date | null | undefined> {
let movieMediaResponse: SeerrMovieResponse = undefined;
let tvMediaResponse: SeerrTVResponse = undefined;
let seasonMediaResponse: SeerrSeasonResponse = undefined;
if (libItem.type === 'movie') {
movieMediaResponse = await this.seerrApi.getMovie(tmdbId.toString());
if (movieMediaResponse === undefined) {
return undefined;
}
} else {
tvMediaResponse = await this.seerrApi.getShow(tmdbId.toString());
if (tvMediaResponse === undefined) {
return undefined;
}
if (dataType === 'season' || dataType === 'episode') {
const seasonNumber =
dataType === 'season' ? origLibItem.index : origLibItem.parentIndex;
seasonMediaResponse = await this.seerrApi.getSeason(
tmdbId.toString(),
seasonNumber?.toString(),
);
if (!seasonMediaResponse) {
this.logger.debug(
`Couldn't fetch season data for '${libItem.title}' season ${seasonNumber} from Seerr. As a result, unreliable results are expected.`,
);
}
}
}
const mediaResponse: SeerrTVResponse | SeerrMovieResponse =
tvMediaResponse ?? movieMediaResponse;
if (!mediaResponse?.mediaInfo) {
return null;
}
if (libItem.type === 'movie') {
return movieMediaResponse?.releaseDate
? new Date(movieMediaResponse.releaseDate)
: null;
}
if (dataType === 'episode') {
const ep = seasonMediaResponse?.episodes?.find(
(el) => el.episodeNumber === origLibItem.index,
);
return ep?.airDate ? new Date(ep.airDate) : null;
}
if (dataType === 'season') {
return seasonMediaResponse?.airDate
? new Date(seasonMediaResponse.airDate)
: null;
}
return tvMediaResponse?.firstAirDate
? new Date(tvMediaResponse.firstAirDate)
: null;
}
/**
* Rebuilds the per-title response shape the property switch expects from the
* flat request list returned by the run-scoped index. All requests for one
* tmdbId share the same media (the /request list endpoint populates
* request.media but not media.requests), so any request's media seeds the
* synthetic mediaInfo and the grouped list is attached as its requests. An
* empty list yields a mediaInfo with no requests, so the switch derives the
* definitive "not requested" values.
*/
private toMediaResponse(
requests: SeerrRequest[],
): SeerrTVResponse | SeerrMovieResponse {
const media = requests[0]?.media;
return {
id: media?.id,
mediaInfo: {
...(media ?? {}),
requests,
},
} as SeerrTVResponse | SeerrMovieResponse;
}
private getSeasonRequests(
libItem: MediaItem,
mediaResponse: SeerrTVResponse,
@@ -1733,6 +1733,9 @@ export class RulesService {
const mediaServer = await this.getMediaServer();
mediaServer.resetMetadataCache(mediaId);
cacheManager.getCache('seerr').data.flushAll();
// Drop the run-scoped Seerr request index too, so a single-item test rebuilds
// it from a fresh /request sweep and agrees with a full run (#3152).
cacheManager.getCache('seerrrequests').data.flushAll();
cacheManager.getCache('tautulli').data.flushAll();
cacheManager
.getCachesByType('radarr')
@@ -429,7 +429,7 @@ export class SettingsController {
return this.metadataSettingsService.refreshMetadataCache(provider);
}
// Unified Seerr endpoints (replaces both Overseerr and Jellyseerr)
// Unified Seerr settings endpoints (legacy aliases kept for backward compatibility)
@Get(['/seerr', '/overseerr', '/jellyseerr'])
async getSeerrSetting(): Promise<SeerrSetting | BasicResponseDto> {
const settings = await this.settingsOperationsService.getSettings();
@@ -30,7 +30,7 @@ export interface EmbyLoginButtonProps {
* server-specific auth UX lives outside the settings page.
*
* Emby's auth endpoint is `POST /Users/AuthenticateByName` — verified against
* Emby Server 4.9 and consistent with the Jellyseerr/Seerr Jellyfin client.
* Emby Server 4.9 and consistent with the Seerr Jellyfin client.
*/
const EmbyLoginButton: React.FC<EmbyLoginButtonProps> = ({
embyUrl,
@@ -44,14 +44,14 @@ const SeerrSettings = () => {
scope="Seerr settings"
pageTitle="Seerr settings - Maintainerr"
heading="Seerr Settings"
description="Seerr configuration (also compatible with Overseerr and Jellyseerr)"
description="Seerr configuration"
docsPage="Configuration/#seerr"
settingsPath="/settings/seerr"
testPath="/settings/test/seerr"
schema={SeerrSettingFormSchema}
fields={fields}
testSuccessTitle="Seerr"
testFailureMessage="Failed to connect to Overseerr. Verify URL and API key."
testFailureMessage="Failed to connect to Seerr. Verify URL and API key."
/>
)
}
+3 -4
View File
@@ -99,22 +99,21 @@ A list of all available environment variables are below. No other env variables
# Features
- Configure rules specific to your needs, based on several available options from Plex, Overseerr, Jellyseerr, Radarr, Sonarr and Tautulli.
- Configure rules specific to your needs, based on several available options from Plex, Seerr, Radarr, Sonarr and Tautulli.
- Manually add media to a collection, in case it's not included after rule execution. (one-off items that don't match a rule set)
- Selectively exclude media from being added to a collection, even if it matches a rule.
- Show a collection, containing rule matched media, on the Plex home screen for a specific duration before deletion. Think "Leaving soon".
- Optionally, use a manual Plex collection, in case you don't want <b>Maintainerr</b> to add & remove Plex collections at will.
- Manage media straight from the collection within Plex. <b>Maintainerr</b> will sync and add or exclude media to/from the internal collection.
- Remove or unmonitor media from \*arr
- Clear requests from Overseerr
- Clear requests from Seerr
- Delete files from disk
<br />
Currently, <b>Maintainerr</b> supports rule parameters from these apps :
- Plex
- Overseerr
- Jellyseerr
- Seerr
- Radarr
- Sonarr
- Tautulli
+3 -4
View File
@@ -276,10 +276,9 @@ Emby itself returns:
The original plan included Emby Connect (the emby.media cloud-account flow)
as an MVP feature, citing an `embyconnect.ts` reference implementation in
Jellyseerr. **That file does not exist** — verified via the GitHub API
against both [Seerr](https://github.com/seerr-team/seerr/tree/develop/server/api)
and [Jellyseerr](https://github.com/Fallenbagel/jellyseerr/tree/develop/server/api).
Neither repo has a dedicated Emby Connect module, and neither `jellyfin.ts`
Seerr. **That file does not exist** — verified via the GitHub API against
[Seerr](https://github.com/seerr-team/seerr/tree/develop/server/api).
The repo has no dedicated Emby Connect module, and `jellyfin.ts`
contains any references to `api.emby.media`, `/service/`, or
`X-Connect-UserToken`.
+1 -1
View File
@@ -87,7 +87,7 @@ export const ApplicationNames: Record<Application, string> = {
}
/**
* Media status for Overseerr/Jellyseerr requests
* Media status for Seerr requests
*/
export enum RequestMediaStatus {
UNKNOWN = 1,
+44 -2
View File
@@ -23,6 +23,7 @@
* FAKE_EMBY_PORT=8097 FAKE_EMBY_LOG=1 node tools/dev/fake-emby.mjs
*/
import http from 'node:http';
import { buildScaleLibrary } from './lib/scale-library.mjs';
const PORT = Number(process.env.FAKE_EMBY_PORT ?? 8097);
const LOG = process.env.FAKE_EMBY_LOG === '1';
@@ -66,8 +67,37 @@ const SHARED_BOXSET = {
Overview: 'Shared manual collection (mock)',
};
// --- Optional large library for Seerr whole-library scale tests (#3152) ----------
// Off unless FAKE_SCALE>0 (see lib/scale-library.mjs). Real tmdb ProviderId, no
// ProductionYear (so the metadata resolver accepts the id without a year check).
// Shared with fake-plex/fake-jellyfin so the item set is identical across
// backends — note this is the only movie content this Emby mock serves.
const SCALE = buildScaleLibrary();
const scaleMovie = (it) => ({
Id: it.key,
Name: it.title,
Type: 'Movie',
ParentId: 'emby-movies',
DateCreated: ISO('2026-01-01'),
ProviderIds: { Tmdb: String(it.tmdbId) },
MediaSources: [{ Id: it.key, Size: 1_000_000_000, Container: 'mkv' }],
});
const scaleSeries = (it) => ({
Id: it.key,
Name: it.title,
Type: 'Series',
ParentId: 'emby-shows',
DateCreated: ISO('2026-01-01'),
ProviderIds: { Tmdb: String(it.tmdbId) },
});
const SCALE_MOVIES = SCALE.movies.map(scaleMovie);
const SCALE_SHOWS = SCALE.shows.map(scaleSeries);
const ITEMS_BY_ID = new Map(
[...SHOWS, SHARED_BOXSET].map((item) => [item.Id, item]),
[...SHOWS, SHARED_BOXSET, ...SCALE_MOVIES, ...SCALE_SHOWS].map((item) => [
item.Id,
item,
]),
);
// --- HTTP helpers ----------------------------------------------------------------
@@ -84,6 +114,15 @@ const itemsResponse = (items) => ({
TotalRecordCount: items.length,
StartIndex: 0,
});
// Honors PascalCase StartIndex/Limit so the adapter's offset loop terminates at
// scale instead of re-fetching the full set per page. Only matters past one page.
function pagedItems(items, u) {
const start = Number(u.searchParams.get('StartIndex')) || 0;
const limRaw = u.searchParams.get('Limit');
const lim = limRaw ? Number(limRaw) : items.length;
const slice = items.slice(start, start + (lim || items.length));
return { Items: slice, TotalRecordCount: items.length, StartIndex: start };
}
const SYSTEM_INFO = {
Id: 'mockembyserver',
@@ -154,8 +193,11 @@ const server = http.createServer((req, res) => {
}
return send(res, 200, itemsResponse([]));
}
if (parentId === 'emby-movies' || itemTypes === 'Movie') {
return send(res, 200, pagedItems(SCALE_MOVIES, u));
}
if (parentId === 'emby-shows' || itemTypes === 'Series') {
return send(res, 200, itemsResponse(SHOWS));
return send(res, 200, pagedItems([...SHOWS, ...SCALE_SHOWS], u));
}
return send(res, 200, itemsResponse([]));
}
+61 -3
View File
@@ -24,6 +24,7 @@
* needed — just start this before (or alongside) `yarn dev`.
*/
import http from 'node:http';
import { buildScaleLibrary } from './lib/scale-library.mjs';
const PORT = Number(process.env.FAKE_JELLYFIN_PORT ?? 8096);
const LOG = process.env.FAKE_JELLYFIN_LOG === '1';
@@ -142,8 +143,52 @@ const SHARED_BOXSET = {
ImageTags: { Primary: 'mocktag' },
};
// --- Optional large library for Seerr whole-library scale tests (#3152) ----------
// Off unless FAKE_SCALE>0 (see lib/scale-library.mjs). Items carry a real tmdb
// ProviderId and NO ProductionYear, so the metadata resolver accepts the direct
// id without a year cross-check. Shared with fake-plex/fake-emby so the item set
// is identical across backends.
const SCALE = buildScaleLibrary();
const scaleMovie = (it) => ({
Id: it.key,
Name: it.title,
Type: 'Movie',
ServerId: 'mockserver',
ParentId: 'jellyfin-movies',
DateCreated: ISO('2026-01-01'),
ProviderIds: { Tmdb: String(it.tmdbId) },
ImageTags: { Primary: 'mocktag' },
MediaSources: [
{
Id: it.key,
Size: 1_000_000_000,
Container: 'mkv',
MediaStreams: [
{ Type: 'Video', Codec: 'h264', Width: 1920, Height: 1080 },
{ Type: 'Audio', Codec: 'aac', Channels: 6 },
],
},
],
UserData: { PlayCount: 0, Played: false, PlayedPercentage: 0, IsFavorite: false },
});
const scaleSeries = (it) => ({
Id: it.key,
Name: it.title,
Type: 'Series',
ServerId: 'mockserver',
ParentId: 'jellyfin-shows',
DateCreated: ISO('2026-01-01'),
ProviderIds: { Tmdb: String(it.tmdbId) },
ImageTags: { Primary: 'mocktag' },
UserData: { PlayCount: 0, Played: false, PlayedPercentage: 0, IsFavorite: false },
});
const SCALE_MOVIES = SCALE.movies.map(scaleMovie);
const SCALE_SHOWS = SCALE.shows.map(scaleSeries);
const ITEMS_BY_ID = new Map(
[...MOVIES, ...SHOWS, SHARED_BOXSET].map((item) => [item.Id, item]),
[...MOVIES, ...SHOWS, SHARED_BOXSET, ...SCALE_MOVIES, ...SCALE_SHOWS].map(
(item) => [item.Id, item],
),
);
// --- HTTP helpers ----------------------------------------------------------------
@@ -178,6 +223,19 @@ function itemsResponse(items) {
return { Items: items, TotalRecordCount: items.length, StartIndex: 0 };
}
// Honors startIndex/limit so the adapter's pagination loop terminates at scale
// (jellyfin-adapter breaks on items.length < pageSize || startIndex >= total).
// Only matters once a library exceeds one page.
function pagedItems(items, u) {
const start =
Number(u.searchParams.get('startIndex') ?? u.searchParams.get('StartIndex')) ||
0;
const limRaw = u.searchParams.get('limit') ?? u.searchParams.get('Limit');
const lim = limRaw ? Number(limRaw) : items.length;
const slice = items.slice(start, start + (lim || items.length));
return { Items: slice, TotalRecordCount: items.length, StartIndex: start };
}
const server = http.createServer((req, res) => {
const u = new URL(req.url, `http://localhost:${PORT}`);
const path = u.pathname;
@@ -255,10 +313,10 @@ const server = http.createServer((req, res) => {
return send(res, 200, itemsResponse([]));
}
if (parentId === 'jellyfin-movies' || itemTypes === 'Movie') {
return send(res, 200, itemsResponse(MOVIES));
return send(res, 200, pagedItems([...MOVIES, ...SCALE_MOVIES], u));
}
if (parentId === 'jellyfin-shows' || itemTypes === 'Series') {
return send(res, 200, itemsResponse(SHOWS));
return send(res, 200, pagedItems([...SHOWS, ...SCALE_SHOWS], u));
}
// Episodes, etc. -> empty for now
return send(res, 200, itemsResponse([]));
+47 -3
View File
@@ -36,6 +36,7 @@
* re-discovery).
*/
import http from 'node:http';
import { buildScaleLibrary } from './lib/scale-library.mjs';
const PORT = Number(process.env.FAKE_PLEX_PORT ?? 32400);
const LOG = process.env.FAKE_PLEX_LOG === '1';
@@ -199,6 +200,31 @@ const EPISODES = [
const ALL_ITEMS = [...MOVIES, SHOW, ...SEASONS, ...EPISODES];
const ITEMS_BY_ID = new Map(ALL_ITEMS.map((m) => [m.ratingKey, m]));
// --- Optional large library for Seerr whole-library scale tests (#3152) ------
// Off unless FAKE_SCALE>0 (see lib/scale-library.mjs). Each item carries only a
// tmdb Guid and no `year`, so the metadata resolver accepts the direct id
// without a year cross-check. Shared with fake-jellyfin/fake-emby so the item
// set is identical across backends.
const SCALE = buildScaleLibrary();
const scaleItem = (it) => ({
ratingKey: it.key,
key: `/library/metadata/${it.key}`,
guid: `plex://${it.type}/${it.key}`,
type: it.type,
title: it.title,
librarySectionID: it.type === 'movie' ? 1 : 2,
librarySectionKey: `/library/sections/${it.type === 'movie' ? 1 : 2}`,
addedAt: daysAgo(30),
updatedAt: daysAgo(30),
Media: [media()],
Guid: [{ id: `tmdb://${it.tmdbId}` }],
});
const SCALE_MOVIES = SCALE.movies.map(scaleItem);
const SCALE_SHOWS = SCALE.shows.map(scaleItem);
for (const it of [...SCALE_MOVIES, ...SCALE_SHOWS]) {
ITEMS_BY_ID.set(it.ratingKey, it);
}
// children: show -> [seasons], season 1 -> [episodes]
const CHILDREN = {
sh1: SEASONS,
@@ -257,6 +283,19 @@ function send(res, status, body) {
const container = (extra) => ({ MediaContainer: { size: 0, ...extra } });
const list = (key, items) =>
container({ size: items.length, totalSize: items.length, [key]: items });
// Honors X-Plex-Container-Start/Size so the adapter's pagination loop (plexApi.ts,
// size 120, loops while totalSize > size*(page+1)) terminates instead of
// re-fetching the full set per page — only matters once a library exceeds 120.
function sendPaged(res, req, items) {
const start = Number(req.headers['x-plex-container-start']) || 0;
const size = Number(req.headers['x-plex-container-size']) || items.length;
const slice = items.slice(start, start + size);
return send(
res,
200,
container({ size: slice.length, totalSize: items.length, Metadata: slice }),
);
}
const server = http.createServer((req, res) => {
const u = new URL(req.url, `http://localhost:${PORT}`);
@@ -310,12 +349,17 @@ const server = http.createServer((req, res) => {
// season-scoped rule group enumerates seasons, not the show.
const allMatch = path.match(/^\/library\/sections\/([^/]+)\/all$/);
if (allMatch) {
let items = MOVIES;
let items = [...MOVIES, ...SCALE_MOVIES];
if (allMatch[1] === '2') {
const type = u.searchParams.get('type');
items = type === '3' ? SEASONS : type === '4' ? EPISODES : [SHOW];
items =
type === '3'
? SEASONS
: type === '4'
? EPISODES
: [SHOW, ...SCALE_SHOWS];
}
return send(res, 200, list('Metadata', items));
return sendPaged(res, req, items);
}
// Section collections: /library/sections/:id/collections
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env node
/**
* Dev-only mock Seerr (v1 API) for Maintainerr.
*
* Maintainerr's Seerr client (apps/server/.../seerr-api/) talks to a real Seerr
* over HTTP under /api/v1. The media-server mocks don't cover it, so the Seerr
* getter path (isRequested / amountRequested / requestDate / addUser /
* approvalDate / mediaAddedAt / releaseDate, at movie and season/episode level)
* can't be exercised from a DB seed alone. This stub answers the endpoints that
* path needs so the whole thing can be driven without a real Seerr.
*
* It exists to demonstrate the fix for #3152: under a whole-library run the
* old getter made a per-item GET /movie|/tv call, which rate-limited and made
* Seerr-seeded rules silently match almost nothing. The fix replaces those with
* ONE bulk GET /request sweep per run. To prove that, this mock can be put in a
* "flaky" mode (FAKE_SEERR_FLAKY=1) where the per-item /movie and /tv endpoints
* fail with 429/503 while /request stays healthy — so the bulk path keeps
* working and the per-item path collapses.
*
* Faithful to a real Seerr's /request shape (verified against a live instance):
* - GET /request is paginated ({ pageInfo:{page,pages,pageSize,results}, results })
* - each result carries request.media (tmdbId, status, updatedAt, mediaAddedAt)
* - each TV request carries request.seasons[] ({ seasonNumber, status })
* - media.requests is NOT populated on the list endpoint (would be circular)
*
* It is intentionally minimal and invented — no real media names (repo rule).
* Titles are generic; tmdbIds are plain numbers (like fake-radarr). The set of
* "requested" tmdbIds is configurable so it can be paired with whatever media
* server library is under test.
*
* Usage
* -----
* node tools/dev/fake-seerr.mjs # listens on :5055
* FAKE_SEERR_PORT=5055 node tools/dev/fake-seerr.mjs
* FAKE_SEERR_FLAKY=1 node tools/dev/fake-seerr.mjs # /movie and /tv rate-limit
* FAKE_SEERR_TMDB_IDS=603,1396,1408 node tools/dev/fake-seerr.mjs # explicit ids
* FAKE_SEERR_LOG=0 node tools/dev/fake-seerr.mjs # silence the request log
*
* The dev seed (tools/dev/seed-db.mjs) points settings.seerr_url at
* http://localhost:5055, so no settings change is needed — just start this
* before (or alongside) `yarn dev`.
*/
import http from 'node:http';
const PORT = Number(process.env.FAKE_SEERR_PORT ?? 5055);
const LOG = process.env.FAKE_SEERR_LOG !== '0';
const FLAKY = process.env.FAKE_SEERR_FLAKY === '1';
// The tmdbIds that have a request. Pairs with the media server library under
// test: any library item resolving to one of these ids is "requested".
// - FAKE_SEERR_TMDB_IDS=603,1396,... : explicit list
// - default : a deterministic synthetic range that
// yields >120 requests across >1 page
const REQUESTED_TMDB_IDS = (() => {
const explicit = process.env.FAKE_SEERR_TMDB_IDS;
if (explicit) {
return explicit
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
}
// 130 synthetic ids: 90 movies (700001..700090) + 40 shows (710001..710040).
const ids = [];
for (let i = 1; i <= 90; i++) ids.push(700000 + i);
for (let i = 1; i <= 40; i++) ids.push(710000 + i);
return ids;
})();
// Three Seerr user shapes so addUser exercises every userType resolution path
// (Plex / local / Jellyfin). Names are invented.
const USERS = [
{
id: 1,
userType: 1,
plexUsername: 'devseed-plex',
username: 'devseed-plex@example.test',
displayName: 'devseed-plex',
},
{
id: 2,
userType: 2,
username: 'devseed-local',
plexUsername: '',
displayName: 'devseed-local',
},
{
id: 3,
userType: 3,
jellyfinUsername: 'devseed-jelly',
username: 'devseed-jelly@example.test',
displayName: 'devseed-jelly',
},
];
const ISO = (daysAgo) =>
new Date(Date.now() - daysAgo * 86_400_000).toISOString();
// Seerr request/media status enums (mirror the codebase):
// request.status: 1 PENDING, 2 APPROVED, 3 DECLINED, 4 FAILED, 5 COMPLETED
// media.status: 1 UNKNOWN, 2 PENDING, 3 PROCESSING, 4 PARTIALLY_AVAILABLE, 5 AVAILABLE
const REQUEST_APPROVED = 2;
const MEDIA_AVAILABLE = 5;
const MEDIA_PROCESSING = 3;
// Build the flat request list once. Most titles get a single request; every
// 7th gets a second request from another user (so amountRequested > 1 and
// addUser de-dupes/aggregates). Roughly half are "available" (media.status 5,
// so approvalDate/mediaAddedAt resolve) and half still processing (status 3,
// so those date props are null) — exercises the status gate.
function buildRequests() {
const requests = [];
let requestId = 1;
REQUESTED_TMDB_IDS.forEach((tmdbId, index) => {
const isShow = tmdbId >= 710000 && tmdbId < 720000;
const available = index % 2 === 0;
const media = {
id: 1000 + index,
tmdbId,
tvdbId: isShow ? 800000 + index : 0,
status: available ? MEDIA_AVAILABLE : MEDIA_PROCESSING,
updatedAt: ISO(index % 30),
mediaAddedAt: available ? ISO(index % 30) : null,
};
const requesterCount = index % 7 === 0 ? 2 : 1;
for (let r = 0; r < requesterCount; r++) {
const user = USERS[(index + r) % USERS.length];
const base = {
id: requestId++,
status: REQUEST_APPROVED,
createdAt: ISO((index % 30) + 1),
updatedAt: ISO(index % 30),
requestedBy: user,
modifiedBy: user,
is4k: false,
serverId: 1,
profileId: 1,
rootFolder: isShow ? '/tv' : '/movies',
media,
};
if (isShow) {
// First request covers seasons 1-2, the second (if any) season 3 — so a
// season-scoped rule can match a specific season's request.
const seasonNumbers = r === 0 ? [1, 2] : [3];
requests.push({
...base,
type: 'tv',
seasons: seasonNumbers.map((seasonNumber) => ({
id: seasonNumber,
seasonNumber,
status: REQUEST_APPROVED,
})),
});
} else {
requests.push({ ...base, type: 'movie' });
}
}
});
return requests;
}
const ALL_REQUESTS = buildRequests();
// --- Per-item detail payloads (releaseDate fallback path) ------------------------
// releaseDate is NOT derivable from /request, so the getter still calls these.
// They are the endpoints made flaky to reproduce #3152.
const movieDetail = (tmdbId) => ({
id: tmdbId,
mediaInfo: { tmdbId, status: MEDIA_AVAILABLE },
releaseDate: '2020-05-01',
});
const tvDetail = (tmdbId) => ({
id: tmdbId,
mediaInfo: { tmdbId, status: MEDIA_AVAILABLE },
firstAirDate: '2019-09-01',
seasons: [
{ id: 1, name: 'Season 1', seasonNumber: 1, airDate: '2019-09-01', episodes: [] },
{ id: 2, name: 'Season 2', seasonNumber: 2, airDate: '2020-09-01', episodes: [] },
],
});
const seasonDetail = (seasonNumber) => ({
id: seasonNumber,
name: `Season ${seasonNumber}`,
seasonNumber,
airDate: '2020-09-01',
episodes: [
{ id: 1, name: 'Episode 1', seasonNumber, episodeNumber: 1, airDate: '2020-09-01' },
],
});
const send = (res, status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(body === undefined ? '' : JSON.stringify(body));
return status;
};
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
const path = url.pathname.replace(/^\/api\/v1/, ''); // client base ends in /api/v1
const method = req.method ?? 'GET';
let status;
// --- Connection test -----------------------------------------------------
if (method === 'GET' && path === '/settings/about') {
status = send(res, 200, {
version: '2.0.0-fake',
totalMediaItems: REQUESTED_TMDB_IDS.length,
totalRequests: ALL_REQUESTS.length,
});
} else if (method === 'GET' && path === '/status') {
status = send(res, 200, {
version: '2.0.0-fake',
commitTag: 'fake',
updateAvailable: false,
commitsBehind: 0,
});
// --- Bulk request sweep (the fix path; stays healthy even when flaky) ---
} else if (method === 'GET' && path === '/request') {
// Honor `take` as-is — real Seerr imposes no upper bound (default 10).
const take = Number(url.searchParams.get('take')) || 10;
const skip = Number(url.searchParams.get('skip')) || 0;
const pageItems = ALL_REQUESTS.slice(skip, skip + take);
const pages = Math.ceil(ALL_REQUESTS.length / take);
status = send(res, 200, {
pageInfo: {
page: Math.floor(skip / take) + 1,
pages,
pageSize: take,
results: ALL_REQUESTS.length,
},
results: pageItems,
});
// --- Per-item detail (releaseDate fallback) — flaky to reproduce #3152 --
} else if (method === 'GET' && /^\/movie\/\d+$/.test(path)) {
if (FLAKY) {
status = send(res, 429, { message: 'Rate limit exceeded (fake-seerr flaky)' });
} else {
status = send(res, 200, movieDetail(Number(path.split('/')[2])));
}
} else if (method === 'GET' && /^\/tv\/\d+$/.test(path)) {
if (FLAKY) {
status = send(res, 503, { message: 'Service unavailable (fake-seerr flaky)' });
} else {
status = send(res, 200, tvDetail(Number(path.split('/')[2])));
}
} else if (method === 'GET' && /^\/tv\/\d+\/season\/\d+$/.test(path)) {
if (FLAKY) {
status = send(res, 503, { message: 'Service unavailable (fake-seerr flaky)' });
} else {
status = send(res, 200, seasonDetail(Number(path.split('/')[4])));
}
// --- Users (paginated, like the real /user) ------------------------------
} else if (method === 'GET' && path === '/user') {
const take = Number(url.searchParams.get('take')) || 50;
const skip = Number(url.searchParams.get('skip')) || 0;
status = send(res, 200, {
pageInfo: {
page: Math.floor(skip / take) + 1,
pages: 1,
pageSize: take,
results: USERS.length,
},
results: USERS,
});
// --- Writes other flows may probe (request/media deletion) ---------------
} else if (method === 'DELETE') {
status = send(res, 200, { code: 'ok', description: 'deleted (fake-seerr)' });
} else {
status = send(res, 404, { message: 'Not found in fake-seerr' });
}
if (LOG) console.log(`${method} ${url.pathname}${url.search} -> ${status}`);
});
server.listen(PORT, () => {
const movies = REQUESTED_TMDB_IDS.filter((id) => id < 710000 || id >= 720000).length;
const shows = REQUESTED_TMDB_IDS.length - movies;
console.log(`fake-seerr listening on http://localhost:${PORT} (api/v1)`);
console.log(
` ${ALL_REQUESTS.length} requests across ${REQUESTED_TMDB_IDS.length} titles ` +
`(${movies} movie / ${shows} tv); GET /request paginates.`,
);
console.log(
FLAKY
? ' FLAKY mode: GET /movie/:id and /tv/:id return 429/503; /request stays healthy.'
: ' Healthy mode: set FAKE_SEERR_FLAKY=1 to rate-limit the per-item endpoints.',
);
});
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Shared large-library fixture for exercising the Seerr getter at whole-library
* scale (issue #3152) against a REAL Seerr. Every media-server fake
* (fake-plex/jellyfin/emby) imports this, so the item set is byte-identical
* across backends: one Seerr rule yields the same matches whether Maintainerr
* points at Plex, Jellyfin, or Emby.
*
* Why these exact ids:
* - tmdbIds are REAL. The metadata resolver (metadata.service) validates each
* direct id against TMDB before the Seerr lookup, so synthetic ids would 404
* and the item would be skipped. Items therefore also OMIT year — the
* resolver accepts a direct id without a year cross-check when the item has
* no year (avoids false rejects on year drift).
* - MATCH_* ids are actually requested in the dev Seerr (seeded via the API),
* so the bulk GET /request index matches them -> "requested".
* - FILLER_* ids are real but NOT requested, so they resolve cleanly to "not
* requested" (definitive 0/[]), reproducing a large library where only a
* minority is requested.
* Titles are invented (repo rule: no real media names).
*
* Enable inside a fake with FAKE_SCALE=<movieCount> (and optional
* FAKE_SCALE_TV=<showCount>); 0/unset leaves the fake's small dataset untouched.
*/
// Canonical pairing: run this fixture against a REAL dev Seerr seeded with the
// MATCH ids below (not fake-seerr, which is a separate flaky-reproduction
// harness). When testing against fake-seerr instead, mirror these ids into
// FAKE_SEERR_TMDB_IDS so the same items resolve to "requested".
//
// Real TMDB ids requested in the dev Seerr (the bulk /request index matches these).
const MATCH_MOVIE_IDS = [862, 11012, 28322, 83533, 301528, 424711, 454639, 479787, 614945, 687163, 912378, 931285, 936075, 949536, 969681, 976912, 1007757, 1057265, 1081003, 1083381, 1084242, 1084244, 1103473, 1122573, 1127384, 1219739, 1226293, 1226863, 1227241, 1228710, 1239134, 1241752, 1275779, 1280738, 1288341, 1293550, 1297842, 1304313, 1308553, 1311031, 1318413, 1318447, 1327819, 1339713, 1358005, 1367220, 1380291, 1392469, 1419406, 1430077, 1431068, 1439930, 1451344, 1462591, 1477317, 1486860, 1522126, 1523145, 1582770, 1694978];
const MATCH_TV_IDS = [456, 549, 764, 1396, 1399, 1408, 1416, 1431, 1434, 1622, 1911, 2734, 4057, 4614, 5920, 33238, 34307, 46952, 60625, 65334, 66732, 71790, 73586, 76479, 79744, 94997, 95897, 107447, 124364, 218613, 232393, 233643, 241002, 273240, 276161, 276880, 278178, 284631, 284725, 288603, 299167];
// Real TMDB ids NOT requested -> resolve to "not requested".
const FILLER_MOVIE_IDS = [980431, 157336, 222517, 278, 1291608, 1351908, 1245398, 39254, 350, 755898, 1290821, 1536233, 1273221, 863, 1679791, 840464, 920728, 1159559, 1294203, 1472951, 137051, 48650, 299536, 1699820, 687259, 1010581, 1242898, 10193, 1292415, 1368166, 235271, 1340206, 980477, 1305781, 1510767, 1628448, 1002398, 1202285, 1156593, 744275, 1265609, 1315772, 1463681, 1246049, 24428, 140638, 1171145, 1375646, 1117898, 1156594, 1198994, 226674, 1658216, 1564614, 1413976, 337167, 1292695, 847742, 238, 1325734, 1428990, 1092936, 979, 1480259, 17073, 1667198, 1363387, 1630423, 1613798, 1314481, 808, 671, 4258, 122, 460465, 1433117, 680, 1220522, 1584215, 533535, 120, 803796, 432123, 1515729, 1579, 1368337, 1669051, 299534, 1061474, 405871, 1558796, 1266127, 47612, 1168190, 315635, 496243, 27, 5082, 1266990, 1157008, 14836, 249397, 1599189, 1330021, 1110034, 1671548, 1560681, 27205, 911430, 4248, 129, 1475803, 1084577, 597, 1233413, 585, 519182, 1272837, 240, 1146058, 19995, 1135873, 1539104, 1284016, 1641319, 569094, 372058, 1507097, 39688, 425274, 106646, 1372, 550, 672, 269955, 673, 1380126, 575265, 1301421, 216015, 875828, 769, 22, 1241982, 674, 1083884, 91269, 1554631, 8587, 1234731, 429617, 1022789, 4935, 121, 1598785, 99861, 1699155, 1600735, 1084187, 7451, 1646787, 1316427, 767, 1320660, 829557, 414906, 950387, 18, 792723, 497, 1470130, 502356, 77338, 98, 675, 1213898, 939243, 16869, 1218925, 1411773, 1688935, 1284465, 1087192, 872585, 13333, 185664, 150540, 1274706, 47971, 933260, 10138, 1210938, 1383731, 1197306, 1290417, 1514125, 324857, 807, 12153, 680493, 1227877, 1316092, 285, 537915, 405746, 1726, 1408208, 1306368, 335984, 2105, 693134, 1414413, 1119449, 1329471, 424, 11036, 256835, 617126, 58, 1116201, 209112, 106, 1597280, 438631, 9806, 1621114, 857041, 1446056, 4257, 13078, 12444, 1706197, 177572, 329505, 77429, 829560, 578, 1556616, 1930, 1011985, 1312867, 1196067, 1530510, 10895, 76600, 1669057, 991494, 13, 1397485, 411, 12445, 11, 1359005, 912649, 564, 354912, 796, 1108427, 533533, 640, 269149, 324786, 286217, 272, 385687, 1003596, 1417285, 389, 37165, 2062, 37169, 38, 12, 10681, 1628367, 1236153, 1549519];
const FILLER_TV_IDS = [1412, 320118, 95479, 45952, 70998, 3172, 4604, 37680, 1433, 85552, 1973, 90966, 1668, 60735, 13805, 315038, 45140, 65733, 502, 44217, 2288, 22980, 1402, 1421, 322430, 44006, 36, 59941, 1405, 324723, 223911, 2661, 4607, 98031, 250596, 65942, 40, 32798, 1419, 693, 2224, 63770, 65494, 63174, 108978, 4629, 1428, 280932, 18165, 71712, 80748, 61818, 90388, 288659, 57243, 2316, 94722, 60574, 1620, 220102, 74561, 63247, 2261, 94664, 57532, 32692, 60059, 113360, 270476, 296285, 1400, 1398, 1407, 154770, 30984, 75219, 39351, 1435, 200709, 260592, 60572, 120089, 261145, 31132, 209867, 206559, 299989, 4177, 4419, 1516, 56570, 58841, 226637, 2691, 311858, 106449, 71728, 17610, 873, 217772, 97072, 269, 60622, 1981, 1411, 12971, 13859, 4601, 220542, 46296, 41956, 10083, 46260, 4087, 70672, 45247, 211288, 3022, 288577, 194, 45790, 322649, 4515, 219971, 4630, 14944, 259140, 1409, 12225, 31910, 1021, 4656, 1413, 37678, 74428, 15260, 2593, 1695, 2122, 218559, 1415, 13945, 48891, 220150, 1395, 34524, 224, 2406, 88580, 46195, 79481, 240407, 76572, 39185, 4588, 255483, 84105, 900, 62650, 105903, 1220, 1104, 32726, 127529, 2190, 5368, 13887, 3346, 790, 300054, 4556, 81044, 4239, 212568, 3034, 78191, 69478, 245215, 1100, 283123, 40663, 127532, 615, 95603, 1508, 101172, 1667, 290138, 1636, 48866, 276609, 14424];
export const SCALE_MOVIE_IDS = [...MATCH_MOVIE_IDS, ...FILLER_MOVIE_IDS];
export const SCALE_TV_IDS = [...MATCH_TV_IDS, ...FILLER_TV_IDS];
const MATCH_MOVIES = new Set(MATCH_MOVIE_IDS);
const MATCH_TV = new Set(MATCH_TV_IDS);
const pad = (n) => String(n).padStart(4, '0');
// Returns server-neutral items: { key, type, tmdbId, title, requested }.
// `movies`/`shows` are clamped to the available real-id pools.
export function buildScaleLibrary({
movies = Number(process.env.FAKE_SCALE ?? 0),
shows = Number(process.env.FAKE_SCALE_TV ?? 0),
} = {}) {
const mk = (ids, matchSet, n, type, prefix) =>
ids.slice(0, Math.max(0, Math.min(n, ids.length))).map((tmdbId, i) => ({
key: `${prefix}${pad(i + 1)}`,
type,
tmdbId,
// The "#" prefix keeps the trailing digits from being read as a release
// year by the metadata resolver (a space + 4 digits would parse as a year
// and then disagree with TMDB, rejecting the otherwise-valid direct id).
title: `Scale ${type === 'movie' ? 'Movie' : 'Show'} #${pad(i + 1)}`,
requested: matchSet.has(tmdbId),
}));
return {
movies: mk(SCALE_MOVIE_IDS, MATCH_MOVIES, movies, 'movie', 'sclm'),
shows: mk(SCALE_TV_IDS, MATCH_TV, shows, 'show', 'sclt'),
};
}
+136 -3
View File
@@ -8,11 +8,12 @@
* and rule groups whose rules cover (almost) the whole rule-property surface
* for the active media server.
*
* This is the only one of the three dev scripts that touches the DB; the
* companion mocks are stateless HTTP servers:
* This is the only one of the dev scripts that touches the DB; the companion
* mocks are stateless HTTP servers:
* - tools/dev/fake-jellyfin.mjs (mock Jellyfin, :8096) — pairs with MEDIA_SERVER=jellyfin
* - tools/dev/fake-emby.mjs (mock Emby, :8097) — pairs with MEDIA_SERVER=emby
* - tools/dev/fake-plex.mjs (mock Plex, :32400) — pairs with MEDIA_SERVER=plex
* - tools/dev/fake-seerr.mjs (mock Seerr, :5055) — evaluates the Seerr rule groups
*
* Notes
* -----
@@ -239,6 +240,20 @@ const run = db.transaction(() => {
});
}
// 2b) Seerr (request service) — points at tools/dev/fake-seerr.mjs (same for
// every target). Pair with `node tools/dev/fake-seerr.mjs`; the Seerr rule
// groups below evaluate against it. The Seerr getter resolves each library
// item to a tmdbId, so matching needs items that resolve to a tmdbId Seerr
// has a request for — point fake-seerr's FAKE_SEERR_TMDB_IDS at the
// library's real tmdbIds (and use the built-in TMDB key, i.e. leave
// tmdb_api_key empty, so those ids resolve).
db.prepare(
`UPDATE settings SET seerr_url = @url, seerr_api_key = @key WHERE id = 1`,
).run({
url: 'http://localhost:5055',
key: 'devseed000000000000000000000seerr',
});
// 3) Radarr / Sonarr settings rows.
const radarrId = db
.prepare(
@@ -433,6 +448,121 @@ const run = db.transaction(() => {
);
}
// 4c) #3152 repro: Seerr-seeded rule groups. The Seerr getter now reads ONE
// bulk /request sweep per run (tools/dev/fake-seerr.mjs) instead of a
// per-item /movie|/tv call per item — those rate-limited under a whole
// library run and made Seerr-seeded rules match almost nothing. Run with
// FAKE_SEERR_FLAKY=1 to reproduce the rate-limiting the bulk sweep is
// immune to. A full run and POST /api/rules/test must agree (the issue's
// key symptom was test-vs-bulk disagreement).
const SEERR = 3; // Application.SEERR
const seerrCol = (title, description, libraryId, type) =>
insCollection.run({
libraryId,
title,
description,
type,
mediaServerType: TARGET,
deleteAfterDays: 30,
visibleOnHome: 0,
arrAction: 4, // DO_NOTHING
listExclusions: 0,
radarrSettingsId: null,
sonarrSettingsId: null,
handledMediaAmount: 0,
handledMediaSizeBytes: 0,
totalSizeBytes: 0,
lastDuration: 0,
addDate: daysAgo(30),
}).lastInsertRowid;
// Movie group: Seerr isRequested == False (find unrequested movies). BOOL=3,
// RulePossibility.EQUALS=2; BOOL false encodes as "0" (comparator coerces it).
const seerrMovieCol = seerrCol(
'Seerr Unrequested Movies',
'Seerr isRequested == False (#3152).',
LIB.movie,
'movie',
);
const seerrMovieGroup = insRuleGroup.run(
'Seerr Unrequested Movies',
'Seerr isRequested == False (#3152).',
LIB.movie,
seerrMovieCol,
'movie',
).lastInsertRowid;
insRule.run(
seerrMovieGroup,
JSON.stringify({
customVal: { ruleTypeId: 3, value: '0' },
operator: null,
firstVal: [SEERR, 6], // isRequested
action: 2, // EQUALS
section: 0,
}),
);
// Season group: Seerr isRequested == True at season level, so the getter's
// season path (per-request seasons[] from the /request sweep) runs end-to-end.
const seerrSeasonCol = seerrCol(
'Seerr Requested Seasons',
'Seerr isRequested == True, season level (#3152).',
LIB.show,
'season',
);
const seerrSeasonGroup = insRuleGroup.run(
'Seerr Requested Seasons',
'Seerr isRequested == True, season level (#3152).',
LIB.show,
seerrSeasonCol,
'season',
).lastInsertRowid;
insRule.run(
seerrSeasonGroup,
JSON.stringify({
customVal: { ruleTypeId: 3, value: '1' },
operator: null,
firstVal: [SEERR, 6], // isRequested
action: 2, // EQUALS
section: 0,
}),
);
// One movie group per remaining request-derived property (and the per-item
// releaseDate fallback), each EXISTS so the getter resolves every property the
// bulk index feeds — matching the property list in the #3152 commit and making
// the live POST /api/rules/test matrix reproducible from a clean seed.
const seerrMovieProps = [
[0, 'addUser'],
[1, 'requestDate'],
[2, 'releaseDate'], // per-item getMovie fallback, not the request index
[3, 'approvalDate'],
[4, 'mediaAddedAt'],
[5, 'amountRequested'],
];
for (const [propId, propName] of seerrMovieProps) {
const description = `Seerr ${propName} EXISTS (#3152).`;
const col = seerrCol(`Seerr ${propName}`, description, LIB.movie, 'movie');
const group = insRuleGroup.run(
`Seerr ${propName}`,
description,
LIB.movie,
col,
'movie',
).lastInsertRowid;
insRule.run(
group,
JSON.stringify({
// EXISTS ignores the operand, so ruleTypeId is irrelevant here.
customVal: { ruleTypeId: 0, value: '' },
operator: null,
firstVal: [SEERR, propId],
action: 18, // EXISTS — resolves the property without a comparison operand
section: 0,
}),
);
}
// 5) Cron schedules (Settings handlers + one per-group override + overlays).
db.prepare(
`UPDATE settings SET collection_handler_job_cron = ?, rules_handler_job_cron = ? WHERE id = 1`,
@@ -536,7 +666,10 @@ console.log(
`Media server set to ${TARGET === "plex" ? "Plex" : TARGET === "emby" ? "Emby" : "Jellyfin"} (dev seed); Radarr + Sonarr configured.`,
);
console.log(
"Also seeded: notifications, cron schedules, collection logs, exclusions, overlays.",
"Also seeded: Seerr rule groups (#3152), notifications, cron schedules, collection logs, exclusions, overlays.",
);
console.log(
"Seerr points at tools/dev/fake-seerr.mjs (http://localhost:5055) — start it to evaluate the Seerr rule groups.",
);
console.log("Restart `yarn dev` and open http://localhost:3000/collections");
db.close();