@thelord/mcp-arr 1.0.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/dist/index.js ADDED
@@ -0,0 +1,1947 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP Server for *arr Media Management Suite
4
+ *
5
+ * Provides tools for managing Sonarr (TV), Radarr (Movies), Lidarr (Music),
6
+ * Readarr (Books), and Prowlarr (Indexers) through Claude Code.
7
+ *
8
+ * Environment variables:
9
+ * - SONARR_URL, SONARR_API_KEY
10
+ * - RADARR_URL, RADARR_API_KEY
11
+ * - LIDARR_URL, LIDARR_API_KEY
12
+ * - READARR_URL, READARR_API_KEY
13
+ * - PROWLARR_URL, PROWLARR_API_KEY
14
+ */
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
18
+ import { SonarrClient, RadarrClient, LidarrClient, ReadarrClient, ProwlarrClient, } from "./arr-client.js";
19
+ import { trashClient } from "./trash-client.js";
20
+ import { LingarrClient } from "./lingarr-client.js";
21
+ import { getLingarrTools } from "./lingarr-tools.js";
22
+ import { handleLingarrTool, getLingarrStatus } from "./lingarr-handlers.js";
23
+ const services = [
24
+ { name: 'sonarr', displayName: 'Sonarr (TV)', url: process.env.SONARR_URL, apiKey: process.env.SONARR_API_KEY },
25
+ { name: 'radarr', displayName: 'Radarr (Movies)', url: process.env.RADARR_URL, apiKey: process.env.RADARR_API_KEY },
26
+ { name: 'lidarr', displayName: 'Lidarr (Music)', url: process.env.LIDARR_URL, apiKey: process.env.LIDARR_API_KEY },
27
+ { name: 'readarr', displayName: 'Readarr (Books)', url: process.env.READARR_URL, apiKey: process.env.READARR_API_KEY },
28
+ { name: 'prowlarr', displayName: 'Prowlarr (Indexers)', url: process.env.PROWLARR_URL, apiKey: process.env.PROWLARR_API_KEY },
29
+ { name: 'lingarr', displayName: 'Lingarr (Subtitles)', url: process.env.LINGARR_URL, apiKey: process.env.LINGARR_API_KEY },
30
+ ];
31
+ // Check which services are configured
32
+ const configuredServices = services.filter(s => s.url && s.apiKey);
33
+ if (configuredServices.length === 0) {
34
+ console.error("Error: No *arr services configured. Set at least one pair of URL and API_KEY environment variables.");
35
+ console.error("Example: SONARR_URL and SONARR_API_KEY");
36
+ process.exit(1);
37
+ }
38
+ // Initialize clients for configured services
39
+ const clients = {};
40
+ for (const service of configuredServices) {
41
+ const config = { url: service.url, apiKey: service.apiKey };
42
+ switch (service.name) {
43
+ case 'sonarr':
44
+ clients.sonarr = new SonarrClient(config);
45
+ break;
46
+ case 'radarr':
47
+ clients.radarr = new RadarrClient(config);
48
+ break;
49
+ case 'lidarr':
50
+ clients.lidarr = new LidarrClient(config);
51
+ break;
52
+ case 'readarr':
53
+ clients.readarr = new ReadarrClient(config);
54
+ break;
55
+ case 'prowlarr':
56
+ clients.prowlarr = new ProwlarrClient(config);
57
+ break;
58
+ case 'lingarr':
59
+ clients.lingarr = new LingarrClient(config);
60
+ break;
61
+ }
62
+ }
63
+ // Build tools based on configured services
64
+ const TOOLS = [
65
+ // General tool available for all
66
+ {
67
+ name: "arr_status",
68
+ description: `Get status of all configured *arr services. Currently configured: ${configuredServices.map(s => s.displayName).join(', ')}`,
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {},
72
+ required: [],
73
+ },
74
+ },
75
+ ];
76
+ // Configuration review tools for each service
77
+ // These are added dynamically based on configured services
78
+ // Helper function to create config tools for a service
79
+ function addConfigTools(serviceName, displayName) {
80
+ TOOLS.push({
81
+ name: `${serviceName}_get_quality_profiles`,
82
+ description: `Get detailed quality profiles from ${displayName}. Shows allowed qualities, upgrade settings, and custom format scores.`,
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {},
86
+ required: [],
87
+ },
88
+ }, {
89
+ name: `${serviceName}_get_health`,
90
+ description: `Get health check warnings and issues from ${displayName}. Shows any problems detected by the application.`,
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: {},
94
+ required: [],
95
+ },
96
+ }, {
97
+ name: `${serviceName}_get_root_folders`,
98
+ description: `Get root folders and storage info from ${displayName}. Shows paths, free space, and unmapped folders.`,
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {},
102
+ required: [],
103
+ },
104
+ }, {
105
+ name: `${serviceName}_get_download_clients`,
106
+ description: `Get download client configurations from ${displayName}. Shows configured clients and their settings.`,
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {},
110
+ required: [],
111
+ },
112
+ }, {
113
+ name: `${serviceName}_get_naming`,
114
+ description: `Get file naming configuration from ${displayName}. Shows naming patterns for files and folders.`,
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {},
118
+ required: [],
119
+ },
120
+ }, {
121
+ name: `${serviceName}_get_tags`,
122
+ description: `Get all tags defined in ${displayName}. Tags can be used to organize and filter content.`,
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {},
126
+ required: [],
127
+ },
128
+ }, {
129
+ name: `${serviceName}_review_setup`,
130
+ description: `Get comprehensive configuration review for ${displayName}. Returns all settings for analysis: quality profiles, download clients, naming, storage, indexers, health warnings, and more. Use this to analyze the setup and suggest improvements.`,
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {},
134
+ required: [],
135
+ },
136
+ });
137
+ }
138
+ // Add config tools for each configured service (except Prowlarr which has different config)
139
+ if (clients.sonarr)
140
+ addConfigTools('sonarr', 'Sonarr (TV)');
141
+ if (clients.radarr)
142
+ addConfigTools('radarr', 'Radarr (Movies)');
143
+ if (clients.lidarr)
144
+ addConfigTools('lidarr', 'Lidarr (Music)');
145
+ if (clients.readarr)
146
+ addConfigTools('readarr', 'Readarr (Books)');
147
+ // Sonarr tools
148
+ if (clients.sonarr) {
149
+ TOOLS.push({
150
+ name: "sonarr_get_series",
151
+ description: "Get all TV series in Sonarr library",
152
+ inputSchema: {
153
+ type: "object",
154
+ properties: {},
155
+ required: [],
156
+ },
157
+ }, {
158
+ name: "sonarr_search",
159
+ description: "Search for TV series to add to Sonarr",
160
+ inputSchema: {
161
+ type: "object",
162
+ properties: {
163
+ term: {
164
+ type: "string",
165
+ description: "Search term (show name)",
166
+ },
167
+ },
168
+ required: ["term"],
169
+ },
170
+ }, {
171
+ name: "sonarr_get_queue",
172
+ description: "Get Sonarr download queue",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {},
176
+ required: [],
177
+ },
178
+ }, {
179
+ name: "sonarr_get_calendar",
180
+ description: "Get upcoming TV episodes from Sonarr",
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ days: {
185
+ type: "number",
186
+ description: "Number of days to look ahead (default: 7)",
187
+ },
188
+ },
189
+ required: [],
190
+ },
191
+ }, {
192
+ name: "sonarr_get_episodes",
193
+ description: "Get episodes for a TV series. Shows which episodes are available and which are missing.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ seriesId: {
198
+ type: "number",
199
+ description: "Series ID to get episodes for",
200
+ },
201
+ seasonNumber: {
202
+ type: "number",
203
+ description: "Optional: filter to a specific season",
204
+ },
205
+ },
206
+ required: ["seriesId"],
207
+ },
208
+ }, {
209
+ name: "sonarr_search_missing",
210
+ description: "Trigger a search for all missing episodes in a series",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ seriesId: {
215
+ type: "number",
216
+ description: "Series ID to search for missing episodes",
217
+ },
218
+ },
219
+ required: ["seriesId"],
220
+ },
221
+ }, {
222
+ name: "sonarr_search_episode",
223
+ description: "Trigger a search for specific episode(s)",
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: {
227
+ episodeIds: {
228
+ type: "array",
229
+ items: { type: "number" },
230
+ description: "Episode ID(s) to search for",
231
+ },
232
+ },
233
+ required: ["episodeIds"],
234
+ },
235
+ });
236
+ }
237
+ // Radarr tools
238
+ if (clients.radarr) {
239
+ TOOLS.push({
240
+ name: "radarr_get_movies",
241
+ description: "Get all movies in Radarr library",
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {},
245
+ required: [],
246
+ },
247
+ }, {
248
+ name: "radarr_search",
249
+ description: "Search for movies to add to Radarr",
250
+ inputSchema: {
251
+ type: "object",
252
+ properties: {
253
+ term: {
254
+ type: "string",
255
+ description: "Search term (movie name)",
256
+ },
257
+ },
258
+ required: ["term"],
259
+ },
260
+ }, {
261
+ name: "radarr_get_queue",
262
+ description: "Get Radarr download queue",
263
+ inputSchema: {
264
+ type: "object",
265
+ properties: {},
266
+ required: [],
267
+ },
268
+ }, {
269
+ name: "radarr_get_calendar",
270
+ description: "Get upcoming movie releases from Radarr",
271
+ inputSchema: {
272
+ type: "object",
273
+ properties: {
274
+ days: {
275
+ type: "number",
276
+ description: "Number of days to look ahead (default: 30)",
277
+ },
278
+ },
279
+ required: [],
280
+ },
281
+ }, {
282
+ name: "radarr_search_movie",
283
+ description: "Trigger a search to download a movie that's already in your library",
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: {
287
+ movieId: {
288
+ type: "number",
289
+ description: "Movie ID to search for",
290
+ },
291
+ },
292
+ required: ["movieId"],
293
+ },
294
+ });
295
+ }
296
+ // Lidarr tools
297
+ if (clients.lidarr) {
298
+ TOOLS.push({
299
+ name: "lidarr_get_artists",
300
+ description: "Get all artists in Lidarr library",
301
+ inputSchema: {
302
+ type: "object",
303
+ properties: {},
304
+ required: [],
305
+ },
306
+ }, {
307
+ name: "lidarr_search",
308
+ description: "Search for artists to add to Lidarr",
309
+ inputSchema: {
310
+ type: "object",
311
+ properties: {
312
+ term: {
313
+ type: "string",
314
+ description: "Search term (artist name)",
315
+ },
316
+ },
317
+ required: ["term"],
318
+ },
319
+ }, {
320
+ name: "lidarr_get_queue",
321
+ description: "Get Lidarr download queue",
322
+ inputSchema: {
323
+ type: "object",
324
+ properties: {},
325
+ required: [],
326
+ },
327
+ }, {
328
+ name: "lidarr_get_albums",
329
+ description: "Get albums for an artist in Lidarr. Shows which albums are available and which are missing.",
330
+ inputSchema: {
331
+ type: "object",
332
+ properties: {
333
+ artistId: {
334
+ type: "number",
335
+ description: "Artist ID to get albums for",
336
+ },
337
+ },
338
+ required: ["artistId"],
339
+ },
340
+ }, {
341
+ name: "lidarr_search_album",
342
+ description: "Trigger a search for a specific album to download",
343
+ inputSchema: {
344
+ type: "object",
345
+ properties: {
346
+ albumId: {
347
+ type: "number",
348
+ description: "Album ID to search for",
349
+ },
350
+ },
351
+ required: ["albumId"],
352
+ },
353
+ }, {
354
+ name: "lidarr_search_missing",
355
+ description: "Trigger a search for all missing albums for an artist",
356
+ inputSchema: {
357
+ type: "object",
358
+ properties: {
359
+ artistId: {
360
+ type: "number",
361
+ description: "Artist ID to search missing albums for",
362
+ },
363
+ },
364
+ required: ["artistId"],
365
+ },
366
+ }, {
367
+ name: "lidarr_get_calendar",
368
+ description: "Get upcoming album releases from Lidarr",
369
+ inputSchema: {
370
+ type: "object",
371
+ properties: {
372
+ days: {
373
+ type: "number",
374
+ description: "Number of days to look ahead (default: 30)",
375
+ },
376
+ },
377
+ required: [],
378
+ },
379
+ });
380
+ }
381
+ // Readarr tools
382
+ if (clients.readarr) {
383
+ TOOLS.push({
384
+ name: "readarr_get_authors",
385
+ description: "Get all authors in Readarr library",
386
+ inputSchema: {
387
+ type: "object",
388
+ properties: {},
389
+ required: [],
390
+ },
391
+ }, {
392
+ name: "readarr_search",
393
+ description: "Search for authors to add to Readarr",
394
+ inputSchema: {
395
+ type: "object",
396
+ properties: {
397
+ term: {
398
+ type: "string",
399
+ description: "Search term (author name)",
400
+ },
401
+ },
402
+ required: ["term"],
403
+ },
404
+ }, {
405
+ name: "readarr_get_queue",
406
+ description: "Get Readarr download queue",
407
+ inputSchema: {
408
+ type: "object",
409
+ properties: {},
410
+ required: [],
411
+ },
412
+ }, {
413
+ name: "readarr_get_books",
414
+ description: "Get books for an author in Readarr. Shows which books are available and which are missing.",
415
+ inputSchema: {
416
+ type: "object",
417
+ properties: {
418
+ authorId: {
419
+ type: "number",
420
+ description: "Author ID to get books for",
421
+ },
422
+ },
423
+ required: ["authorId"],
424
+ },
425
+ }, {
426
+ name: "readarr_search_book",
427
+ description: "Trigger a search for a specific book to download",
428
+ inputSchema: {
429
+ type: "object",
430
+ properties: {
431
+ bookIds: {
432
+ type: "array",
433
+ items: { type: "number" },
434
+ description: "Book ID(s) to search for",
435
+ },
436
+ },
437
+ required: ["bookIds"],
438
+ },
439
+ }, {
440
+ name: "readarr_search_missing",
441
+ description: "Trigger a search for all missing books for an author",
442
+ inputSchema: {
443
+ type: "object",
444
+ properties: {
445
+ authorId: {
446
+ type: "number",
447
+ description: "Author ID to search missing books for",
448
+ },
449
+ },
450
+ required: ["authorId"],
451
+ },
452
+ }, {
453
+ name: "readarr_get_calendar",
454
+ description: "Get upcoming book releases from Readarr",
455
+ inputSchema: {
456
+ type: "object",
457
+ properties: {
458
+ days: {
459
+ type: "number",
460
+ description: "Number of days to look ahead (default: 30)",
461
+ },
462
+ },
463
+ required: [],
464
+ },
465
+ });
466
+ }
467
+ // Prowlarr tools
468
+ if (clients.prowlarr) {
469
+ TOOLS.push({
470
+ name: "prowlarr_get_indexers",
471
+ description: "Get all configured indexers in Prowlarr",
472
+ inputSchema: {
473
+ type: "object",
474
+ properties: {},
475
+ required: [],
476
+ },
477
+ }, {
478
+ name: "prowlarr_search",
479
+ description: "Search across all Prowlarr indexers",
480
+ inputSchema: {
481
+ type: "object",
482
+ properties: {
483
+ query: {
484
+ type: "string",
485
+ description: "Search query",
486
+ },
487
+ },
488
+ required: ["query"],
489
+ },
490
+ }, {
491
+ name: "prowlarr_test_indexers",
492
+ description: "Test all indexers and return their health status",
493
+ inputSchema: {
494
+ type: "object",
495
+ properties: {},
496
+ required: [],
497
+ },
498
+ }, {
499
+ name: "prowlarr_get_stats",
500
+ description: "Get indexer statistics (queries, grabs, failures)",
501
+ inputSchema: {
502
+ type: "object",
503
+ properties: {},
504
+ required: [],
505
+ },
506
+ });
507
+ }
508
+ // Lingarr tools - imported from separate module to reduce merge conflicts
509
+ if (clients.lingarr) {
510
+ TOOLS.push(...getLingarrTools());
511
+ }
512
+ // Cross-service search tool
513
+ TOOLS.push({
514
+ name: "arr_search_all",
515
+ description: "Search across all configured *arr services for any media",
516
+ inputSchema: {
517
+ type: "object",
518
+ properties: {
519
+ term: {
520
+ type: "string",
521
+ description: "Search term",
522
+ },
523
+ },
524
+ required: ["term"],
525
+ },
526
+ });
527
+ // TRaSH Guides tools (always available - no *arr config required)
528
+ TOOLS.push({
529
+ name: "trash_list_profiles",
530
+ description: "List available TRaSH Guides quality profiles for Radarr or Sonarr. Shows recommended profiles for different use cases (1080p, 4K, Remux, etc.)",
531
+ inputSchema: {
532
+ type: "object",
533
+ properties: {
534
+ service: {
535
+ type: "string",
536
+ enum: ["radarr", "sonarr"],
537
+ description: "Which service to get profiles for",
538
+ },
539
+ },
540
+ required: ["service"],
541
+ },
542
+ }, {
543
+ name: "trash_get_profile",
544
+ description: "Get a specific TRaSH Guides quality profile with all custom format scores, quality settings, and implementation details",
545
+ inputSchema: {
546
+ type: "object",
547
+ properties: {
548
+ service: {
549
+ type: "string",
550
+ enum: ["radarr", "sonarr"],
551
+ description: "Which service",
552
+ },
553
+ profile: {
554
+ type: "string",
555
+ description: "Profile name (e.g., 'remux-web-1080p', 'uhd-bluray-web', 'hd-bluray-web')",
556
+ },
557
+ },
558
+ required: ["service", "profile"],
559
+ },
560
+ }, {
561
+ name: "trash_list_custom_formats",
562
+ description: "List available TRaSH Guides custom formats. Can filter by category: hdr, audio, resolution, source, streaming, anime, unwanted, release, language",
563
+ inputSchema: {
564
+ type: "object",
565
+ properties: {
566
+ service: {
567
+ type: "string",
568
+ enum: ["radarr", "sonarr"],
569
+ description: "Which service",
570
+ },
571
+ category: {
572
+ type: "string",
573
+ description: "Optional filter by category",
574
+ },
575
+ },
576
+ required: ["service"],
577
+ },
578
+ }, {
579
+ name: "trash_get_naming",
580
+ description: "Get TRaSH Guides recommended naming conventions for your media server (Plex, Emby, Jellyfin, or standard)",
581
+ inputSchema: {
582
+ type: "object",
583
+ properties: {
584
+ service: {
585
+ type: "string",
586
+ enum: ["radarr", "sonarr"],
587
+ description: "Which service",
588
+ },
589
+ mediaServer: {
590
+ type: "string",
591
+ enum: ["plex", "emby", "jellyfin", "standard"],
592
+ description: "Which media server you use",
593
+ },
594
+ },
595
+ required: ["service", "mediaServer"],
596
+ },
597
+ }, {
598
+ name: "trash_get_quality_sizes",
599
+ description: "Get TRaSH Guides recommended min/max/preferred sizes for each quality level",
600
+ inputSchema: {
601
+ type: "object",
602
+ properties: {
603
+ service: {
604
+ type: "string",
605
+ enum: ["radarr", "sonarr"],
606
+ description: "Which service",
607
+ },
608
+ type: {
609
+ type: "string",
610
+ description: "Content type: 'movie', 'anime' for Radarr; 'series', 'anime' for Sonarr",
611
+ },
612
+ },
613
+ required: ["service"],
614
+ },
615
+ }, {
616
+ name: "trash_compare_profile",
617
+ description: "Compare your quality profile against TRaSH Guides recommendations. Shows missing custom formats, scoring differences, and quality settings. Requires the corresponding *arr service to be configured.",
618
+ inputSchema: {
619
+ type: "object",
620
+ properties: {
621
+ service: {
622
+ type: "string",
623
+ enum: ["radarr", "sonarr"],
624
+ description: "Which service",
625
+ },
626
+ profileId: {
627
+ type: "number",
628
+ description: "Your quality profile ID to compare",
629
+ },
630
+ trashProfile: {
631
+ type: "string",
632
+ description: "TRaSH profile name to compare against",
633
+ },
634
+ },
635
+ required: ["service", "profileId", "trashProfile"],
636
+ },
637
+ }, {
638
+ name: "trash_compare_naming",
639
+ description: "Compare your naming configuration against TRaSH Guides recommendations. Requires the corresponding *arr service to be configured.",
640
+ inputSchema: {
641
+ type: "object",
642
+ properties: {
643
+ service: {
644
+ type: "string",
645
+ enum: ["radarr", "sonarr"],
646
+ description: "Which service",
647
+ },
648
+ mediaServer: {
649
+ type: "string",
650
+ enum: ["plex", "emby", "jellyfin", "standard"],
651
+ description: "Which media server you use",
652
+ },
653
+ },
654
+ required: ["service", "mediaServer"],
655
+ },
656
+ });
657
+ // Create server instance
658
+ const server = new Server({
659
+ name: "mcp-arr",
660
+ version: "1.0.0",
661
+ }, {
662
+ capabilities: {
663
+ tools: {},
664
+ },
665
+ });
666
+ // Handle list tools request
667
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
668
+ return { tools: TOOLS };
669
+ });
670
+ // Handle tool calls
671
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
672
+ const { name, arguments: args } = request.params;
673
+ try {
674
+ // Delegate Lingarr tools to separate handler module
675
+ const lingarrResult = await handleLingarrTool(name, (args || {}), clients.lingarr);
676
+ if (lingarrResult) {
677
+ return lingarrResult;
678
+ }
679
+ switch (name) {
680
+ case "arr_status": {
681
+ const statuses = {};
682
+ for (const service of configuredServices) {
683
+ try {
684
+ if (service.name === 'lingarr' && clients.lingarr) {
685
+ // Lingarr status handled by separate module
686
+ statuses[service.name] = await getLingarrStatus(clients.lingarr);
687
+ }
688
+ else {
689
+ const client = clients[service.name];
690
+ if (client) {
691
+ const status = await client.getStatus();
692
+ statuses[service.name] = {
693
+ configured: true,
694
+ connected: true,
695
+ version: status.version,
696
+ appName: status.appName,
697
+ };
698
+ }
699
+ }
700
+ }
701
+ catch (error) {
702
+ statuses[service.name] = {
703
+ configured: true,
704
+ connected: false,
705
+ error: error instanceof Error ? error.message : String(error),
706
+ };
707
+ }
708
+ }
709
+ // Add unconfigured services
710
+ for (const service of services) {
711
+ if (!statuses[service.name]) {
712
+ statuses[service.name] = { configured: false };
713
+ }
714
+ }
715
+ return {
716
+ content: [{ type: "text", text: JSON.stringify(statuses, null, 2) }],
717
+ };
718
+ }
719
+ // Dynamic config tool handlers
720
+ // Quality Profiles
721
+ case "sonarr_get_quality_profiles":
722
+ case "radarr_get_quality_profiles":
723
+ case "lidarr_get_quality_profiles":
724
+ case "readarr_get_quality_profiles": {
725
+ const serviceName = name.split('_')[0];
726
+ const client = clients[serviceName];
727
+ if (!client)
728
+ throw new Error(`${serviceName} not configured`);
729
+ const profiles = await client.getQualityProfiles();
730
+ return {
731
+ content: [{
732
+ type: "text",
733
+ text: JSON.stringify({
734
+ count: profiles.length,
735
+ profiles: profiles.map((p) => ({
736
+ id: p.id,
737
+ name: p.name,
738
+ upgradeAllowed: p.upgradeAllowed,
739
+ cutoff: p.cutoff,
740
+ allowedQualities: p.items
741
+ .filter((i) => i.allowed)
742
+ .map((i) => i.quality?.name || i.name || (i.items?.map((q) => q.quality.name).join(', ')))
743
+ .filter(Boolean),
744
+ customFormats: p.formatItems?.filter((f) => f.score !== 0).map((f) => ({
745
+ name: f.name,
746
+ score: f.score,
747
+ })) || [],
748
+ minFormatScore: p.minFormatScore,
749
+ cutoffFormatScore: p.cutoffFormatScore,
750
+ })),
751
+ }, null, 2),
752
+ }],
753
+ };
754
+ }
755
+ // Health checks
756
+ case "sonarr_get_health":
757
+ case "radarr_get_health":
758
+ case "lidarr_get_health":
759
+ case "readarr_get_health": {
760
+ const serviceName = name.split('_')[0];
761
+ const client = clients[serviceName];
762
+ if (!client)
763
+ throw new Error(`${serviceName} not configured`);
764
+ const health = await client.getHealth();
765
+ return {
766
+ content: [{
767
+ type: "text",
768
+ text: JSON.stringify({
769
+ issueCount: health.length,
770
+ issues: health.map((h) => ({
771
+ source: h.source,
772
+ type: h.type,
773
+ message: h.message,
774
+ wikiUrl: h.wikiUrl,
775
+ })),
776
+ status: health.length === 0 ? 'healthy' : 'issues detected',
777
+ }, null, 2),
778
+ }],
779
+ };
780
+ }
781
+ // Root folders
782
+ case "sonarr_get_root_folders":
783
+ case "radarr_get_root_folders":
784
+ case "lidarr_get_root_folders":
785
+ case "readarr_get_root_folders": {
786
+ const serviceName = name.split('_')[0];
787
+ const client = clients[serviceName];
788
+ if (!client)
789
+ throw new Error(`${serviceName} not configured`);
790
+ const folders = await client.getRootFoldersDetailed();
791
+ return {
792
+ content: [{
793
+ type: "text",
794
+ text: JSON.stringify({
795
+ count: folders.length,
796
+ folders: folders.map((f) => ({
797
+ id: f.id,
798
+ path: f.path,
799
+ accessible: f.accessible,
800
+ freeSpace: formatBytes(f.freeSpace),
801
+ freeSpaceBytes: f.freeSpace,
802
+ unmappedFolders: f.unmappedFolders?.length || 0,
803
+ })),
804
+ }, null, 2),
805
+ }],
806
+ };
807
+ }
808
+ // Download clients
809
+ case "sonarr_get_download_clients":
810
+ case "radarr_get_download_clients":
811
+ case "lidarr_get_download_clients":
812
+ case "readarr_get_download_clients": {
813
+ const serviceName = name.split('_')[0];
814
+ const client = clients[serviceName];
815
+ if (!client)
816
+ throw new Error(`${serviceName} not configured`);
817
+ const downloadClients = await client.getDownloadClients();
818
+ return {
819
+ content: [{
820
+ type: "text",
821
+ text: JSON.stringify({
822
+ count: downloadClients.length,
823
+ clients: downloadClients.map((c) => ({
824
+ id: c.id,
825
+ name: c.name,
826
+ implementation: c.implementationName,
827
+ protocol: c.protocol,
828
+ enabled: c.enable,
829
+ priority: c.priority,
830
+ removeCompletedDownloads: c.removeCompletedDownloads,
831
+ removeFailedDownloads: c.removeFailedDownloads,
832
+ tags: c.tags,
833
+ })),
834
+ }, null, 2),
835
+ }],
836
+ };
837
+ }
838
+ // Naming config
839
+ case "sonarr_get_naming":
840
+ case "radarr_get_naming":
841
+ case "lidarr_get_naming":
842
+ case "readarr_get_naming": {
843
+ const serviceName = name.split('_')[0];
844
+ const client = clients[serviceName];
845
+ if (!client)
846
+ throw new Error(`${serviceName} not configured`);
847
+ const naming = await client.getNamingConfig();
848
+ return {
849
+ content: [{
850
+ type: "text",
851
+ text: JSON.stringify(naming, null, 2),
852
+ }],
853
+ };
854
+ }
855
+ // Tags
856
+ case "sonarr_get_tags":
857
+ case "radarr_get_tags":
858
+ case "lidarr_get_tags":
859
+ case "readarr_get_tags": {
860
+ const serviceName = name.split('_')[0];
861
+ const client = clients[serviceName];
862
+ if (!client)
863
+ throw new Error(`${serviceName} not configured`);
864
+ const tags = await client.getTags();
865
+ return {
866
+ content: [{
867
+ type: "text",
868
+ text: JSON.stringify({
869
+ count: tags.length,
870
+ tags: tags.map((t) => ({ id: t.id, label: t.label })),
871
+ }, null, 2),
872
+ }],
873
+ };
874
+ }
875
+ // Comprehensive setup review
876
+ case "sonarr_review_setup":
877
+ case "radarr_review_setup":
878
+ case "lidarr_review_setup":
879
+ case "readarr_review_setup": {
880
+ const serviceName = name.split('_')[0];
881
+ const client = clients[serviceName];
882
+ if (!client)
883
+ throw new Error(`${serviceName} not configured`);
884
+ // Gather all configuration data
885
+ const [status, health, qualityProfiles, qualityDefinitions, downloadClients, naming, mediaManagement, rootFolders, tags, indexers] = await Promise.all([
886
+ client.getStatus(),
887
+ client.getHealth(),
888
+ client.getQualityProfiles(),
889
+ client.getQualityDefinitions(),
890
+ client.getDownloadClients(),
891
+ client.getNamingConfig(),
892
+ client.getMediaManagement(),
893
+ client.getRootFoldersDetailed(),
894
+ client.getTags(),
895
+ client.getIndexers(),
896
+ ]);
897
+ // For Lidarr/Readarr, also get metadata profiles
898
+ let metadataProfiles = null;
899
+ if (serviceName === 'lidarr' && clients.lidarr) {
900
+ metadataProfiles = await clients.lidarr.getMetadataProfiles();
901
+ }
902
+ else if (serviceName === 'readarr' && clients.readarr) {
903
+ metadataProfiles = await clients.readarr.getMetadataProfiles();
904
+ }
905
+ const review = {
906
+ service: serviceName,
907
+ version: status.version,
908
+ appName: status.appName,
909
+ platform: {
910
+ os: status.osName,
911
+ isDocker: status.isDocker,
912
+ },
913
+ health: {
914
+ issueCount: health.length,
915
+ issues: health,
916
+ },
917
+ storage: {
918
+ rootFolders: rootFolders.map((f) => ({
919
+ path: f.path,
920
+ accessible: f.accessible,
921
+ freeSpace: formatBytes(f.freeSpace),
922
+ freeSpaceBytes: f.freeSpace,
923
+ unmappedFolderCount: f.unmappedFolders?.length || 0,
924
+ })),
925
+ },
926
+ qualityProfiles: qualityProfiles.map((p) => ({
927
+ id: p.id,
928
+ name: p.name,
929
+ upgradeAllowed: p.upgradeAllowed,
930
+ cutoff: p.cutoff,
931
+ allowedQualities: p.items
932
+ .filter((i) => i.allowed)
933
+ .map((i) => i.quality?.name || i.name || (i.items?.map((q) => q.quality.name).join(', ')))
934
+ .filter(Boolean),
935
+ customFormatsWithScores: p.formatItems?.filter((f) => f.score !== 0).length || 0,
936
+ minFormatScore: p.minFormatScore,
937
+ })),
938
+ qualityDefinitions: qualityDefinitions.map((d) => ({
939
+ quality: d.quality.name,
940
+ minSize: d.minSize + ' MB/min',
941
+ maxSize: d.maxSize === 0 ? 'unlimited' : d.maxSize + ' MB/min',
942
+ preferredSize: d.preferredSize + ' MB/min',
943
+ })),
944
+ downloadClients: downloadClients.map((c) => ({
945
+ name: c.name,
946
+ type: c.implementationName,
947
+ protocol: c.protocol,
948
+ enabled: c.enable,
949
+ priority: c.priority,
950
+ })),
951
+ indexers: indexers.map((i) => ({
952
+ name: i.name,
953
+ protocol: i.protocol,
954
+ enableRss: i.enableRss,
955
+ enableAutomaticSearch: i.enableAutomaticSearch,
956
+ enableInteractiveSearch: i.enableInteractiveSearch,
957
+ priority: i.priority,
958
+ })),
959
+ naming: naming,
960
+ mediaManagement: {
961
+ recycleBin: mediaManagement.recycleBin || 'not set',
962
+ recycleBinCleanupDays: mediaManagement.recycleBinCleanupDays,
963
+ downloadPropersAndRepacks: mediaManagement.downloadPropersAndRepacks,
964
+ deleteEmptyFolders: mediaManagement.deleteEmptyFolders,
965
+ copyUsingHardlinks: mediaManagement.copyUsingHardlinks,
966
+ importExtraFiles: mediaManagement.importExtraFiles,
967
+ extraFileExtensions: mediaManagement.extraFileExtensions,
968
+ },
969
+ tags: tags.map((t) => t.label),
970
+ ...(metadataProfiles && { metadataProfiles }),
971
+ };
972
+ return {
973
+ content: [{
974
+ type: "text",
975
+ text: JSON.stringify(review, null, 2),
976
+ }],
977
+ };
978
+ }
979
+ // Sonarr handlers
980
+ case "sonarr_get_series": {
981
+ if (!clients.sonarr)
982
+ throw new Error("Sonarr not configured");
983
+ const series = await clients.sonarr.getSeries();
984
+ return {
985
+ content: [{
986
+ type: "text",
987
+ text: JSON.stringify({
988
+ count: series.length,
989
+ series: series.map(s => ({
990
+ id: s.id,
991
+ title: s.title,
992
+ year: s.year,
993
+ status: s.status,
994
+ network: s.network,
995
+ seasons: s.statistics?.seasonCount,
996
+ episodes: s.statistics?.episodeFileCount + '/' + s.statistics?.totalEpisodeCount,
997
+ sizeOnDisk: formatBytes(s.statistics?.sizeOnDisk || 0),
998
+ monitored: s.monitored,
999
+ })),
1000
+ }, null, 2),
1001
+ }],
1002
+ };
1003
+ }
1004
+ case "sonarr_search": {
1005
+ if (!clients.sonarr)
1006
+ throw new Error("Sonarr not configured");
1007
+ const term = args.term;
1008
+ const results = await clients.sonarr.searchSeries(term);
1009
+ return {
1010
+ content: [{
1011
+ type: "text",
1012
+ text: JSON.stringify({
1013
+ count: results.length,
1014
+ results: results.slice(0, 10).map(r => ({
1015
+ title: r.title,
1016
+ year: r.year,
1017
+ tvdbId: r.tvdbId,
1018
+ overview: r.overview?.substring(0, 200) + (r.overview && r.overview.length > 200 ? '...' : ''),
1019
+ })),
1020
+ }, null, 2),
1021
+ }],
1022
+ };
1023
+ }
1024
+ case "sonarr_get_queue": {
1025
+ if (!clients.sonarr)
1026
+ throw new Error("Sonarr not configured");
1027
+ const queue = await clients.sonarr.getQueue();
1028
+ return {
1029
+ content: [{
1030
+ type: "text",
1031
+ text: JSON.stringify({
1032
+ totalRecords: queue.totalRecords,
1033
+ items: queue.records.map(q => ({
1034
+ title: q.title,
1035
+ status: q.status,
1036
+ progress: ((1 - q.sizeleft / q.size) * 100).toFixed(1) + '%',
1037
+ timeLeft: q.timeleft,
1038
+ downloadClient: q.downloadClient,
1039
+ })),
1040
+ }, null, 2),
1041
+ }],
1042
+ };
1043
+ }
1044
+ case "sonarr_get_calendar": {
1045
+ if (!clients.sonarr)
1046
+ throw new Error("Sonarr not configured");
1047
+ const days = args?.days || 7;
1048
+ const start = new Date().toISOString().split('T')[0];
1049
+ const end = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
1050
+ const calendar = await clients.sonarr.getCalendar(start, end);
1051
+ return {
1052
+ content: [{ type: "text", text: JSON.stringify(calendar, null, 2) }],
1053
+ };
1054
+ }
1055
+ case "sonarr_get_episodes": {
1056
+ if (!clients.sonarr)
1057
+ throw new Error("Sonarr not configured");
1058
+ const { seriesId, seasonNumber } = args;
1059
+ const episodes = await clients.sonarr.getEpisodes(seriesId, seasonNumber);
1060
+ return {
1061
+ content: [{
1062
+ type: "text",
1063
+ text: JSON.stringify({
1064
+ count: episodes.length,
1065
+ episodes: episodes.map(e => ({
1066
+ id: e.id,
1067
+ seasonNumber: e.seasonNumber,
1068
+ episodeNumber: e.episodeNumber,
1069
+ title: e.title,
1070
+ airDate: e.airDate,
1071
+ hasFile: e.hasFile,
1072
+ monitored: e.monitored,
1073
+ })),
1074
+ }, null, 2),
1075
+ }],
1076
+ };
1077
+ }
1078
+ case "sonarr_search_missing": {
1079
+ if (!clients.sonarr)
1080
+ throw new Error("Sonarr not configured");
1081
+ const seriesId = args.seriesId;
1082
+ const result = await clients.sonarr.searchMissing(seriesId);
1083
+ return {
1084
+ content: [{
1085
+ type: "text",
1086
+ text: JSON.stringify({
1087
+ success: true,
1088
+ message: `Search triggered for missing episodes`,
1089
+ commandId: result.id,
1090
+ }, null, 2),
1091
+ }],
1092
+ };
1093
+ }
1094
+ case "sonarr_search_episode": {
1095
+ if (!clients.sonarr)
1096
+ throw new Error("Sonarr not configured");
1097
+ const episodeIds = args.episodeIds;
1098
+ const result = await clients.sonarr.searchEpisode(episodeIds);
1099
+ return {
1100
+ content: [{
1101
+ type: "text",
1102
+ text: JSON.stringify({
1103
+ success: true,
1104
+ message: `Search triggered for ${episodeIds.length} episode(s)`,
1105
+ commandId: result.id,
1106
+ }, null, 2),
1107
+ }],
1108
+ };
1109
+ }
1110
+ // Radarr handlers
1111
+ case "radarr_get_movies": {
1112
+ if (!clients.radarr)
1113
+ throw new Error("Radarr not configured");
1114
+ const movies = await clients.radarr.getMovies();
1115
+ return {
1116
+ content: [{
1117
+ type: "text",
1118
+ text: JSON.stringify({
1119
+ count: movies.length,
1120
+ movies: movies.map(m => ({
1121
+ id: m.id,
1122
+ title: m.title,
1123
+ year: m.year,
1124
+ status: m.status,
1125
+ hasFile: m.hasFile,
1126
+ sizeOnDisk: formatBytes(m.sizeOnDisk),
1127
+ monitored: m.monitored,
1128
+ studio: m.studio,
1129
+ })),
1130
+ }, null, 2),
1131
+ }],
1132
+ };
1133
+ }
1134
+ case "radarr_search": {
1135
+ if (!clients.radarr)
1136
+ throw new Error("Radarr not configured");
1137
+ const term = args.term;
1138
+ const results = await clients.radarr.searchMovies(term);
1139
+ return {
1140
+ content: [{
1141
+ type: "text",
1142
+ text: JSON.stringify({
1143
+ count: results.length,
1144
+ results: results.slice(0, 10).map(r => ({
1145
+ title: r.title,
1146
+ year: r.year,
1147
+ tmdbId: r.tmdbId,
1148
+ imdbId: r.imdbId,
1149
+ overview: r.overview?.substring(0, 200) + (r.overview && r.overview.length > 200 ? '...' : ''),
1150
+ })),
1151
+ }, null, 2),
1152
+ }],
1153
+ };
1154
+ }
1155
+ case "radarr_get_queue": {
1156
+ if (!clients.radarr)
1157
+ throw new Error("Radarr not configured");
1158
+ const queue = await clients.radarr.getQueue();
1159
+ return {
1160
+ content: [{
1161
+ type: "text",
1162
+ text: JSON.stringify({
1163
+ totalRecords: queue.totalRecords,
1164
+ items: queue.records.map(q => ({
1165
+ title: q.title,
1166
+ status: q.status,
1167
+ progress: ((1 - q.sizeleft / q.size) * 100).toFixed(1) + '%',
1168
+ timeLeft: q.timeleft,
1169
+ downloadClient: q.downloadClient,
1170
+ })),
1171
+ }, null, 2),
1172
+ }],
1173
+ };
1174
+ }
1175
+ case "radarr_get_calendar": {
1176
+ if (!clients.radarr)
1177
+ throw new Error("Radarr not configured");
1178
+ const days = args?.days || 30;
1179
+ const start = new Date().toISOString().split('T')[0];
1180
+ const end = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
1181
+ const calendar = await clients.radarr.getCalendar(start, end);
1182
+ return {
1183
+ content: [{ type: "text", text: JSON.stringify(calendar, null, 2) }],
1184
+ };
1185
+ }
1186
+ case "radarr_search_movie": {
1187
+ if (!clients.radarr)
1188
+ throw new Error("Radarr not configured");
1189
+ const movieId = args.movieId;
1190
+ const result = await clients.radarr.searchMovie(movieId);
1191
+ return {
1192
+ content: [{
1193
+ type: "text",
1194
+ text: JSON.stringify({
1195
+ success: true,
1196
+ message: `Search triggered for movie`,
1197
+ commandId: result.id,
1198
+ }, null, 2),
1199
+ }],
1200
+ };
1201
+ }
1202
+ // Lidarr handlers
1203
+ case "lidarr_get_artists": {
1204
+ if (!clients.lidarr)
1205
+ throw new Error("Lidarr not configured");
1206
+ const artists = await clients.lidarr.getArtists();
1207
+ return {
1208
+ content: [{
1209
+ type: "text",
1210
+ text: JSON.stringify({
1211
+ count: artists.length,
1212
+ artists: artists.map(a => ({
1213
+ id: a.id,
1214
+ artistName: a.artistName,
1215
+ status: a.status,
1216
+ albums: a.statistics?.albumCount,
1217
+ tracks: a.statistics?.trackFileCount + '/' + a.statistics?.totalTrackCount,
1218
+ sizeOnDisk: formatBytes(a.statistics?.sizeOnDisk || 0),
1219
+ monitored: a.monitored,
1220
+ })),
1221
+ }, null, 2),
1222
+ }],
1223
+ };
1224
+ }
1225
+ case "lidarr_search": {
1226
+ if (!clients.lidarr)
1227
+ throw new Error("Lidarr not configured");
1228
+ const term = args.term;
1229
+ const results = await clients.lidarr.searchArtists(term);
1230
+ return {
1231
+ content: [{
1232
+ type: "text",
1233
+ text: JSON.stringify({
1234
+ count: results.length,
1235
+ results: results.slice(0, 10).map(r => ({
1236
+ title: r.title,
1237
+ foreignArtistId: r.foreignArtistId,
1238
+ overview: r.overview?.substring(0, 200) + (r.overview && r.overview.length > 200 ? '...' : ''),
1239
+ })),
1240
+ }, null, 2),
1241
+ }],
1242
+ };
1243
+ }
1244
+ case "lidarr_get_queue": {
1245
+ if (!clients.lidarr)
1246
+ throw new Error("Lidarr not configured");
1247
+ const queue = await clients.lidarr.getQueue();
1248
+ return {
1249
+ content: [{
1250
+ type: "text",
1251
+ text: JSON.stringify({
1252
+ totalRecords: queue.totalRecords,
1253
+ items: queue.records.map(q => ({
1254
+ title: q.title,
1255
+ status: q.status,
1256
+ progress: ((1 - q.sizeleft / q.size) * 100).toFixed(1) + '%',
1257
+ timeLeft: q.timeleft,
1258
+ downloadClient: q.downloadClient,
1259
+ })),
1260
+ }, null, 2),
1261
+ }],
1262
+ };
1263
+ }
1264
+ case "lidarr_get_albums": {
1265
+ if (!clients.lidarr)
1266
+ throw new Error("Lidarr not configured");
1267
+ const artistId = args.artistId;
1268
+ const albums = await clients.lidarr.getAlbums(artistId);
1269
+ return {
1270
+ content: [{
1271
+ type: "text",
1272
+ text: JSON.stringify({
1273
+ count: albums.length,
1274
+ albums: albums.map(a => ({
1275
+ id: a.id,
1276
+ title: a.title,
1277
+ releaseDate: a.releaseDate,
1278
+ albumType: a.albumType,
1279
+ monitored: a.monitored,
1280
+ tracks: a.statistics ? `${a.statistics.trackFileCount}/${a.statistics.totalTrackCount}` : 'unknown',
1281
+ sizeOnDisk: formatBytes(a.statistics?.sizeOnDisk || 0),
1282
+ percentComplete: a.statistics?.percentOfTracks || 0,
1283
+ grabbed: a.grabbed,
1284
+ })),
1285
+ }, null, 2),
1286
+ }],
1287
+ };
1288
+ }
1289
+ case "lidarr_search_album": {
1290
+ if (!clients.lidarr)
1291
+ throw new Error("Lidarr not configured");
1292
+ const albumId = args.albumId;
1293
+ const result = await clients.lidarr.searchAlbum(albumId);
1294
+ return {
1295
+ content: [{
1296
+ type: "text",
1297
+ text: JSON.stringify({
1298
+ success: true,
1299
+ message: `Search triggered for album`,
1300
+ commandId: result.id,
1301
+ }, null, 2),
1302
+ }],
1303
+ };
1304
+ }
1305
+ case "lidarr_search_missing": {
1306
+ if (!clients.lidarr)
1307
+ throw new Error("Lidarr not configured");
1308
+ const artistId = args.artistId;
1309
+ const result = await clients.lidarr.searchMissingAlbums(artistId);
1310
+ return {
1311
+ content: [{
1312
+ type: "text",
1313
+ text: JSON.stringify({
1314
+ success: true,
1315
+ message: `Search triggered for missing albums`,
1316
+ commandId: result.id,
1317
+ }, null, 2),
1318
+ }],
1319
+ };
1320
+ }
1321
+ case "lidarr_get_calendar": {
1322
+ if (!clients.lidarr)
1323
+ throw new Error("Lidarr not configured");
1324
+ const days = args?.days || 30;
1325
+ const start = new Date().toISOString().split('T')[0];
1326
+ const end = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
1327
+ const calendar = await clients.lidarr.getCalendar(start, end);
1328
+ return {
1329
+ content: [{
1330
+ type: "text",
1331
+ text: JSON.stringify({
1332
+ count: calendar.length,
1333
+ albums: calendar.map(a => ({
1334
+ id: a.id,
1335
+ title: a.title,
1336
+ artistId: a.artistId,
1337
+ releaseDate: a.releaseDate,
1338
+ albumType: a.albumType,
1339
+ monitored: a.monitored,
1340
+ })),
1341
+ }, null, 2),
1342
+ }],
1343
+ };
1344
+ }
1345
+ // Readarr handlers
1346
+ case "readarr_get_authors": {
1347
+ if (!clients.readarr)
1348
+ throw new Error("Readarr not configured");
1349
+ const authors = await clients.readarr.getAuthors();
1350
+ return {
1351
+ content: [{
1352
+ type: "text",
1353
+ text: JSON.stringify({
1354
+ count: authors.length,
1355
+ authors: authors.map(a => ({
1356
+ id: a.id,
1357
+ authorName: a.authorName,
1358
+ status: a.status,
1359
+ books: a.statistics?.bookFileCount + '/' + a.statistics?.totalBookCount,
1360
+ sizeOnDisk: formatBytes(a.statistics?.sizeOnDisk || 0),
1361
+ monitored: a.monitored,
1362
+ })),
1363
+ }, null, 2),
1364
+ }],
1365
+ };
1366
+ }
1367
+ case "readarr_search": {
1368
+ if (!clients.readarr)
1369
+ throw new Error("Readarr not configured");
1370
+ const term = args.term;
1371
+ const results = await clients.readarr.searchAuthors(term);
1372
+ return {
1373
+ content: [{
1374
+ type: "text",
1375
+ text: JSON.stringify({
1376
+ count: results.length,
1377
+ results: results.slice(0, 10).map(r => ({
1378
+ title: r.title,
1379
+ foreignAuthorId: r.foreignAuthorId,
1380
+ overview: r.overview?.substring(0, 200) + (r.overview && r.overview.length > 200 ? '...' : ''),
1381
+ })),
1382
+ }, null, 2),
1383
+ }],
1384
+ };
1385
+ }
1386
+ case "readarr_get_queue": {
1387
+ if (!clients.readarr)
1388
+ throw new Error("Readarr not configured");
1389
+ const queue = await clients.readarr.getQueue();
1390
+ return {
1391
+ content: [{
1392
+ type: "text",
1393
+ text: JSON.stringify({
1394
+ totalRecords: queue.totalRecords,
1395
+ items: queue.records.map(q => ({
1396
+ title: q.title,
1397
+ status: q.status,
1398
+ progress: ((1 - q.sizeleft / q.size) * 100).toFixed(1) + '%',
1399
+ timeLeft: q.timeleft,
1400
+ downloadClient: q.downloadClient,
1401
+ })),
1402
+ }, null, 2),
1403
+ }],
1404
+ };
1405
+ }
1406
+ case "readarr_get_books": {
1407
+ if (!clients.readarr)
1408
+ throw new Error("Readarr not configured");
1409
+ const authorId = args.authorId;
1410
+ const books = await clients.readarr.getBooks(authorId);
1411
+ return {
1412
+ content: [{
1413
+ type: "text",
1414
+ text: JSON.stringify({
1415
+ count: books.length,
1416
+ books: books.map(b => ({
1417
+ id: b.id,
1418
+ title: b.title,
1419
+ releaseDate: b.releaseDate,
1420
+ pageCount: b.pageCount,
1421
+ monitored: b.monitored,
1422
+ hasFile: b.statistics ? b.statistics.bookFileCount > 0 : false,
1423
+ sizeOnDisk: formatBytes(b.statistics?.sizeOnDisk || 0),
1424
+ grabbed: b.grabbed,
1425
+ })),
1426
+ }, null, 2),
1427
+ }],
1428
+ };
1429
+ }
1430
+ case "readarr_search_book": {
1431
+ if (!clients.readarr)
1432
+ throw new Error("Readarr not configured");
1433
+ const bookIds = args.bookIds;
1434
+ const result = await clients.readarr.searchBook(bookIds);
1435
+ return {
1436
+ content: [{
1437
+ type: "text",
1438
+ text: JSON.stringify({
1439
+ success: true,
1440
+ message: `Search triggered for ${bookIds.length} book(s)`,
1441
+ commandId: result.id,
1442
+ }, null, 2),
1443
+ }],
1444
+ };
1445
+ }
1446
+ case "readarr_search_missing": {
1447
+ if (!clients.readarr)
1448
+ throw new Error("Readarr not configured");
1449
+ const authorId = args.authorId;
1450
+ const result = await clients.readarr.searchMissingBooks(authorId);
1451
+ return {
1452
+ content: [{
1453
+ type: "text",
1454
+ text: JSON.stringify({
1455
+ success: true,
1456
+ message: `Search triggered for missing books`,
1457
+ commandId: result.id,
1458
+ }, null, 2),
1459
+ }],
1460
+ };
1461
+ }
1462
+ case "readarr_get_calendar": {
1463
+ if (!clients.readarr)
1464
+ throw new Error("Readarr not configured");
1465
+ const days = args?.days || 30;
1466
+ const start = new Date().toISOString().split('T')[0];
1467
+ const end = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
1468
+ const calendar = await clients.readarr.getCalendar(start, end);
1469
+ return {
1470
+ content: [{
1471
+ type: "text",
1472
+ text: JSON.stringify({
1473
+ count: calendar.length,
1474
+ books: calendar.map(b => ({
1475
+ id: b.id,
1476
+ title: b.title,
1477
+ authorId: b.authorId,
1478
+ releaseDate: b.releaseDate,
1479
+ monitored: b.monitored,
1480
+ })),
1481
+ }, null, 2),
1482
+ }],
1483
+ };
1484
+ }
1485
+ // Prowlarr handlers
1486
+ case "prowlarr_get_indexers": {
1487
+ if (!clients.prowlarr)
1488
+ throw new Error("Prowlarr not configured");
1489
+ const indexers = await clients.prowlarr.getIndexers();
1490
+ return {
1491
+ content: [{
1492
+ type: "text",
1493
+ text: JSON.stringify({
1494
+ count: indexers.length,
1495
+ indexers: indexers.map(i => ({
1496
+ id: i.id,
1497
+ name: i.name,
1498
+ protocol: i.protocol,
1499
+ enableRss: i.enableRss,
1500
+ enableAutomaticSearch: i.enableAutomaticSearch,
1501
+ enableInteractiveSearch: i.enableInteractiveSearch,
1502
+ priority: i.priority,
1503
+ })),
1504
+ }, null, 2),
1505
+ }],
1506
+ };
1507
+ }
1508
+ case "prowlarr_search": {
1509
+ if (!clients.prowlarr)
1510
+ throw new Error("Prowlarr not configured");
1511
+ const query = args.query;
1512
+ const results = await clients.prowlarr.search(query);
1513
+ return {
1514
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
1515
+ };
1516
+ }
1517
+ case "prowlarr_test_indexers": {
1518
+ if (!clients.prowlarr)
1519
+ throw new Error("Prowlarr not configured");
1520
+ const results = await clients.prowlarr.testAllIndexers();
1521
+ const indexers = await clients.prowlarr.getIndexers();
1522
+ const indexerMap = new Map(indexers.map(i => [i.id, i.name]));
1523
+ return {
1524
+ content: [{
1525
+ type: "text",
1526
+ text: JSON.stringify({
1527
+ count: results.length,
1528
+ indexers: results.map(r => ({
1529
+ id: r.id,
1530
+ name: indexerMap.get(r.id) || 'Unknown',
1531
+ isValid: r.isValid,
1532
+ errors: r.validationFailures.map(f => f.errorMessage),
1533
+ })),
1534
+ healthy: results.filter(r => r.isValid).length,
1535
+ failed: results.filter(r => !r.isValid).length,
1536
+ }, null, 2),
1537
+ }],
1538
+ };
1539
+ }
1540
+ case "prowlarr_get_stats": {
1541
+ if (!clients.prowlarr)
1542
+ throw new Error("Prowlarr not configured");
1543
+ const stats = await clients.prowlarr.getIndexerStats();
1544
+ return {
1545
+ content: [{
1546
+ type: "text",
1547
+ text: JSON.stringify({
1548
+ count: stats.indexers.length,
1549
+ indexers: stats.indexers.map(s => ({
1550
+ name: s.indexerName,
1551
+ queries: s.numberOfQueries,
1552
+ grabs: s.numberOfGrabs,
1553
+ failedQueries: s.numberOfFailedQueries,
1554
+ failedGrabs: s.numberOfFailedGrabs,
1555
+ avgResponseTime: s.averageResponseTime + 'ms',
1556
+ })),
1557
+ totals: {
1558
+ queries: stats.indexers.reduce((sum, s) => sum + s.numberOfQueries, 0),
1559
+ grabs: stats.indexers.reduce((sum, s) => sum + s.numberOfGrabs, 0),
1560
+ failedQueries: stats.indexers.reduce((sum, s) => sum + s.numberOfFailedQueries, 0),
1561
+ failedGrabs: stats.indexers.reduce((sum, s) => sum + s.numberOfFailedGrabs, 0),
1562
+ },
1563
+ }, null, 2),
1564
+ }],
1565
+ };
1566
+ }
1567
+ // Cross-service search
1568
+ case "arr_search_all": {
1569
+ const term = args.term;
1570
+ const results = {};
1571
+ if (clients.sonarr) {
1572
+ try {
1573
+ const sonarrResults = await clients.sonarr.searchSeries(term);
1574
+ results.sonarr = { count: sonarrResults.length, results: sonarrResults.slice(0, 5) };
1575
+ }
1576
+ catch (e) {
1577
+ results.sonarr = { error: e instanceof Error ? e.message : String(e) };
1578
+ }
1579
+ }
1580
+ if (clients.radarr) {
1581
+ try {
1582
+ const radarrResults = await clients.radarr.searchMovies(term);
1583
+ results.radarr = { count: radarrResults.length, results: radarrResults.slice(0, 5) };
1584
+ }
1585
+ catch (e) {
1586
+ results.radarr = { error: e instanceof Error ? e.message : String(e) };
1587
+ }
1588
+ }
1589
+ if (clients.lidarr) {
1590
+ try {
1591
+ const lidarrResults = await clients.lidarr.searchArtists(term);
1592
+ results.lidarr = { count: lidarrResults.length, results: lidarrResults.slice(0, 5) };
1593
+ }
1594
+ catch (e) {
1595
+ results.lidarr = { error: e instanceof Error ? e.message : String(e) };
1596
+ }
1597
+ }
1598
+ if (clients.readarr) {
1599
+ try {
1600
+ const readarrResults = await clients.readarr.searchAuthors(term);
1601
+ results.readarr = { count: readarrResults.length, results: readarrResults.slice(0, 5) };
1602
+ }
1603
+ catch (e) {
1604
+ results.readarr = { error: e instanceof Error ? e.message : String(e) };
1605
+ }
1606
+ }
1607
+ return {
1608
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
1609
+ };
1610
+ }
1611
+ // TRaSH Guides handlers
1612
+ case "trash_list_profiles": {
1613
+ const service = args.service;
1614
+ const profiles = await trashClient.listProfiles(service);
1615
+ return {
1616
+ content: [{
1617
+ type: "text",
1618
+ text: JSON.stringify({
1619
+ service,
1620
+ count: profiles.length,
1621
+ profiles: profiles.map(p => ({
1622
+ name: p.name,
1623
+ description: p.description?.replace(/<br>/g, ' ') || 'No description',
1624
+ })),
1625
+ usage: "Use trash_get_profile to see full details for a specific profile",
1626
+ }, null, 2),
1627
+ }],
1628
+ };
1629
+ }
1630
+ case "trash_get_profile": {
1631
+ const { service, profile: profileName } = args;
1632
+ const profile = await trashClient.getProfile(service, profileName);
1633
+ if (!profile) {
1634
+ return {
1635
+ content: [{
1636
+ type: "text",
1637
+ text: JSON.stringify({
1638
+ error: `Profile '${profileName}' not found for ${service}`,
1639
+ hint: "Use trash_list_profiles to see available profiles",
1640
+ }, null, 2),
1641
+ }],
1642
+ isError: true,
1643
+ };
1644
+ }
1645
+ return {
1646
+ content: [{
1647
+ type: "text",
1648
+ text: JSON.stringify({
1649
+ name: profile.name,
1650
+ description: profile.trash_description?.replace(/<br>/g, '\n'),
1651
+ trash_id: profile.trash_id,
1652
+ upgradeAllowed: profile.upgradeAllowed,
1653
+ cutoff: profile.cutoff,
1654
+ minFormatScore: profile.minFormatScore,
1655
+ cutoffFormatScore: profile.cutoffFormatScore,
1656
+ language: profile.language,
1657
+ qualities: profile.items.map(i => ({
1658
+ name: i.name,
1659
+ allowed: i.allowed,
1660
+ items: i.items,
1661
+ })),
1662
+ customFormats: Object.entries(profile.formatItems || {}).map(([name, trashId]) => ({
1663
+ name,
1664
+ trash_id: trashId,
1665
+ })),
1666
+ }, null, 2),
1667
+ }],
1668
+ };
1669
+ }
1670
+ case "trash_list_custom_formats": {
1671
+ const { service, category } = args;
1672
+ const formats = await trashClient.listCustomFormats(service, category);
1673
+ return {
1674
+ content: [{
1675
+ type: "text",
1676
+ text: JSON.stringify({
1677
+ service,
1678
+ category: category || 'all',
1679
+ count: formats.length,
1680
+ formats: formats.slice(0, 50).map(f => ({
1681
+ name: f.name,
1682
+ categories: f.categories,
1683
+ defaultScore: f.defaultScore,
1684
+ })),
1685
+ note: formats.length > 50 ? `Showing first 50 of ${formats.length}. Use category filter to narrow results.` : undefined,
1686
+ availableCategories: ['hdr', 'audio', 'resolution', 'source', 'streaming', 'anime', 'unwanted', 'release', 'language'],
1687
+ }, null, 2),
1688
+ }],
1689
+ };
1690
+ }
1691
+ case "trash_get_naming": {
1692
+ const { service, mediaServer } = args;
1693
+ const naming = await trashClient.getNaming(service);
1694
+ if (!naming) {
1695
+ return {
1696
+ content: [{
1697
+ type: "text",
1698
+ text: JSON.stringify({ error: `Could not fetch naming conventions for ${service}` }, null, 2),
1699
+ }],
1700
+ isError: true,
1701
+ };
1702
+ }
1703
+ // Map media server to naming key
1704
+ const serverMap = {
1705
+ plex: { folder: 'plex-imdb', file: 'plex-imdb' },
1706
+ emby: { folder: 'emby-imdb', file: 'emby-imdb' },
1707
+ jellyfin: { folder: 'jellyfin-imdb', file: 'jellyfin-imdb' },
1708
+ standard: { folder: 'default', file: 'standard' },
1709
+ };
1710
+ const keys = serverMap[mediaServer] || serverMap.standard;
1711
+ return {
1712
+ content: [{
1713
+ type: "text",
1714
+ text: JSON.stringify({
1715
+ service,
1716
+ mediaServer,
1717
+ recommended: {
1718
+ folder: naming.folder[keys.folder] || naming.folder.default,
1719
+ file: naming.file[keys.file] || naming.file.standard,
1720
+ ...(naming.season && { season: naming.season[keys.folder] || naming.season.default }),
1721
+ ...(naming.series && { series: naming.series[keys.folder] || naming.series.default }),
1722
+ },
1723
+ allFolderOptions: Object.keys(naming.folder),
1724
+ allFileOptions: Object.keys(naming.file),
1725
+ }, null, 2),
1726
+ }],
1727
+ };
1728
+ }
1729
+ case "trash_get_quality_sizes": {
1730
+ const { service, type } = args;
1731
+ const sizes = await trashClient.getQualitySizes(service, type);
1732
+ return {
1733
+ content: [{
1734
+ type: "text",
1735
+ text: JSON.stringify({
1736
+ service,
1737
+ type: type || 'all',
1738
+ profiles: sizes.map(s => ({
1739
+ type: s.type,
1740
+ qualities: s.qualities.map(q => ({
1741
+ quality: q.quality,
1742
+ min: q.min + ' MB/min',
1743
+ preferred: q.preferred === 1999 ? 'unlimited' : q.preferred + ' MB/min',
1744
+ max: q.max === 2000 ? 'unlimited' : q.max + ' MB/min',
1745
+ })),
1746
+ })),
1747
+ }, null, 2),
1748
+ }],
1749
+ };
1750
+ }
1751
+ case "trash_compare_profile": {
1752
+ const { service, profileId, trashProfile } = args;
1753
+ // Get client
1754
+ const client = service === 'radarr' ? clients.radarr : clients.sonarr;
1755
+ if (!client) {
1756
+ return {
1757
+ content: [{
1758
+ type: "text",
1759
+ text: JSON.stringify({ error: `${service} not configured. Cannot compare profiles.` }, null, 2),
1760
+ }],
1761
+ isError: true,
1762
+ };
1763
+ }
1764
+ // Fetch both profiles
1765
+ const [userProfiles, trashProfileData] = await Promise.all([
1766
+ client.getQualityProfiles(),
1767
+ trashClient.getProfile(service, trashProfile),
1768
+ ]);
1769
+ const userProfile = userProfiles.find(p => p.id === profileId);
1770
+ if (!userProfile) {
1771
+ return {
1772
+ content: [{
1773
+ type: "text",
1774
+ text: JSON.stringify({
1775
+ error: `Profile ID ${profileId} not found`,
1776
+ availableProfiles: userProfiles.map(p => ({ id: p.id, name: p.name })),
1777
+ }, null, 2),
1778
+ }],
1779
+ isError: true,
1780
+ };
1781
+ }
1782
+ if (!trashProfileData) {
1783
+ return {
1784
+ content: [{
1785
+ type: "text",
1786
+ text: JSON.stringify({
1787
+ error: `TRaSH profile '${trashProfile}' not found`,
1788
+ hint: "Use trash_list_profiles to see available profiles",
1789
+ }, null, 2),
1790
+ }],
1791
+ isError: true,
1792
+ };
1793
+ }
1794
+ // Compare qualities
1795
+ const userQualities = new Set(userProfile.items
1796
+ .filter(i => i.allowed)
1797
+ .map(i => i.quality?.name || i.name)
1798
+ .filter((n) => n !== undefined));
1799
+ const trashQualities = new Set(trashProfileData.items
1800
+ .filter(i => i.allowed)
1801
+ .map(i => i.name));
1802
+ const qualityComparison = {
1803
+ matching: [...userQualities].filter(q => trashQualities.has(q)),
1804
+ missingFromYours: [...trashQualities].filter(q => !userQualities.has(q)),
1805
+ extraInYours: [...userQualities].filter(q => !trashQualities.has(q)),
1806
+ };
1807
+ // Compare custom formats
1808
+ const userCFNames = new Set((userProfile.formatItems || [])
1809
+ .filter(f => f.score !== 0)
1810
+ .map(f => f.name));
1811
+ const trashCFNames = new Set(Object.keys(trashProfileData.formatItems || {}));
1812
+ const cfComparison = {
1813
+ matching: [...userCFNames].filter(cf => trashCFNames.has(cf)),
1814
+ missingFromYours: [...trashCFNames].filter(cf => !userCFNames.has(cf)),
1815
+ extraInYours: [...userCFNames].filter(cf => !trashCFNames.has(cf)),
1816
+ };
1817
+ return {
1818
+ content: [{
1819
+ type: "text",
1820
+ text: JSON.stringify({
1821
+ yourProfile: {
1822
+ name: userProfile.name,
1823
+ id: userProfile.id,
1824
+ upgradeAllowed: userProfile.upgradeAllowed,
1825
+ cutoff: userProfile.cutoff,
1826
+ },
1827
+ trashProfile: {
1828
+ name: trashProfileData.name,
1829
+ upgradeAllowed: trashProfileData.upgradeAllowed,
1830
+ cutoff: trashProfileData.cutoff,
1831
+ },
1832
+ qualityComparison,
1833
+ customFormatComparison: cfComparison,
1834
+ recommendations: [
1835
+ ...(qualityComparison.missingFromYours.length > 0
1836
+ ? [`Enable these qualities: ${qualityComparison.missingFromYours.join(', ')}`]
1837
+ : []),
1838
+ ...(cfComparison.missingFromYours.length > 0
1839
+ ? [`Add these custom formats: ${cfComparison.missingFromYours.slice(0, 5).join(', ')}${cfComparison.missingFromYours.length > 5 ? ` and ${cfComparison.missingFromYours.length - 5} more` : ''}`]
1840
+ : []),
1841
+ ...(userProfile.upgradeAllowed !== trashProfileData.upgradeAllowed
1842
+ ? [`Set upgradeAllowed to ${trashProfileData.upgradeAllowed}`]
1843
+ : []),
1844
+ ],
1845
+ }, null, 2),
1846
+ }],
1847
+ };
1848
+ }
1849
+ case "trash_compare_naming": {
1850
+ const { service, mediaServer } = args;
1851
+ // Get client
1852
+ const client = service === 'radarr' ? clients.radarr : clients.sonarr;
1853
+ if (!client) {
1854
+ return {
1855
+ content: [{
1856
+ type: "text",
1857
+ text: JSON.stringify({ error: `${service} not configured. Cannot compare naming.` }, null, 2),
1858
+ }],
1859
+ isError: true,
1860
+ };
1861
+ }
1862
+ // Fetch both
1863
+ const [userNaming, trashNaming] = await Promise.all([
1864
+ client.getNamingConfig(),
1865
+ trashClient.getNaming(service),
1866
+ ]);
1867
+ if (!trashNaming) {
1868
+ return {
1869
+ content: [{
1870
+ type: "text",
1871
+ text: JSON.stringify({ error: `Could not fetch TRaSH naming for ${service}` }, null, 2),
1872
+ }],
1873
+ isError: true,
1874
+ };
1875
+ }
1876
+ // Map media server to naming key
1877
+ const serverMap = {
1878
+ plex: { folder: 'plex-imdb', file: 'plex-imdb' },
1879
+ emby: { folder: 'emby-imdb', file: 'emby-imdb' },
1880
+ jellyfin: { folder: 'jellyfin-imdb', file: 'jellyfin-imdb' },
1881
+ standard: { folder: 'default', file: 'standard' },
1882
+ };
1883
+ const keys = serverMap[mediaServer] || serverMap.standard;
1884
+ const recommendedFolder = trashNaming.folder[keys.folder] || trashNaming.folder.default;
1885
+ const recommendedFile = trashNaming.file[keys.file] || trashNaming.file.standard;
1886
+ // Extract user's current naming (field names vary by service)
1887
+ const namingRecord = userNaming;
1888
+ const userFolder = namingRecord.movieFolderFormat ||
1889
+ namingRecord.seriesFolderFormat ||
1890
+ namingRecord.standardMovieFormat;
1891
+ const userFile = namingRecord.standardMovieFormat ||
1892
+ namingRecord.standardEpisodeFormat;
1893
+ return {
1894
+ content: [{
1895
+ type: "text",
1896
+ text: JSON.stringify({
1897
+ mediaServer,
1898
+ yourNaming: {
1899
+ folder: userFolder,
1900
+ file: userFile,
1901
+ },
1902
+ trashRecommended: {
1903
+ folder: recommendedFolder,
1904
+ file: recommendedFile,
1905
+ },
1906
+ folderMatch: userFolder === recommendedFolder,
1907
+ fileMatch: userFile === recommendedFile,
1908
+ recommendations: [
1909
+ ...(userFolder !== recommendedFolder ? [`Update folder format to: ${recommendedFolder}`] : []),
1910
+ ...(userFile !== recommendedFile ? [`Update file format to: ${recommendedFile}`] : []),
1911
+ ],
1912
+ }, null, 2),
1913
+ }],
1914
+ };
1915
+ }
1916
+ default:
1917
+ throw new Error(`Unknown tool: ${name}`);
1918
+ }
1919
+ }
1920
+ catch (error) {
1921
+ const errorMessage = error instanceof Error ? error.message : String(error);
1922
+ return {
1923
+ content: [{ type: "text", text: `Error: ${errorMessage}` }],
1924
+ isError: true,
1925
+ };
1926
+ }
1927
+ });
1928
+ // Helper function to format bytes
1929
+ function formatBytes(bytes) {
1930
+ if (bytes === 0)
1931
+ return '0 B';
1932
+ const k = 1024;
1933
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1934
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1935
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1936
+ }
1937
+ // Start the server
1938
+ async function main() {
1939
+ const transport = new StdioServerTransport();
1940
+ await server.connect(transport);
1941
+ console.error(`*arr MCP server running - configured services: ${configuredServices.map(s => s.name).join(', ')}`);
1942
+ }
1943
+ main().catch((error) => {
1944
+ console.error("Fatal error:", error);
1945
+ process.exit(1);
1946
+ });
1947
+ //# sourceMappingURL=index.js.map