mirror of
https://github.com/jorenn92/Maintainerr.git
synced 2026-07-16 16:47:04 +02:00
* 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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([]));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user