@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.
Files changed (73) hide show
  1. package/AGENTS.md +407 -0
  2. package/CLAUDE.md +407 -0
  3. package/Dockerfile +121 -0
  4. package/LICENSE +201 -0
  5. package/README.md +302 -0
  6. package/changelog/0.1.x/0.1.0.md +26 -0
  7. package/changelog/template.md +127 -0
  8. package/dist/config/server-config.d.ts +27 -0
  9. package/dist/config/server-config.d.ts.map +1 -0
  10. package/dist/config/server-config.js +45 -0
  11. package/dist/config/server-config.js.map +1 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +24 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/mcp-server/resources/definitions/index.d.ts +47 -0
  17. package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -0
  18. package/dist/mcp-server/resources/definitions/index.js +8 -0
  19. package/dist/mcp-server/resources/definitions/index.js.map +1 -0
  20. package/dist/mcp-server/resources/definitions/request.resource.d.ts +53 -0
  21. package/dist/mcp-server/resources/definitions/request.resource.d.ts.map +1 -0
  22. package/dist/mcp-server/resources/definitions/request.resource.js +70 -0
  23. package/dist/mcp-server/resources/definitions/request.resource.js.map +1 -0
  24. package/dist/mcp-server/tools/definitions/get-media.tool.d.ts +66 -0
  25. package/dist/mcp-server/tools/definitions/get-media.tool.d.ts.map +1 -0
  26. package/dist/mcp-server/tools/definitions/get-media.tool.js +197 -0
  27. package/dist/mcp-server/tools/definitions/get-media.tool.js.map +1 -0
  28. package/dist/mcp-server/tools/definitions/index.d.ts +341 -0
  29. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -0
  30. package/dist/mcp-server/tools/definitions/index.js +22 -0
  31. package/dist/mcp-server/tools/definitions/index.js.map +1 -0
  32. package/dist/mcp-server/tools/definitions/list-requests.tool.d.ts +76 -0
  33. package/dist/mcp-server/tools/definitions/list-requests.tool.d.ts.map +1 -0
  34. package/dist/mcp-server/tools/definitions/list-requests.tool.js +187 -0
  35. package/dist/mcp-server/tools/definitions/list-requests.tool.js.map +1 -0
  36. package/dist/mcp-server/tools/definitions/request-media.tool.d.ts +117 -0
  37. package/dist/mcp-server/tools/definitions/request-media.tool.d.ts.map +1 -0
  38. package/dist/mcp-server/tools/definitions/request-media.tool.js +347 -0
  39. package/dist/mcp-server/tools/definitions/request-media.tool.js.map +1 -0
  40. package/dist/mcp-server/tools/definitions/request-status.tool.d.ts +55 -0
  41. package/dist/mcp-server/tools/definitions/request-status.tool.d.ts.map +1 -0
  42. package/dist/mcp-server/tools/definitions/request-status.tool.js +111 -0
  43. package/dist/mcp-server/tools/definitions/request-status.tool.js.map +1 -0
  44. package/dist/mcp-server/tools/definitions/search-media.tool.d.ts +51 -0
  45. package/dist/mcp-server/tools/definitions/search-media.tool.d.ts.map +1 -0
  46. package/dist/mcp-server/tools/definitions/search-media.tool.js +167 -0
  47. package/dist/mcp-server/tools/definitions/search-media.tool.js.map +1 -0
  48. package/dist/mcp-server/tools/definitions/service-options.tool.d.ts +51 -0
  49. package/dist/mcp-server/tools/definitions/service-options.tool.d.ts.map +1 -0
  50. package/dist/mcp-server/tools/definitions/service-options.tool.js +195 -0
  51. package/dist/mcp-server/tools/definitions/service-options.tool.js.map +1 -0
  52. package/dist/services/seerr/errors.d.ts +33 -0
  53. package/dist/services/seerr/errors.d.ts.map +1 -0
  54. package/dist/services/seerr/errors.js +74 -0
  55. package/dist/services/seerr/errors.js.map +1 -0
  56. package/dist/services/seerr/normalizers.d.ts +111 -0
  57. package/dist/services/seerr/normalizers.d.ts.map +1 -0
  58. package/dist/services/seerr/normalizers.js +179 -0
  59. package/dist/services/seerr/normalizers.js.map +1 -0
  60. package/dist/services/seerr/seerr-service.d.ts +92 -0
  61. package/dist/services/seerr/seerr-service.d.ts.map +1 -0
  62. package/dist/services/seerr/seerr-service.js +187 -0
  63. package/dist/services/seerr/seerr-service.js.map +1 -0
  64. package/dist/services/seerr/status.d.ts +27 -0
  65. package/dist/services/seerr/status.d.ts.map +1 -0
  66. package/dist/services/seerr/status.js +70 -0
  67. package/dist/services/seerr/status.js.map +1 -0
  68. package/dist/services/seerr/types.d.ts +221 -0
  69. package/dist/services/seerr/types.d.ts.map +1 -0
  70. package/dist/services/seerr/types.js +11 -0
  71. package/dist/services/seerr/types.js.map +1 -0
  72. package/package.json +86 -0
  73. 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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"}