@cyanheads/seerr-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +407 -0
- package/CLAUDE.md +407 -0
- package/Dockerfile +121 -0
- package/LICENSE +201 -0
- package/README.md +302 -0
- package/changelog/0.1.x/0.1.0.md +26 -0
- package/changelog/template.md +127 -0
- package/dist/config/server-config.d.ts +27 -0
- package/dist/config/server-config.d.ts.map +1 -0
- package/dist/config/server-config.js +45 -0
- package/dist/config/server-config.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server/resources/definitions/index.d.ts +47 -0
- package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -0
- package/dist/mcp-server/resources/definitions/index.js +8 -0
- package/dist/mcp-server/resources/definitions/index.js.map +1 -0
- package/dist/mcp-server/resources/definitions/request.resource.d.ts +53 -0
- package/dist/mcp-server/resources/definitions/request.resource.d.ts.map +1 -0
- package/dist/mcp-server/resources/definitions/request.resource.js +70 -0
- package/dist/mcp-server/resources/definitions/request.resource.js.map +1 -0
- package/dist/mcp-server/tools/definitions/get-media.tool.d.ts +66 -0
- package/dist/mcp-server/tools/definitions/get-media.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/get-media.tool.js +197 -0
- package/dist/mcp-server/tools/definitions/get-media.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/index.d.ts +341 -0
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/index.js +22 -0
- package/dist/mcp-server/tools/definitions/index.js.map +1 -0
- package/dist/mcp-server/tools/definitions/list-requests.tool.d.ts +76 -0
- package/dist/mcp-server/tools/definitions/list-requests.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/list-requests.tool.js +187 -0
- package/dist/mcp-server/tools/definitions/list-requests.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/request-media.tool.d.ts +117 -0
- package/dist/mcp-server/tools/definitions/request-media.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/request-media.tool.js +347 -0
- package/dist/mcp-server/tools/definitions/request-media.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/request-status.tool.d.ts +55 -0
- package/dist/mcp-server/tools/definitions/request-status.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/request-status.tool.js +111 -0
- package/dist/mcp-server/tools/definitions/request-status.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/search-media.tool.d.ts +51 -0
- package/dist/mcp-server/tools/definitions/search-media.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/search-media.tool.js +167 -0
- package/dist/mcp-server/tools/definitions/search-media.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/service-options.tool.d.ts +51 -0
- package/dist/mcp-server/tools/definitions/service-options.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/service-options.tool.js +195 -0
- package/dist/mcp-server/tools/definitions/service-options.tool.js.map +1 -0
- package/dist/services/seerr/errors.d.ts +33 -0
- package/dist/services/seerr/errors.d.ts.map +1 -0
- package/dist/services/seerr/errors.js +74 -0
- package/dist/services/seerr/errors.js.map +1 -0
- package/dist/services/seerr/normalizers.d.ts +111 -0
- package/dist/services/seerr/normalizers.d.ts.map +1 -0
- package/dist/services/seerr/normalizers.js +179 -0
- package/dist/services/seerr/normalizers.js.map +1 -0
- package/dist/services/seerr/seerr-service.d.ts +92 -0
- package/dist/services/seerr/seerr-service.d.ts.map +1 -0
- package/dist/services/seerr/seerr-service.js +187 -0
- package/dist/services/seerr/seerr-service.js.map +1 -0
- package/dist/services/seerr/status.d.ts +27 -0
- package/dist/services/seerr/status.d.ts.map +1 -0
- package/dist/services/seerr/status.js +70 -0
- package/dist/services/seerr/status.js.map +1 -0
- package/dist/services/seerr/types.d.ts +221 -0
- package/dist/services/seerr/types.d.ts.map +1 -0
- package/dist/services/seerr/types.js +11 -0
- package/dist/services/seerr/types.js.map +1 -0
- package/package.json +86 -0
- package/server.json +137 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Upstream-error classifier for the Seerr API. Seerr returns
|
|
3
|
+
* idiosyncratic not-found responses that the generic HTTP-status mapping would
|
|
4
|
+
* mislabel:
|
|
5
|
+
* - A missing TMDB movie/show id → HTTP 500 with body `{"message":"Unable to
|
|
6
|
+
* retrieve movie."}` (confirmed live). The generic mapping would call this a
|
|
7
|
+
* transient `ServiceUnavailable` and retry it — it never succeeds. We classify
|
|
8
|
+
* it as `media_not_found` (NotFound) so the agent re-searches instead.
|
|
9
|
+
* - A missing request id → HTTP 404 with body `{"message":"Request not found."}`
|
|
10
|
+
* → `request_not_found` (NotFound).
|
|
11
|
+
*
|
|
12
|
+
* The classifier is used for the GET endpoints that carry these semantics
|
|
13
|
+
* (`/movie/{id}`, `/tv/{id}`, `/request/{id}`). It inspects status + body and
|
|
14
|
+
* throws a domain-tagged `McpError`; callers declare matching `errors[]` contract
|
|
15
|
+
* entries so `data.reason` lines up with `ctx.fail`. The thrown errors carry
|
|
16
|
+
* `retryable: false` so `withRetry` fails fast rather than hammering a 500 that
|
|
17
|
+
* will never resolve, and a static `recovery.hint` so the actionable next step
|
|
18
|
+
* reaches both wire surfaces (the framework mirrors `data.recovery.hint` into the
|
|
19
|
+
* `content[]` text). The classifier runs in the service layer without `ctx`, so
|
|
20
|
+
* the hint is attached here rather than resolved from a tool's `recoveryFor`.
|
|
21
|
+
* @module services/seerr/errors
|
|
22
|
+
*/
|
|
23
|
+
/** Which not-found-bearing endpoint a classified response belongs to. */
|
|
24
|
+
export type NotFoundKind = 'media' | 'request';
|
|
25
|
+
/**
|
|
26
|
+
* Inspect a non-OK `Response` from a not-found-bearing GET and throw the right
|
|
27
|
+
* domain error. `kind` selects the expected not-found semantics. Falls back to the
|
|
28
|
+
* framework's status-based mapping for anything that isn't the known not-found shape.
|
|
29
|
+
*
|
|
30
|
+
* Reads the response body once. The caller must not have consumed it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function throwClassifiedSeerrError(response: Response, kind: NotFoundKind, service: string): Promise<never>;
|
|
33
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/services/seerr/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAKH,yEAAyE;AACzE,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,SAAS,CAAC;AAe/C;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,KAAK,CAAC,CA4ChB"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Upstream-error classifier for the Seerr API. Seerr returns
|
|
3
|
+
* idiosyncratic not-found responses that the generic HTTP-status mapping would
|
|
4
|
+
* mislabel:
|
|
5
|
+
* - A missing TMDB movie/show id → HTTP 500 with body `{"message":"Unable to
|
|
6
|
+
* retrieve movie."}` (confirmed live). The generic mapping would call this a
|
|
7
|
+
* transient `ServiceUnavailable` and retry it — it never succeeds. We classify
|
|
8
|
+
* it as `media_not_found` (NotFound) so the agent re-searches instead.
|
|
9
|
+
* - A missing request id → HTTP 404 with body `{"message":"Request not found."}`
|
|
10
|
+
* → `request_not_found` (NotFound).
|
|
11
|
+
*
|
|
12
|
+
* The classifier is used for the GET endpoints that carry these semantics
|
|
13
|
+
* (`/movie/{id}`, `/tv/{id}`, `/request/{id}`). It inspects status + body and
|
|
14
|
+
* throws a domain-tagged `McpError`; callers declare matching `errors[]` contract
|
|
15
|
+
* entries so `data.reason` lines up with `ctx.fail`. The thrown errors carry
|
|
16
|
+
* `retryable: false` so `withRetry` fails fast rather than hammering a 500 that
|
|
17
|
+
* will never resolve, and a static `recovery.hint` so the actionable next step
|
|
18
|
+
* reaches both wire surfaces (the framework mirrors `data.recovery.hint` into the
|
|
19
|
+
* `content[]` text). The classifier runs in the service layer without `ctx`, so
|
|
20
|
+
* the hint is attached here rather than resolved from a tool's `recoveryFor`.
|
|
21
|
+
* @module services/seerr/errors
|
|
22
|
+
*/
|
|
23
|
+
import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
|
|
24
|
+
import { httpErrorFromResponse } from '@cyanheads/mcp-ts-core/utils';
|
|
25
|
+
const MEDIA_NOT_FOUND_BODY = /unable to retrieve (movie|series|tv)/i;
|
|
26
|
+
const REQUEST_NOT_FOUND_BODY = /request not found/i;
|
|
27
|
+
/**
|
|
28
|
+
* Static recovery hints mirrored to both wire surfaces. The text matches the
|
|
29
|
+
* `errors[]` contract entries of the consuming tools (`seerr_get_media`,
|
|
30
|
+
* `seerr_request_media`, `seerr_request_status`, and the request resource).
|
|
31
|
+
*/
|
|
32
|
+
const MEDIA_NOT_FOUND_RECOVERY = 'Call seerr_search_media to find the correct tmdbId, then retry with the exact ID and matching mediaType.';
|
|
33
|
+
const REQUEST_NOT_FOUND_RECOVERY = 'List requests with seerr_list_requests to find a valid requestId, then retry.';
|
|
34
|
+
/**
|
|
35
|
+
* Inspect a non-OK `Response` from a not-found-bearing GET and throw the right
|
|
36
|
+
* domain error. `kind` selects the expected not-found semantics. Falls back to the
|
|
37
|
+
* framework's status-based mapping for anything that isn't the known not-found shape.
|
|
38
|
+
*
|
|
39
|
+
* Reads the response body once. The caller must not have consumed it.
|
|
40
|
+
*/
|
|
41
|
+
export async function throwClassifiedSeerrError(response, kind, service) {
|
|
42
|
+
const body = await response.text();
|
|
43
|
+
if (kind === 'media' &&
|
|
44
|
+
(response.status === 500 || response.status === 404) &&
|
|
45
|
+
MEDIA_NOT_FOUND_BODY.test(body)) {
|
|
46
|
+
throw new McpError(JsonRpcErrorCode.NotFound, 'Unable to retrieve the requested title from Seerr.', {
|
|
47
|
+
reason: 'media_not_found',
|
|
48
|
+
httpStatus: response.status,
|
|
49
|
+
retryable: false,
|
|
50
|
+
recovery: { hint: MEDIA_NOT_FOUND_RECOVERY },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (kind === 'request' && response.status === 404 && REQUEST_NOT_FOUND_BODY.test(body)) {
|
|
54
|
+
throw new McpError(JsonRpcErrorCode.NotFound, 'Request not found.', {
|
|
55
|
+
reason: 'request_not_found',
|
|
56
|
+
httpStatus: response.status,
|
|
57
|
+
retryable: false,
|
|
58
|
+
recovery: { hint: REQUEST_NOT_FOUND_RECOVERY },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Not a recognized not-found shape — defer to the framework's full status table.
|
|
63
|
+
* `captureBody: false` is the security boundary: a raw Seerr error body is the one
|
|
64
|
+
* path that would reach output WITHOUT passing the `normalizers.ts` choke point,
|
|
65
|
+
* and Seerr/Radarr/Sonarr error strings can echo `serviceUrl` (internal host:port)
|
|
66
|
+
* or root-folder paths. Status + statusText carry enough for the agent to act.
|
|
67
|
+
*/
|
|
68
|
+
throw await httpErrorFromResponse(new Response(null, {
|
|
69
|
+
status: response.status,
|
|
70
|
+
statusText: response.statusText,
|
|
71
|
+
headers: response.headers,
|
|
72
|
+
}), { service, captureBody: false });
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../../src/services/seerr/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAKrE,MAAM,oBAAoB,GAAG,uCAAuC,CAAC;AACrE,MAAM,sBAAsB,GAAG,oBAAoB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,wBAAwB,GAC5B,0GAA0G,CAAC;AAC7G,MAAM,0BAA0B,GAC9B,+EAA+E,CAAC;AAElF;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,QAAkB,EAClB,IAAkB,EAClB,OAAe;IAEf,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IAEnC,IACE,IAAI,KAAK,OAAO;QAChB,CAAC,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,CAAC;QACpD,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,EAC/B,CAAC;QACD,MAAM,IAAI,QAAQ,CAChB,gBAAgB,CAAC,QAAQ,EACzB,oDAAoD,EACpD;YACE,MAAM,EAAE,iBAAiB;YACzB,UAAU,EAAE,QAAQ,CAAC,MAAM;YAC3B,SAAS,EAAE,KAAK;YAChB,QAAQ,EAAE,EAAE,IAAI,EAAE,wBAAwB,EAAE;SAC7C,CACF,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACvF,MAAM,IAAI,QAAQ,CAAC,gBAAgB,CAAC,QAAQ,EAAE,oBAAoB,EAAE;YAClE,MAAM,EAAE,mBAAmB;YAC3B,UAAU,EAAE,QAAQ,CAAC,MAAM;YAC3B,SAAS,EAAE,KAAK;YAChB,QAAQ,EAAE,EAAE,IAAI,EAAE,0BAA0B,EAAE;SAC/C,CAAC,CAAC;IACL,CAAC;IAED;;;;;;OAMG;IACH,MAAM,MAAM,qBAAqB,CAC/B,IAAI,QAAQ,CAAC,IAAI,EAAE;QACjB,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,UAAU,EAAE,QAAQ,CAAC,UAAU;QAC/B,OAAO,EAAE,QAAQ,CAAC,OAAO;KAC1B,CAAC,EACF,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,CAChC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The mandatory PII / infrastructure redaction choke point. Every
|
|
3
|
+
* raw Seerr payload is projected to a domain shape HERE — never in `format()` —
|
|
4
|
+
* so both `structuredContent` and `content[]` are clean by construction.
|
|
5
|
+
*
|
|
6
|
+
* Live Seerr payloads leak operator-private data that must NEVER reach tool output:
|
|
7
|
+
* - `User` objects (BOTH `requestedBy` and `modifiedBy`): email, Plex/Jellyfin
|
|
8
|
+
* auth tokens, `jellyfinUserId`, `plexId`, avatar, permissions bitfield, quotas.
|
|
9
|
+
* - `MediaInfo.serviceUrl` / `serviceUrl4k`: internal `http://<tailscale-ip>:<port>/…`.
|
|
10
|
+
* - `jellyfinMediaId`, `ratingKey`, download status, etc. on `MediaInfo`.
|
|
11
|
+
* - Radarr/Sonarr `activeDirectory` + `activeAnimeDirectory` and root-folder `path`
|
|
12
|
+
* (filesystem paths) — gated behind `includePaths`.
|
|
13
|
+
* - `/settings/public` `vapidPublic` (push key) + `plexClientIdentifier` (Plex UUID).
|
|
14
|
+
*
|
|
15
|
+
* The projection functions below allow-list the fields they emit. They read named
|
|
16
|
+
* fields off the raw object and drop everything else — a leaked field cannot pass
|
|
17
|
+
* through because it is never copied. The base URL and API key live only in config
|
|
18
|
+
* and likewise never appear in any projected shape.
|
|
19
|
+
* @module services/seerr/normalizers
|
|
20
|
+
*/
|
|
21
|
+
import { type DecodedStatus } from './status.js';
|
|
22
|
+
import type { DomainUser, RawEpisode, RawMediaInfo, RawMediaRequest, RawRootFolder, RawSeason, RawServiceProfile, RawUser } from './types.js';
|
|
23
|
+
/**
|
|
24
|
+
* Project a raw `User` to `{ id, displayName }` — the ONLY user shape allowed in
|
|
25
|
+
* output. `displayName` is the first present of `username` → `jellyfinUsername` →
|
|
26
|
+
* `plexUsername` → Seerr's own `displayName` → `User #<id>`. Every other field
|
|
27
|
+
* (email, tokens, IDs, avatar, permissions, quotas) is dropped by omission.
|
|
28
|
+
*/
|
|
29
|
+
export declare function redactUser(raw: RawUser): DomainUser;
|
|
30
|
+
/** Decoded availability projected from a raw `MediaInfo`. `status4k` is omitted when absent. */
|
|
31
|
+
export interface DomainAvailability {
|
|
32
|
+
status: DecodedStatus;
|
|
33
|
+
status4k?: DecodedStatus;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Project the availability fields of a raw `MediaInfo` to decoded `{ status, status4k? }`.
|
|
37
|
+
* Reads ONLY `status` / `status4k` — `serviceUrl`, `jellyfinMediaId`, `ratingKey`,
|
|
38
|
+
* and the rest of the leak surface are never touched. Status 1 (`unknown`) is the
|
|
39
|
+
* fallback when the raw status is absent.
|
|
40
|
+
*/
|
|
41
|
+
export declare function redactAvailability(raw: RawMediaInfo): DomainAvailability;
|
|
42
|
+
/** Normalize requested season numbers off a raw request — handles both `number[]` and `{seasonNumber}[]`. */
|
|
43
|
+
export declare function normalizeSeasons(raw: RawMediaRequest): number[];
|
|
44
|
+
/** The existing-request projection surfaced on detail/preview outputs. */
|
|
45
|
+
export interface DomainOpenRequest {
|
|
46
|
+
is4k: boolean;
|
|
47
|
+
requestId: number;
|
|
48
|
+
status: DecodedStatus;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Pick the most recent request from a raw `MediaInfo.requests[]` and project it to
|
|
52
|
+
* `{ requestId, status, is4k }`. Returns `undefined` when there are no requests.
|
|
53
|
+
*/
|
|
54
|
+
export declare function redactOpenRequest(media: RawMediaInfo | undefined): DomainOpenRequest | undefined;
|
|
55
|
+
/** Project a raw season summary (TV detail) — allow-listed fields only. */
|
|
56
|
+
export declare function redactSeason(raw: RawSeason): {
|
|
57
|
+
seasonNumber: number;
|
|
58
|
+
name: string;
|
|
59
|
+
episodeCount: number;
|
|
60
|
+
airDate: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** Project a raw episode (season detail) — allow-listed fields only; `overview` omitted when empty. */
|
|
63
|
+
export declare function redactEpisode(raw: RawEpisode): {
|
|
64
|
+
episodeNumber: number;
|
|
65
|
+
name: string;
|
|
66
|
+
airDate: string | null;
|
|
67
|
+
overview?: string;
|
|
68
|
+
};
|
|
69
|
+
/** Project a raw quality profile to `{ id, name }`. */
|
|
70
|
+
export declare function redactProfile(raw: RawServiceProfile): {
|
|
71
|
+
id: number;
|
|
72
|
+
name: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Project a raw root folder. Paths and free space are operator-private and are
|
|
76
|
+
* surfaced ONLY when `includePaths` is true; otherwise this returns `undefined`
|
|
77
|
+
* so the caller omits the folder entirely rather than emitting an empty shell.
|
|
78
|
+
*/
|
|
79
|
+
export declare function redactRootFolder(raw: RawRootFolder, includePaths: boolean): {
|
|
80
|
+
path: string;
|
|
81
|
+
freeSpace?: number;
|
|
82
|
+
} | undefined;
|
|
83
|
+
/** Fully-redacted single-request projection — the shared shape behind seerr_request_status and the resource. */
|
|
84
|
+
export interface DomainRequestDetail {
|
|
85
|
+
createdAt: string;
|
|
86
|
+
is4k: boolean;
|
|
87
|
+
mediaStatus?: DecodedStatus;
|
|
88
|
+
mediaStatus4k?: DecodedStatus;
|
|
89
|
+
mediaType: 'movie' | 'tv';
|
|
90
|
+
requestedBy: DomainUser;
|
|
91
|
+
requestId: number;
|
|
92
|
+
requestStatus: DecodedStatus;
|
|
93
|
+
routing: {
|
|
94
|
+
serverId?: number;
|
|
95
|
+
profileName?: string;
|
|
96
|
+
is4k: boolean;
|
|
97
|
+
};
|
|
98
|
+
seasons?: number[];
|
|
99
|
+
stateGuidance?: string;
|
|
100
|
+
tmdbId?: number;
|
|
101
|
+
updatedAt: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Project a raw `MediaRequest` (single-request / created shape) to the redacted
|
|
105
|
+
* `DomainRequestDetail`. The media-type discriminator is `type` (not `mediaType`);
|
|
106
|
+
* `requestedBy` is projected to `{ id, displayName }`; `modifiedBy`, `serviceUrl`,
|
|
107
|
+
* tokens, and paths are dropped by omission. `routing` carries names/IDs only.
|
|
108
|
+
* `title` is intentionally absent — request objects have no title field.
|
|
109
|
+
*/
|
|
110
|
+
export declare function projectRequestDetail(raw: RawMediaRequest): DomainRequestDetail;
|
|
111
|
+
//# sourceMappingURL=normalizers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalizers.d.ts","sourceRoot":"","sources":["../../../src/services/seerr/normalizers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,KAAK,aAAa,EAA0C,MAAM,aAAa,CAAC;AACzF,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,YAAY,EACZ,eAAe,EACf,aAAa,EACb,SAAS,EACT,iBAAiB,EACjB,OAAO,EACR,MAAM,YAAY,CAAC;AAEpB;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,CAQnD;AAED,gGAAgG;AAChG,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,aAAa,CAAC;IACtB,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,YAAY,GAAG,kBAAkB,CAMxE;AAED,6GAA6G;AAC7G,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,EAAE,CAM/D;AAED,0EAA0E;AAC1E,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,GAAG,iBAAiB,GAAG,SAAS,CAWhG;AAQD,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CAOA;AAED,uGAAuG;AACvG,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG;IAC9C,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAQA;AAED,uDAAuD;AACvD,wBAAgB,aAAa,CAAC,GAAG,EAAE,iBAAiB,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAElF;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,aAAa,EAClB,YAAY,EAAE,OAAO,GACpB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAMlD;AAED,gHAAgH;AAChH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,CAAC,EAAE,aAAa,CAAC;IAC5B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,SAAS,EAAE,OAAO,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,UAAU,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,aAAa,CAAC;IAC7B,OAAO,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC;IACpE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,eAAe,GAAG,mBAAmB,CA8B9E"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The mandatory PII / infrastructure redaction choke point. Every
|
|
3
|
+
* raw Seerr payload is projected to a domain shape HERE — never in `format()` —
|
|
4
|
+
* so both `structuredContent` and `content[]` are clean by construction.
|
|
5
|
+
*
|
|
6
|
+
* Live Seerr payloads leak operator-private data that must NEVER reach tool output:
|
|
7
|
+
* - `User` objects (BOTH `requestedBy` and `modifiedBy`): email, Plex/Jellyfin
|
|
8
|
+
* auth tokens, `jellyfinUserId`, `plexId`, avatar, permissions bitfield, quotas.
|
|
9
|
+
* - `MediaInfo.serviceUrl` / `serviceUrl4k`: internal `http://<tailscale-ip>:<port>/…`.
|
|
10
|
+
* - `jellyfinMediaId`, `ratingKey`, download status, etc. on `MediaInfo`.
|
|
11
|
+
* - Radarr/Sonarr `activeDirectory` + `activeAnimeDirectory` and root-folder `path`
|
|
12
|
+
* (filesystem paths) — gated behind `includePaths`.
|
|
13
|
+
* - `/settings/public` `vapidPublic` (push key) + `plexClientIdentifier` (Plex UUID).
|
|
14
|
+
*
|
|
15
|
+
* The projection functions below allow-list the fields they emit. They read named
|
|
16
|
+
* fields off the raw object and drop everything else — a leaked field cannot pass
|
|
17
|
+
* through because it is never copied. The base URL and API key live only in config
|
|
18
|
+
* and likewise never appear in any projected shape.
|
|
19
|
+
* @module services/seerr/normalizers
|
|
20
|
+
*/
|
|
21
|
+
import { decodeMediaStatus, decodeRequestStatus } from './status.js';
|
|
22
|
+
/**
|
|
23
|
+
* Project a raw `User` to `{ id, displayName }` — the ONLY user shape allowed in
|
|
24
|
+
* output. `displayName` is the first present of `username` → `jellyfinUsername` →
|
|
25
|
+
* `plexUsername` → Seerr's own `displayName` → `User #<id>`. Every other field
|
|
26
|
+
* (email, tokens, IDs, avatar, permissions, quotas) is dropped by omission.
|
|
27
|
+
*/
|
|
28
|
+
export function redactUser(raw) {
|
|
29
|
+
const displayName = raw.username ??
|
|
30
|
+
raw.jellyfinUsername ??
|
|
31
|
+
raw.plexUsername ??
|
|
32
|
+
raw.displayName ??
|
|
33
|
+
`User #${raw.id}`;
|
|
34
|
+
return { id: raw.id, displayName };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Project the availability fields of a raw `MediaInfo` to decoded `{ status, status4k? }`.
|
|
38
|
+
* Reads ONLY `status` / `status4k` — `serviceUrl`, `jellyfinMediaId`, `ratingKey`,
|
|
39
|
+
* and the rest of the leak surface are never touched. Status 1 (`unknown`) is the
|
|
40
|
+
* fallback when the raw status is absent.
|
|
41
|
+
*/
|
|
42
|
+
export function redactAvailability(raw) {
|
|
43
|
+
const status = decodeMediaStatus(raw.status ?? 1);
|
|
44
|
+
return {
|
|
45
|
+
status,
|
|
46
|
+
...(typeof raw.status4k === 'number' ? { status4k: decodeMediaStatus(raw.status4k) } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Normalize requested season numbers off a raw request — handles both `number[]` and `{seasonNumber}[]`. */
|
|
50
|
+
export function normalizeSeasons(raw) {
|
|
51
|
+
const seasons = raw.seasons;
|
|
52
|
+
if (!Array.isArray(seasons))
|
|
53
|
+
return [];
|
|
54
|
+
return seasons
|
|
55
|
+
.map((s) => (typeof s === 'number' ? s : s?.seasonNumber))
|
|
56
|
+
.filter((n) => typeof n === 'number');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Pick the most recent request from a raw `MediaInfo.requests[]` and project it to
|
|
60
|
+
* `{ requestId, status, is4k }`. Returns `undefined` when there are no requests.
|
|
61
|
+
*/
|
|
62
|
+
export function redactOpenRequest(media) {
|
|
63
|
+
const requests = media?.requests;
|
|
64
|
+
if (!Array.isArray(requests) || requests.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
const latest = requests.reduce((newest, r) => requestSortKey(r) >= requestSortKey(newest) ? r : newest);
|
|
67
|
+
return {
|
|
68
|
+
requestId: latest.id,
|
|
69
|
+
status: decodeRequestStatus(latest.status ?? 1),
|
|
70
|
+
is4k: latest.is4k === true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Sort key for "most recent" request — `createdAt` epoch, falling back to `id`. */
|
|
74
|
+
function requestSortKey(r) {
|
|
75
|
+
const t = r.createdAt ? Date.parse(r.createdAt) : Number.NaN;
|
|
76
|
+
return Number.isNaN(t) ? r.id : t;
|
|
77
|
+
}
|
|
78
|
+
/** Project a raw season summary (TV detail) — allow-listed fields only. */
|
|
79
|
+
export function redactSeason(raw) {
|
|
80
|
+
return {
|
|
81
|
+
seasonNumber: raw.seasonNumber ?? 0,
|
|
82
|
+
name: raw.name ?? '',
|
|
83
|
+
episodeCount: raw.episodeCount ?? 0,
|
|
84
|
+
airDate: raw.airDate ?? null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Project a raw episode (season detail) — allow-listed fields only; `overview` omitted when empty. */
|
|
88
|
+
export function redactEpisode(raw) {
|
|
89
|
+
const overview = raw.overview?.trim();
|
|
90
|
+
return {
|
|
91
|
+
episodeNumber: raw.episodeNumber ?? 0,
|
|
92
|
+
name: raw.name ?? '',
|
|
93
|
+
airDate: raw.airDate ?? null,
|
|
94
|
+
...(overview ? { overview } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Project a raw quality profile to `{ id, name }`. */
|
|
98
|
+
export function redactProfile(raw) {
|
|
99
|
+
return { id: raw.id, name: raw.name ?? `Profile ${raw.id}` };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Project a raw root folder. Paths and free space are operator-private and are
|
|
103
|
+
* surfaced ONLY when `includePaths` is true; otherwise this returns `undefined`
|
|
104
|
+
* so the caller omits the folder entirely rather than emitting an empty shell.
|
|
105
|
+
*/
|
|
106
|
+
export function redactRootFolder(raw, includePaths) {
|
|
107
|
+
if (!includePaths)
|
|
108
|
+
return;
|
|
109
|
+
return {
|
|
110
|
+
path: raw.path ?? '',
|
|
111
|
+
...(typeof raw.freeSpace === 'number' ? { freeSpace: raw.freeSpace } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Project a raw `MediaRequest` (single-request / created shape) to the redacted
|
|
116
|
+
* `DomainRequestDetail`. The media-type discriminator is `type` (not `mediaType`);
|
|
117
|
+
* `requestedBy` is projected to `{ id, displayName }`; `modifiedBy`, `serviceUrl`,
|
|
118
|
+
* tokens, and paths are dropped by omission. `routing` carries names/IDs only.
|
|
119
|
+
* `title` is intentionally absent — request objects have no title field.
|
|
120
|
+
*/
|
|
121
|
+
export function projectRequestDetail(raw) {
|
|
122
|
+
const mediaType = raw.type === 'tv' ? 'tv' : 'movie';
|
|
123
|
+
const requestStatus = decodeRequestStatus(raw.status ?? 1);
|
|
124
|
+
const mediaStatus = typeof raw.media?.status === 'number' ? decodeMediaStatus(raw.media.status) : undefined;
|
|
125
|
+
const mediaStatus4k = typeof raw.media?.status4k === 'number' ? decodeMediaStatus(raw.media.status4k) : undefined;
|
|
126
|
+
const seasons = normalizeSeasons(raw);
|
|
127
|
+
const guidance = stateGuidanceFor(requestStatus, mediaStatus);
|
|
128
|
+
const tmdbId = raw.media?.tmdbId;
|
|
129
|
+
return {
|
|
130
|
+
requestId: raw.id,
|
|
131
|
+
mediaType,
|
|
132
|
+
...(typeof tmdbId === 'number' ? { tmdbId } : {}),
|
|
133
|
+
requestStatus,
|
|
134
|
+
...(mediaStatus ? { mediaStatus } : {}),
|
|
135
|
+
...(mediaStatus4k ? { mediaStatus4k } : {}),
|
|
136
|
+
is4k: raw.is4k === true,
|
|
137
|
+
...(seasons.length > 0 ? { seasons } : {}),
|
|
138
|
+
requestedBy: raw.requestedBy ? redactUser(raw.requestedBy) : { id: 0, displayName: 'Unknown' },
|
|
139
|
+
routing: {
|
|
140
|
+
...(typeof raw.serverId === 'number' ? { serverId: raw.serverId } : {}),
|
|
141
|
+
...(raw.profileName ? { profileName: raw.profileName } : {}),
|
|
142
|
+
is4k: raw.is4k === true,
|
|
143
|
+
},
|
|
144
|
+
createdAt: raw.createdAt ?? '',
|
|
145
|
+
updatedAt: raw.updatedAt ?? '',
|
|
146
|
+
...(guidance ? { stateGuidance: guidance } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Derive a next-step hint from the decoded request + media status. Guidance only — never an ETA. */
|
|
150
|
+
function stateGuidanceFor(requestStatus, mediaStatus) {
|
|
151
|
+
switch (requestStatus.label) {
|
|
152
|
+
case 'pending':
|
|
153
|
+
return 'Awaiting approval in Seerr. An operator must approve it before it downloads.';
|
|
154
|
+
case 'declined':
|
|
155
|
+
return 'This request was declined in Seerr. Re-request only if the decision should change.';
|
|
156
|
+
case 'failed':
|
|
157
|
+
return 'This request failed in Seerr. Retry it from the Seerr UI; the API does not expose a retry.';
|
|
158
|
+
case 'completed':
|
|
159
|
+
return 'This request is complete. The media should be available to watch.';
|
|
160
|
+
case 'approved':
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
switch (mediaStatus?.label) {
|
|
166
|
+
case 'processing':
|
|
167
|
+
case 'pending':
|
|
168
|
+
return 'Approved and downloading via Radarr/Sonarr. Check back shortly.';
|
|
169
|
+
case 'partially_available':
|
|
170
|
+
return 'Some media is available; the rest is still downloading.';
|
|
171
|
+
case 'available':
|
|
172
|
+
return 'Media is available to watch.';
|
|
173
|
+
case 'unknown':
|
|
174
|
+
return 'Approved, but no download has started — if it stays stuck, retry the request in the Seerr UI.';
|
|
175
|
+
default:
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=normalizers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalizers.js","sourceRoot":"","sources":["../../../src/services/seerr/normalizers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview SeerrService — the typed client for the Seerr REST API v1
|
|
3
|
+
* (`{SEERR_BASE_URL}/api/v1`, `X-Api-Key` auth). One upstream API, one service,
|
|
4
|
+
* init/accessor pattern. Every method wraps a single-attempt fetch + parse in
|
|
5
|
+
* `withRetry` (the retry boundary covers the full pipeline). Not-found-bearing GETs
|
|
6
|
+
* (movie/tv/request) route their non-OK responses through the `errors.ts` classifier
|
|
7
|
+
* so Seerr's idiosyncratic 500/404 not-found bodies become clean domain errors.
|
|
8
|
+
*
|
|
9
|
+
* The instance is a trusted local LAN / Tailscale host, so requests use plain
|
|
10
|
+
* `fetch` (the framework's `fetchWithTimeout` SSRF guard would block the private
|
|
11
|
+
* IP, and the classifier needs body access on non-OK). The base URL and API key
|
|
12
|
+
* never leave this file's request construction — no method returns them.
|
|
13
|
+
* @module services/seerr/seerr-service
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@cyanheads/mcp-ts-core';
|
|
16
|
+
import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
|
|
17
|
+
import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
|
|
18
|
+
import type { CreateRequestBody, RawMediaRequest, RawMovieDetail, RawPublicSettings, RawRequestListResponse, RawSearchResponse, RawSeasonDetail, RawServiceDetail, RawServiceListEntry, RawStatus, RawTvDetail } from './types.js';
|
|
19
|
+
/**
|
|
20
|
+
* `GET /request/{id}` and `POST /request` return a `MediaRequest` — aliased here
|
|
21
|
+
* for method-return clarity (same shape as the list entries).
|
|
22
|
+
*/
|
|
23
|
+
export type RawMediaRequestDetail = RawMediaRequest;
|
|
24
|
+
/** Filters accepted by `listRequests`. */
|
|
25
|
+
export interface ListRequestsParams {
|
|
26
|
+
filter: string;
|
|
27
|
+
mediaType: string;
|
|
28
|
+
requestedById?: number;
|
|
29
|
+
skip: number;
|
|
30
|
+
sort: string;
|
|
31
|
+
sortDirection: string;
|
|
32
|
+
take: number;
|
|
33
|
+
}
|
|
34
|
+
export declare class SeerrService {
|
|
35
|
+
private readonly baseUrl;
|
|
36
|
+
private readonly apiKey;
|
|
37
|
+
private readonly timeoutMs;
|
|
38
|
+
constructor();
|
|
39
|
+
/** `GET /search` — title disambiguation. */
|
|
40
|
+
search(params: {
|
|
41
|
+
query: string;
|
|
42
|
+
page: number;
|
|
43
|
+
language?: string;
|
|
44
|
+
}, ctx: Context): Promise<RawSearchResponse>;
|
|
45
|
+
/** `GET /movie/{id}` — movie detail. 500 "Unable to retrieve movie." → media_not_found. */
|
|
46
|
+
getMovie(tmdbId: number, ctx: Context): Promise<RawMovieDetail>;
|
|
47
|
+
/** `GET /tv/{id}` — TV detail. */
|
|
48
|
+
getTv(tmdbId: number, ctx: Context): Promise<RawTvDetail>;
|
|
49
|
+
/** `GET /tv/{id}/season/{n}` — episode list for one season. */
|
|
50
|
+
getSeason(tmdbId: number, seasonNumber: number, ctx: Context): Promise<RawSeasonDetail>;
|
|
51
|
+
/** `GET /request` — list requests with filters. */
|
|
52
|
+
listRequests(params: ListRequestsParams, ctx: Context): Promise<RawRequestListResponse>;
|
|
53
|
+
/** `GET /request/{id}` — single request. 404 "Request not found." → request_not_found. */
|
|
54
|
+
getRequest(requestId: number, ctx: Context): Promise<RawMediaRequestDetail>;
|
|
55
|
+
/** `POST /request` — create a media request. The only write in the surface. */
|
|
56
|
+
createRequest(body: CreateRequestBody, ctx: Context): Promise<RawMediaRequestDetail>;
|
|
57
|
+
/** `GET /service/radarr` — Radarr service list. */
|
|
58
|
+
getRadarrServices(ctx: Context): Promise<RawServiceListEntry[]>;
|
|
59
|
+
/** `GET /service/radarr/{id}` — Radarr profiles + root folders. */
|
|
60
|
+
getRadarrDetail(serverId: number, ctx: Context): Promise<RawServiceDetail>;
|
|
61
|
+
/** `GET /service/sonarr` — Sonarr service list. */
|
|
62
|
+
getSonarrServices(ctx: Context): Promise<RawServiceListEntry[]>;
|
|
63
|
+
/** `GET /service/sonarr/{id}` — Sonarr profiles + root folders. */
|
|
64
|
+
getSonarrDetail(serverId: number, ctx: Context): Promise<RawServiceDetail>;
|
|
65
|
+
/** `GET /status` — Seerr version. */
|
|
66
|
+
getStatus(ctx: Context): Promise<RawStatus>;
|
|
67
|
+
/**
|
|
68
|
+
* `GET /settings/public` — capability flags. Cached in `ctx.state` with a short
|
|
69
|
+
* TTL: capability checks read it on most calls (preview validation, search/get
|
|
70
|
+
* 4K decoding) but instance config changes rarely. Cache failures are non-fatal.
|
|
71
|
+
*/
|
|
72
|
+
getPublicSettings(ctx: Context, options?: {
|
|
73
|
+
forceRefresh?: boolean;
|
|
74
|
+
}): Promise<RawPublicSettings>;
|
|
75
|
+
/**
|
|
76
|
+
* Single Seerr request: fetch (single attempt) + status check + JSON parse, all
|
|
77
|
+
* inside `withRetry`. Non-OK responses route through the not-found classifier
|
|
78
|
+
* (when `notFoundKind` is set) or the framework's status mapping. The retry
|
|
79
|
+
* boundary wraps the whole pipeline so a transient parse failure also retries.
|
|
80
|
+
*/
|
|
81
|
+
private request;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Initialize the singleton SeerrService — called from `createApp`'s `setup()`.
|
|
85
|
+
* Config and storage are accepted to match the framework's init signature; the
|
|
86
|
+
* service reads its own env config via `getServerConfig()` and uses tenant-scoped
|
|
87
|
+
* `ctx.state` (not the raw storage handle) for the settings cache.
|
|
88
|
+
*/
|
|
89
|
+
export declare function initSeerrService(_config: AppConfig, _storage: StorageService): void;
|
|
90
|
+
/** Accessor for the SeerrService singleton. Throws if `initSeerrService` was not called. */
|
|
91
|
+
export declare function getSeerrService(): SeerrService;
|
|
92
|
+
//# sourceMappingURL=seerr-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seerr-service.d.ts","sourceRoot":"","sources":["../../../src/services/seerr/seerr-service.ts"],"names":[],"mappings":"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"}
|