From fa913d15619a4a444aaa42bfa2cd06bc79b50e3e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 12 Feb 2026 15:45:08 +0100 Subject: [PATCH] feat: expand API coverage with new tools, params, and 90% test threshold --- src/api-client.ts | 174 ++++++++-- src/config.ts | 14 +- src/formatters/content.ts | 48 ++- src/index.ts | 8 +- src/tools/autocomplete.ts | 60 ++++ src/tools/get-popular.ts | 15 +- src/tools/get-recent.ts | 15 +- src/tools/scan-request.ts | 77 +++++ src/tools/search-content.ts | 69 +++- src/tools/track-interaction.ts | 44 +++ src/types.ts | 63 ++++ tests/api-client.test.ts | 124 ++++++- tests/config.test.ts | 32 +- tests/formatters/content.test.ts | 462 ++++++++++++++++++++++++++ tests/tools/autocomplete.test.ts | 115 +++++++ tests/tools/get-popular.test.ts | 6 +- tests/tools/get-recent.test.ts | 6 +- tests/tools/scan-request.test.ts | 190 +++++++++++ tests/tools/search-content.test.ts | 19 +- tests/tools/track-interaction.test.ts | 112 +++++++ vitest.config.ts | 8 +- 21 files changed, 1573 insertions(+), 88 deletions(-) create mode 100644 src/tools/autocomplete.ts create mode 100644 src/tools/scan-request.ts create mode 100644 src/tools/track-interaction.ts create mode 100644 tests/tools/autocomplete.test.ts create mode 100644 tests/tools/scan-request.test.ts create mode 100644 tests/tools/track-interaction.test.ts diff --git a/src/api-client.ts b/src/api-client.ts index cc26462..f8e8d4e 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -6,6 +6,9 @@ import type { WatchProvidersResponse, CreditsResponse, StatsResponse, + AutocompleteResponse, + TrackResponse, + ScanRequestResponse, } from "./types.js"; export class ApiError extends Error { @@ -15,6 +18,8 @@ export class ApiError extends Error { ) { const messages: Record = { 400: "Bad request — check that all parameters are valid.", + 401: "API key required or invalid. Set TORRENTCLAW_API_KEY environment variable.", + 403: "Insufficient API tier or endpoint not allowed for this key.", 404: "Not found — the requested content ID does not exist. Use search_content to find valid IDs.", 429: "Rate limit exceeded. Wait 10-30 seconds before retrying.", 500: "TorrentClaw server error. Try again in a moment.", @@ -32,13 +37,16 @@ interface CacheEntry { } const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const DEFAULT_CACHE_MAX_SIZE = 200; export class ResponseCache { private store = new Map>(); private ttl: number; + private maxSize: number; - constructor(ttl = DEFAULT_CACHE_TTL) { + constructor(ttl = DEFAULT_CACHE_TTL, maxSize = DEFAULT_CACHE_MAX_SIZE) { this.ttl = ttl; + this.maxSize = maxSize; } get(key: string): T | undefined { @@ -48,10 +56,21 @@ export class ResponseCache { this.store.delete(key); return undefined; } + // Move to end for LRU ordering (Map preserves insertion order) + this.store.delete(key); + this.store.set(key, entry); return entry.data as T; } set(key: string, data: T): void { + // Delete first to refresh position if key exists + this.store.delete(key); + // Evict oldest entries if at capacity + while (this.store.size >= this.maxSize) { + const oldest = this.store.keys().next().value; + if (oldest !== undefined) this.store.delete(oldest); + else break; + } this.store.set(key, { data, expiresAt: Date.now() + this.ttl }); } @@ -73,6 +92,12 @@ export interface SearchParams { min_rating?: number; quality?: string; language?: string; + audio?: string; + hdr?: string; + availability?: string; + locale?: string; + season?: number; + episode?: number; sort?: string; page?: number; limit?: number; @@ -82,14 +107,59 @@ export interface SearchParams { export class TorrentClawClient { private baseUrl: string; private userAgent: string; + private apiKey: string | undefined; readonly cache: ResponseCache; constructor(cacheTtl?: number) { this.baseUrl = config.apiUrl; this.userAgent = `torrentclaw-mcp/${config.version}`; + this.apiKey = config.apiKey; this.cache = new ResponseCache(cacheTtl); } + private buildHeaders(): Record { + const headers: Record = { + "User-Agent": this.userAgent, + Accept: "application/json", + "X-Search-Source": "mcp", + }; + if (this.apiKey) { + headers["Authorization"] = `Bearer ${this.apiKey}`; + } + return headers; + } + + private async handleErrorResponse(response: Response): Promise { + let body = ""; + if (response.status >= 400 && response.status < 500) { + try { + body = (await response.text()).slice(0, 200); + } catch {} + } + throw new ApiError(response.status, body); + } + + private async fetchWithRetry( + url: string, + init: RequestInit, + ): Promise { + const MAX_RETRIES = 2; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 429 && attempt < MAX_RETRIES) { + const delay = Math.min(1000 * 2 ** attempt, 10_000); + await new Promise((r) => setTimeout(r, delay)); + continue; + } + return response; + } + // Unreachable, but TypeScript needs it + throw new ApiError(429, ""); + } + private async request( path: string, params?: Record, @@ -107,24 +177,12 @@ export class TorrentClawClient { const cached = this.cache.get(cacheKey); if (cached !== undefined) return cached; - const response = await fetch(url.toString(), { - headers: { - "User-Agent": this.userAgent, - Accept: "application/json", - "X-Search-Source": "mcp", - }, - signal: AbortSignal.timeout(15_000), + const response = await this.fetchWithRetry(url.toString(), { + headers: this.buildHeaders(), }); if (!response.ok) { - // Only expose body for 4xx (client errors); omit for 5xx (may leak internals) - let body = ""; - if (response.status >= 400 && response.status < 500) { - try { - body = (await response.text()).slice(0, 200); - } catch {} - } - throw new ApiError(response.status, body); + await this.handleErrorResponse(response); } const data = (await response.json()) as T; @@ -132,6 +190,27 @@ export class TorrentClawClient { return data; } + private async postRequest( + path: string, + body: Record, + ): Promise { + const url = new URL(path, this.baseUrl); + const headers = this.buildHeaders(); + headers["Content-Type"] = "application/json"; + + const response = await this.fetchWithRetry(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + await this.handleErrorResponse(response); + } + + return (await response.json()) as T; + } + async search(params: SearchParams): Promise { return this.request("/api/v1/search", { q: params.query, @@ -142,6 +221,12 @@ export class TorrentClawClient { min_rating: params.min_rating, quality: params.quality, lang: params.language, + audio: params.audio, + hdr: params.hdr, + availability: params.availability, + locale: params.locale, + season: params.season, + episode: params.episode, sort: params.sort, page: params.page, limit: params.limit, @@ -149,12 +234,34 @@ export class TorrentClawClient { }); } - async getPopular(limit?: number, page?: number): Promise { - return this.request("/api/v1/popular", { limit, page }); + async autocomplete(query: string): Promise { + return this.request("/api/v1/autocomplete", { + q: query, + }); } - async getRecent(limit?: number, page?: number): Promise { - return this.request("/api/v1/recent", { limit, page }); + async getPopular( + limit?: number, + page?: number, + locale?: string, + ): Promise { + return this.request("/api/v1/popular", { + limit, + page, + locale, + }); + } + + async getRecent( + limit?: number, + page?: number, + locale?: string, + ): Promise { + return this.request("/api/v1/recent", { + limit, + page, + locale, + }); } async getWatchProviders( @@ -177,6 +284,33 @@ export class TorrentClawClient { return this.request("/api/v1/stats"); } + async track( + infoHash: string, + action: "magnet" | "torrent_download" | "copy", + ): Promise { + return this.postRequest("/api/v1/track", { + infoHash, + action, + }); + } + + async submitScanRequest( + infoHash: string, + email: string, + ): Promise { + return this.postRequest("/api/v1/scan-request", { + infoHash, + email, + website: "", + }); + } + + async getScanStatus(infoHash: string): Promise { + return this.request( + `/api/v1/scan-request/${infoHash}`, + ); + } + getTorrentDownloadUrl(infoHash: string): string { return `${this.baseUrl}/api/v1/torrent/${infoHash}`; } diff --git a/src/config.ts b/src/config.ts index 4a615d9..62b25e1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,14 +24,11 @@ export function validateApiUrl(raw: string): string { ); } - const allowPrivate = process.env.TORRENTCLAW_ALLOW_PRIVATE === "true"; - if (!allowPrivate) { - const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); - if (PRIVATE_IP_PATTERNS.some((re) => re.test(hostname))) { - throw new Error( - `Invalid TORRENTCLAW_API_URL: private/reserved addresses not allowed. Set TORRENTCLAW_ALLOW_PRIVATE=true for self-hosted setups.`, - ); - } + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + if (PRIVATE_IP_PATTERNS.some((re) => re.test(hostname))) { + throw new Error( + `Invalid TORRENTCLAW_API_URL: private/reserved addresses not allowed`, + ); } return raw; @@ -41,5 +38,6 @@ export const config = { apiUrl: validateApiUrl( process.env.TORRENTCLAW_API_URL || "https://torrentclaw.com", ), + apiKey: process.env.TORRENTCLAW_API_KEY || undefined, version: process.env.npm_package_version || "1.0.0", } as const; diff --git a/src/formatters/content.ts b/src/formatters/content.ts index 177b349..2339259 100644 --- a/src/formatters/content.ts +++ b/src/formatters/content.ts @@ -42,13 +42,42 @@ function formatTorrent(t: TorrentInfo, compact?: boolean): string { let line = ` - ${label} (${size}) | ${seeds}`; if (score) line += ` | ${score}`; + + if (t.season != null) { + const ep = + t.episode != null ? `E${String(t.episode).padStart(2, "0")}` : ""; + line += ` | S${String(t.season).padStart(2, "0")}${ep}`; + } + line += `\n Info hash: ${t.infoHash}`; if (compact) { - // Short magnet (hash only, no trackers) — still clickable, saves ~200 chars per torrent line += `\n Magnet: magnet:?xt=urn:btih:${t.infoHash}`; } else if (t.magnetUrl) { line += `\n Magnet: ${t.magnetUrl}`; } + if (t.torrentUrl) { + line += `\n Torrent: ${t.torrentUrl}`; + } + + // Audio/subtitle track summary (from scanned torrents) + if (t.audioTracks && t.audioTracks.length > 0) { + const langs = t.audioTracks + .map((a) => a.lang || "?") + .filter((v, i, arr) => arr.indexOf(v) === i); + line += `\n Audio: ${langs.join(", ")}`; + const codecs = t.audioTracks + .map((a) => a.codec) + .filter((v): v is string => v != null) + .filter((v, i, arr) => arr.indexOf(v) === i); + if (codecs.length > 0) line += ` (${codecs.join(", ")})`; + } + if (t.subtitleTracks && t.subtitleTracks.length > 0) { + const langs = t.subtitleTracks + .map((s) => s.lang || "?") + .filter((v, i, arr) => arr.indexOf(v) === i); + line += `\n Subtitles: ${langs.join(", ")}`; + } + return line; } @@ -99,6 +128,7 @@ function formatResult( ` Content ID: ${r.id} — use with get_watch_providers(content_id=${r.id}) or get_credits(content_id=${r.id})`, ); if (r.imdbId) lines.push(` IMDb: ${r.imdbId}`); + if (r.contentUrl) lines.push(` URL: ${r.contentUrl}`); return lines.join("\n"); } @@ -111,9 +141,21 @@ export function formatSearchResults( return "No results found. Try: (1) a shorter or alternate title, (2) removing filters like quality or year, (3) checking spelling. You can also try get_popular or get_recent to browse available content."; } - const header = `Found ${data.total} results (page ${data.page}, showing ${data.results.length}):`; + const headerParts = [ + `Found ${data.total} results (page ${data.page}, showing ${data.results.length}):`, + ]; + if (data.parsedSeason != null) { + const ep = + data.parsedEpisode != null + ? `E${String(data.parsedEpisode).padStart(2, "0")}` + : ""; + headerParts.push( + `Detected season/episode: S${String(data.parsedSeason).padStart(2, "0")}${ep}`, + ); + } + const results = data.results.map((r, i) => formatResult(r, i + 1, opts)); - return [header, "", ...results].join("\n"); + return [...headerParts, "", ...results].join("\n"); } function formatPopularItem(item: PopularItem, index: number): string { diff --git a/src/index.ts b/src/index.ts index f3396bb..7353a14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ import { registerGetRecent } from "./tools/get-recent.js"; import { registerGetWatchProviders } from "./tools/get-watch-providers.js"; import { registerGetCredits } from "./tools/get-credits.js"; import { registerGetTorrentUrl } from "./tools/get-torrent-url.js"; +import { registerAutocomplete } from "./tools/autocomplete.js"; +import { registerTrackInteraction } from "./tools/track-interaction.js"; +import { registerScanRequest } from "./tools/scan-request.js"; import { registerStatsResource } from "./resources/stats.js"; import { registerPrompts } from "./prompts.js"; @@ -18,16 +21,19 @@ const server = new McpServer({ name: "torrentclaw", version: config.version, description: - "Search and discover movies and TV shows with torrent downloads, streaming availability, and cast/crew metadata. Start with search_content to find content, then use get_watch_providers or get_credits with the content_id. Use get_popular/get_recent to browse (no torrents — search for a title to get torrents).", + "Search and discover movies and TV shows with torrent downloads, streaming availability, and cast/crew metadata. Start with autocomplete to validate titles, then search_content for full results with torrents. Use get_watch_providers or get_credits with the content_id. Use get_popular/get_recent to browse. Track interactions with track_interaction and request quality scans with submit_scan_request.", }); // Register tools registerSearchContent(server, client); +registerAutocomplete(server, client); registerGetPopular(server, client); registerGetRecent(server, client); registerGetWatchProviders(server, client); registerGetCredits(server, client); registerGetTorrentUrl(server, client); +registerTrackInteraction(server, client); +registerScanRequest(server, client); // Register resources registerStatsResource(server, client); diff --git a/src/tools/autocomplete.ts b/src/tools/autocomplete.ts new file mode 100644 index 0000000..6e1151c --- /dev/null +++ b/src/tools/autocomplete.ts @@ -0,0 +1,60 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; + +export function registerAutocomplete( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "autocomplete", + "Get type-ahead search suggestions for movies and TV shows. Use this to validate or disambiguate a title before calling search_content. Returns up to 8 suggestions with id, title, year, and content type. Much faster than a full search.", + { + query: z + .string() + .min(2) + .max(200) + .refine( + (q) => !/[\x00-\x08\x0B-\x0C\x0E-\x1F]/.test(q), + "Query contains invalid control characters", + ) + .describe( + "Partial title to get suggestions for (min 2 chars). E.g. 'break' → 'Breaking Bad', 'The Break-Up'.", + ), + }, + async (params) => { + try { + const data = await client.autocomplete(params.query); + if (data.suggestions.length === 0) { + return { + content: [ + { + type: "text", + text: `No suggestions for "${params.query}". Try search_content for a full search.`, + }, + ], + }; + } + const lines = data.suggestions.map((s, i) => { + const yearStr = s.year ? ` (${s.year})` : ""; + return `${i + 1}. ${s.title}${yearStr} [${s.contentType}] — ID: ${s.id}`; + }); + return { + content: [ + { + type: "text", + text: `Suggestions for "${params.query}":\n${lines.join("\n")}`, + }, + ], + }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/tools/get-popular.ts b/src/tools/get-popular.ts index 891d40d..450a1ba 100644 --- a/src/tools/get-popular.ts +++ b/src/tools/get-popular.ts @@ -18,17 +18,28 @@ export function registerGetPopular( .min(1) .max(24) .optional() - .describe("Number of items (default: 10)"), + .describe("Number of items (default: 12)"), page: z .number() .int() .min(1) .optional() .describe("Page number (default: 1)"), + locale: z + .string() + .regex(/^[a-z]{2}$/, "Must be a lowercase 2-letter language code") + .optional() + .describe( + "Locale for translated titles (e.g. 'es' for Spanish, 'fr' for French). If omitted, returns English.", + ), }, async (params) => { try { - const data = await client.getPopular(params.limit ?? 10, params.page); + const data = await client.getPopular( + params.limit ?? 12, + params.page, + params.locale, + ); return { content: [{ type: "text", text: formatPopularResults(data) }], }; diff --git a/src/tools/get-recent.ts b/src/tools/get-recent.ts index e0cba31..522735a 100644 --- a/src/tools/get-recent.ts +++ b/src/tools/get-recent.ts @@ -18,17 +18,28 @@ export function registerGetRecent( .min(1) .max(24) .optional() - .describe("Number of items (default: 10)"), + .describe("Number of items (default: 12)"), page: z .number() .int() .min(1) .optional() .describe("Page number (default: 1)"), + locale: z + .string() + .regex(/^[a-z]{2}$/, "Must be a lowercase 2-letter language code") + .optional() + .describe( + "Locale for translated titles (e.g. 'es' for Spanish, 'fr' for French). If omitted, returns English.", + ), }, async (params) => { try { - const data = await client.getRecent(params.limit ?? 10, params.page); + const data = await client.getRecent( + params.limit ?? 12, + params.page, + params.locale, + ); return { content: [{ type: "text", text: formatRecentResults(data) }], }; diff --git a/src/tools/scan-request.ts b/src/tools/scan-request.ts new file mode 100644 index 0000000..738f36b --- /dev/null +++ b/src/tools/scan-request.ts @@ -0,0 +1,77 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; + +export function registerScanRequest( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "submit_scan_request", + "Submit a torrent for audio/video quality analysis (codec, tracks, resolution, HDR). Use when the user wants to know the exact media specs of a torrent before downloading. Results are not instant — use get_scan_status to check progress. Rate limited to 5 requests per hour.", + { + info_hash: z + .string() + .regex(/^[a-fA-F0-9]{40}$/) + .describe("40-character hex torrent info_hash to scan"), + email: z + .string() + .email() + .max(200) + .describe("Email address for scan completion notification"), + }, + async (params) => { + try { + const data = await client.submitScanRequest( + params.info_hash.toLowerCase(), + params.email, + ); + return { + content: [ + { + type: "text", + text: `Scan request submitted for ${params.info_hash.toLowerCase()}.\nStatus: ${data.status}\nUse get_scan_status(info_hash="${params.info_hash.toLowerCase()}") to check progress.`, + }, + ], + }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); + + server.tool( + "get_scan_status", + "Check the status of a torrent audio/video scan request. Returns the current scan status (pending, scanning, completed, failed). Use after submit_scan_request.", + { + info_hash: z + .string() + .regex(/^[a-fA-F0-9]{40}$/) + .describe("40-character hex torrent info_hash to check"), + }, + async (params) => { + try { + const data = await client.getScanStatus(params.info_hash.toLowerCase()); + const lines = [`Scan status for ${params.info_hash.toLowerCase()}:`]; + lines.push(` Status: ${data.status}`); + if (data.source) lines.push(` Source: ${data.source}`); + if (data.createdAt) lines.push(` Submitted: ${data.createdAt}`); + if (data.completedAt) lines.push(` Completed: ${data.completedAt}`); + return { + content: [{ type: "text", text: lines.join("\n") }], + }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/tools/search-content.ts b/src/tools/search-content.ts index 8c95d57..d1772f2 100644 --- a/src/tools/search-content.ts +++ b/src/tools/search-content.ts @@ -10,7 +10,7 @@ export function registerSearchContent( ): void { server.tool( "search_content", - "Search for movies and TV shows by title, genre, year, rating, or quality. Returns matching content with metadata (title, year, genres, IMDb/TMDB ratings) and torrent download options (magnet links, quality, seeders, file size). This is the primary tool — use it first when a user asks to find, download, or learn about a movie or TV show. Results include a content_id needed by get_watch_providers and get_credits.", + "Search for movies and TV shows by title, genre, year, rating, or quality. Returns matching content with metadata (title, year, genres, IMDb/TMDB ratings) and torrent download options (magnet links, quality, seeders, file size). This is the primary tool — use it first when a user asks to find, download, or learn about a movie or TV show. Results include a content_id needed by get_watch_providers and get_credits. For TV shows, you can filter by season/episode. Season/episode can also be auto-detected from the query (e.g. 'Bluey s01e05').", { query: z .string() @@ -21,7 +21,7 @@ export function registerSearchContent( "Query contains invalid control characters", ) .describe( - "Search query — typically a movie or TV show title (e.g. 'The Matrix', 'Breaking Bad'). Supports partial matches.", + "Search query — typically a movie or TV show title (e.g. 'The Matrix', 'Breaking Bad'). Supports partial matches. Season/episode can be included in query (e.g. 'Bluey s01e05').", ), type: z .enum(["movie", "show"]) @@ -62,10 +62,59 @@ export function registerSearchContent( .describe("Filter torrents by resolution"), language: z .string() + .regex( + /^[a-z]{2}$/, + "Must be a lowercase 2-letter ISO 639-1 language code", + ) .optional() .describe( "ISO 639-1 language code to filter torrents (e.g. 'en' for English, 'es' for Spanish, 'fr' for French). Lowercase 2-letter code.", ), + audio: z + .string() + .regex( + /^[a-zA-Z0-9.]+$/, + "Audio codec must contain only alphanumeric characters and dots", + ) + .optional() + .describe( + "Filter torrents by audio codec (e.g. 'aac', 'flac', 'atmos', 'opus', 'dts'). Substring match.", + ), + hdr: z + .enum(["hdr10", "dolby_vision", "hdr10plus", "hlg"]) + .optional() + .describe("Filter torrents by HDR format"), + availability: z + .enum(["all", "available", "unavailable"]) + .optional() + .describe( + "Filter by torrent availability: 'available' (has seeders), 'unavailable' (no seeders), 'all' (default).", + ), + season: z + .number() + .int() + .min(0) + .max(99) + .optional() + .describe( + "Season number for TV shows (0-99). Use with type='show' to filter torrents for a specific season.", + ), + episode: z + .number() + .int() + .min(0) + .max(999) + .optional() + .describe( + "Episode number for TV shows (0-999). Use with season to filter torrents for a specific episode.", + ), + locale: z + .string() + .regex(/^[a-z]{2}$/, "Must be a lowercase 2-letter language code") + .optional() + .describe( + "Locale for translated titles and overviews (e.g. 'es' for Spanish, 'fr' for French). If omitted, returns English.", + ), sort: z .enum(["relevance", "seeders", "year", "rating", "added"]) .default("relevance") @@ -74,16 +123,16 @@ export function registerSearchContent( .number() .int() .min(1) - .max(100) + .max(1000) .optional() - .describe("Page number (default: 1)"), + .describe("Page number (default: 1, max: 1000)"), limit: z .number() .int() .min(1) - .max(20) + .max(50) .optional() - .describe("Results per page (default: 10, max: 20)"), + .describe("Results per page (default: 20, max: 50)"), country: z .string() .regex( @@ -112,9 +161,15 @@ export function registerSearchContent( min_rating: params.min_rating, quality: params.quality, language: params.language, + audio: params.audio, + hdr: params.hdr, + availability: params.availability, + locale: params.locale, + season: params.season, + episode: params.episode, sort: params.sort, page: params.page, - limit: params.limit ?? 10, + limit: params.limit ?? 20, country: params.country, }); return { diff --git a/src/tools/track-interaction.ts b/src/tools/track-interaction.ts new file mode 100644 index 0000000..8698f54 --- /dev/null +++ b/src/tools/track-interaction.ts @@ -0,0 +1,44 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { TorrentClawClient } from "../api-client.js"; +import { ApiError } from "../api-client.js"; + +export function registerTrackInteraction( + server: McpServer, + client: TorrentClawClient, +): void { + server.tool( + "track_interaction", + "Track a user interaction with a torrent (magnet link click, .torrent download, or hash copy). Use this after presenting a magnet link or torrent URL to the user, to keep popularity stats accurate. Fire-and-forget — does not block.", + { + info_hash: z + .string() + .regex(/^[a-fA-F0-9]{40}$/) + .describe("40-character hex torrent info_hash"), + action: z + .enum(["magnet", "torrent_download", "copy"]) + .describe( + "Type of interaction: 'magnet' (clicked magnet link), 'torrent_download' (downloaded .torrent file), 'copy' (copied info hash or magnet)", + ), + }, + async (params) => { + try { + await client.track(params.info_hash.toLowerCase(), params.action); + return { + content: [ + { + type: "text", + text: `Tracked ${params.action} for ${params.info_hash.toLowerCase()}.`, + }, + ], + }; + } catch (error) { + const message = + error instanceof ApiError + ? `TorrentClaw API error (${error.status}): ${error.message}` + : `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`; + return { content: [{ type: "text", text: message }], isError: true }; + } + }, + ); +} diff --git a/src/types.ts b/src/types.ts index 2001e0f..a6bf18a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,32 @@ // Response types mirrored from TorrentClaw API (src/types/api.ts) +export interface AudioTrack { + lang: string | null; + codec: string | null; + channels: string | null; + title: string | null; + default: boolean | null; +} + +export interface SubtitleTrack { + lang: string | null; + codec: string | null; + title: string | null; + forced: boolean | null; +} + +export interface VideoInfo { + codec: string | null; + width: number | null; + height: number | null; + bitDepth: number | null; + hdr: string | null; + frameRate: string | null; +} + export interface TorrentInfo { infoHash: string; + rawTitle: string | null; quality: string | null; codec: string | null; sourceType: string | null; @@ -9,6 +34,7 @@ export interface TorrentInfo { seeders: number; leechers: number; magnetUrl: string | null; + torrentUrl: string | null; source: string; qualityScore: number | null; uploadedAt: string | null; @@ -19,6 +45,12 @@ export interface TorrentInfo { isProper: boolean | null; isRepack: boolean | null; isRemastered: boolean | null; + season: number | null; + episode: number | null; + audioTracks: AudioTrack[] | null; + subtitleTracks: SubtitleTrack[] | null; + videoInfo: VideoInfo | null; + scanStatus: string | null; } export interface StreamingProviderItem { @@ -49,6 +81,7 @@ export interface SearchResult { genres: string[] | null; ratingImdb: string | null; ratingTmdb: string | null; + contentUrl: string | null; hasTorrents: boolean; torrents: TorrentInfo[]; streaming?: StreamingInfo; @@ -58,9 +91,39 @@ export interface SearchResponse { total: number; page: number; pageSize: number; + parsedSeason?: number; + parsedEpisode?: number; results: SearchResult[]; } +export interface AutocompleteItem { + id: number; + title: string; + year: number | null; + contentType: string; + posterUrl: string | null; +} + +export interface AutocompleteResponse { + suggestions: AutocompleteItem[]; +} + +export interface TrackRequest { + infoHash: string; + action: "magnet" | "torrent_download" | "copy"; +} + +export interface TrackResponse { + ok: boolean; +} + +export interface ScanRequestResponse { + status: string; + source?: string; + createdAt?: string; + completedAt?: string; +} + export interface PopularItem { id: number; title: string; diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts index 038f19d..6f4f115 100644 --- a/tests/api-client.test.ts +++ b/tests/api-client.test.ts @@ -82,7 +82,10 @@ describe("TorrentClawClient", () => { await expect(client.search({ query: "test" })).rejects.toThrow(ApiError); }); - it("throws ApiError with rate limit message on 429", async () => { + it("throws ApiError with rate limit message on 429 after retries", async () => { + // With retry logic (MAX_RETRIES=2), need 3 consecutive 429 responses + mockFetchError("Too many requests", 429); + mockFetchError("Too many requests", 429); mockFetchError("Too many requests", 429); const client = new TorrentClawClient(); @@ -119,6 +122,28 @@ describe("TorrentClawClient", () => { expect(calledUrl).toContain("page=2"); }); + it("includes locale param for popular", async () => { + mockFetch({ items: [], total: 0, page: 1, pageSize: 10 }); + + const client = new TorrentClawClient(); + await client.getPopular(10, 1, "es"); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("locale=es"); + }); + + it("includes locale param for recent", async () => { + mockFetch({ items: [], total: 0, page: 1, pageSize: 10 }); + + const client = new TorrentClawClient(); + await client.getRecent(10, 1, "fr"); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("locale=fr"); + }); + it("calls correct endpoint for watch providers", async () => { mockFetch({ contentId: 42, @@ -216,4 +241,101 @@ describe("TorrentClawClient", () => { expect((e as ApiError).body).toBe(""); } }); + + it("calls correct endpoint for autocomplete", async () => { + mockFetch({ suggestions: [{ id: 1, title: "Test", year: 2024, contentType: "movie", posterUrl: null }] }); + + const client = new TorrentClawClient(); + const result = await client.autocomplete("test"); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/autocomplete"); + expect(calledUrl).toContain("q=test"); + expect(result.suggestions).toHaveLength(1); + }); + + it("calls correct endpoint for track (POST)", async () => { + mockFetch({ ok: true }); + + const client = new TorrentClawClient(); + const result = await client.track("abc123def456abc123def456abc123def456abc1", "magnet"); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(calledUrl).toContain("/api/v1/track"); + expect(options.method).toBe("POST"); + expect(JSON.parse(options.body as string)).toEqual({ + infoHash: "abc123def456abc123def456abc123def456abc1", + action: "magnet", + }); + expect(result.ok).toBe(true); + }); + + it("calls correct endpoint for submitScanRequest (POST)", async () => { + mockFetch({ status: "pending" }); + + const client = new TorrentClawClient(); + const result = await client.submitScanRequest("abc123def456abc123def456abc123def456abc1", "test@example.com"); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(calledUrl).toContain("/api/v1/scan-request"); + expect(options.method).toBe("POST"); + expect(JSON.parse(options.body as string)).toEqual({ + infoHash: "abc123def456abc123def456abc123def456abc1", + email: "test@example.com", + website: "", + }); + expect(result.status).toBe("pending"); + }); + + it("calls correct endpoint for getScanStatus", async () => { + mockFetch({ status: "completed", source: "scan_request" }); + + const client = new TorrentClawClient(); + const result = await client.getScanStatus("abc123def456abc123def456abc123def456abc1"); + + const fetchMock = globalThis.fetch as ReturnType; + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/scan-request/abc123def456abc123def456abc123def456abc1"); + expect(result.status).toBe("completed"); + }); + + it("retries on 429 and succeeds", async () => { + mockFetchError("Too many requests", 429); + mockFetch({ total: 1, page: 1, pageSize: 10, results: [] }); + + const client = new TorrentClawClient(); + const result = await client.search({ query: "test" }); + + expect(result.total).toBe(1); + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("includes Authorization header when apiKey is set", async () => { + mockFetch({ total: 0, page: 1, pageSize: 10, results: [] }); + + const client = new TorrentClawClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (client as any).apiKey = "test-api-key-123"; + await client.search({ query: "test" }); + + const fetchMock = globalThis.fetch as ReturnType; + const options = fetchMock.mock.calls[0][1] as RequestInit; + const headers = options.headers as Record; + expect(headers["Authorization"]).toBe("Bearer test-api-key-123"); + }); + + it("throws ApiError on POST 400 response", async () => { + mockFetchError("Invalid body", 400); + + const client = new TorrentClawClient(); + await expect( + client.track("abc123def456abc123def456abc123def456abc1", "magnet"), + ).rejects.toThrow(ApiError); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 8c9a5c2..adb7a6c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,20 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { validateApiUrl } from "../src/config.js"; describe("validateApiUrl", () => { - const originalEnv = process.env.TORRENTCLAW_ALLOW_PRIVATE; - - beforeEach(() => { - delete process.env.TORRENTCLAW_ALLOW_PRIVATE; - }); - - afterEach(() => { - if (originalEnv !== undefined) { - process.env.TORRENTCLAW_ALLOW_PRIVATE = originalEnv; - } else { - delete process.env.TORRENTCLAW_ALLOW_PRIVATE; - } - }); it("accepts valid https URL", () => { expect(validateApiUrl("https://torrentclaw.com")).toBe( "https://torrentclaw.com", @@ -91,21 +78,4 @@ describe("validateApiUrl", () => { it("rejects IPv6 loopback ::1", () => { expect(() => validateApiUrl("http://[::1]")).toThrow("private/reserved"); }); - - it("allows localhost when TORRENTCLAW_ALLOW_PRIVATE=true", () => { - process.env.TORRENTCLAW_ALLOW_PRIVATE = "true"; - expect(validateApiUrl("http://localhost:3030")).toBe( - "http://localhost:3030", - ); - }); - - it("allows 192.168.x.x when TORRENTCLAW_ALLOW_PRIVATE=true", () => { - process.env.TORRENTCLAW_ALLOW_PRIVATE = "true"; - expect(validateApiUrl("http://192.168.1.1")).toBe("http://192.168.1.1"); - }); - - it("still rejects ftp even when TORRENTCLAW_ALLOW_PRIVATE=true", () => { - process.env.TORRENTCLAW_ALLOW_PRIVATE = "true"; - expect(() => validateApiUrl("ftp://localhost")).toThrow("only http/https"); - }); }); diff --git a/tests/formatters/content.test.ts b/tests/formatters/content.test.ts index 701d7c0..4206386 100644 --- a/tests/formatters/content.test.ts +++ b/tests/formatters/content.test.ts @@ -520,6 +520,468 @@ describe("formatSearchResults", () => { expect(full).not.toContain("Magnet:"); }); + it("shows season and episode in torrent line", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "show", + title: "Breaking Bad", + titleOriginal: null, + year: 2008, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: true, + torrents: [ + { + infoHash: "a".repeat(40), + rawTitle: null, + quality: "1080p", + codec: null, + sourceType: null, + sizeBytes: "1073741824", + seeders: 50, + leechers: 2, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: 70, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: 1, + episode: 5, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("S01E05"); + }); + + it("shows season without episode", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "show", + title: "Some Show", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: true, + torrents: [ + { + infoHash: "b".repeat(40), + rawTitle: null, + quality: "720p", + codec: null, + sourceType: null, + sizeBytes: "500000000", + seeders: 10, + leechers: 1, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: 3, + episode: null, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("S03"); + expect(text).not.toContain("S03E"); + }); + + it("shows torrentUrl when present", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Torrent URL Movie", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: true, + torrents: [ + { + infoHash: "c".repeat(40), + rawTitle: null, + quality: "1080p", + codec: null, + sourceType: null, + sizeBytes: "2000000000", + seeders: 20, + leechers: 1, + magnetUrl: null, + torrentUrl: "https://example.com/torrent/ccc.torrent", + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: null, + episode: null, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("Torrent: https://example.com/torrent/ccc.torrent"); + }); + + it("shows audio tracks with languages and codecs", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Audio Movie", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: true, + torrents: [ + { + infoHash: "d".repeat(40), + rawTitle: null, + quality: "1080p", + codec: null, + sourceType: null, + sizeBytes: "4000000000", + seeders: 30, + leechers: 2, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: null, + episode: null, + audioTracks: [ + { lang: "en", codec: "aac", channels: "5.1", title: "English", default: true }, + { lang: "es", codec: "aac", channels: "5.1", title: "Spanish", default: false }, + { lang: "en", codec: "ac3", channels: "2.0", title: "Commentary", default: false }, + ], + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("Audio: en, es"); + expect(text).toContain("(aac, ac3)"); + }); + + it("shows audio tracks with null lang as ?", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Unknown Lang Movie", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: true, + torrents: [ + { + infoHash: "f".repeat(40), + rawTitle: null, + quality: "720p", + codec: null, + sourceType: null, + sizeBytes: "1000000000", + seeders: 5, + leechers: 0, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: null, + episode: null, + audioTracks: [ + { lang: null, codec: null, channels: null, title: null, default: null }, + ], + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("Audio: ?"); + // No codecs listed after Audio line when all codecs are null + expect(text).not.toMatch(/Audio: \?\s*\(/); + }); + + it("shows subtitle tracks summary", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Sub Movie", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: true, + torrents: [ + { + infoHash: "e".repeat(40), + rawTitle: null, + quality: "1080p", + codec: null, + sourceType: null, + sizeBytes: "3000000000", + seeders: 25, + leechers: 3, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: null, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: null, + episode: null, + audioTracks: null, + subtitleTracks: [ + { lang: "en", codec: "srt", title: "English", forced: false }, + { lang: "es", codec: "srt", title: "Spanish", forced: false }, + { lang: "fr", codec: "ass", title: "French", forced: false }, + ], + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("Subtitles: en, es, fr"); + }); + + it("shows contentUrl when present", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 99, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "URL Movie", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: "https://torrentclaw.com/content/99", + hasTorrents: false, + torrents: [], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("URL: https://torrentclaw.com/content/99"); + }); + + it("shows parsedSeason and parsedEpisode in header", () => { + const response: SearchResponse = { + total: 5, + page: 1, + pageSize: 10, + parsedSeason: 2, + parsedEpisode: 7, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "show", + title: "Test Show", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: false, + torrents: [], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("Detected season/episode: S02E07"); + }); + + it("shows parsedSeason only in header (no episode)", () => { + const response: SearchResponse = { + total: 3, + page: 1, + pageSize: 10, + parsedSeason: 4, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "show", + title: "Another Show", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + contentUrl: null, + hasTorrents: false, + torrents: [], + }, + ], + }; + const text = formatSearchResults(response); + expect(text).toContain("Detected season/episode: S04"); + expect(text).not.toContain("S04E"); + }); + it("shows streaming info when available", () => { const response: SearchResponse = { total: 1, diff --git a/tests/tools/autocomplete.test.ts b/tests/tools/autocomplete.test.ts new file mode 100644 index 0000000..5c24ac5 --- /dev/null +++ b/tests/tools/autocomplete.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerAutocomplete } from "../../src/tools/autocomplete.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + autocomplete: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + track: vi.fn(), + submitScanRequest: vi.fn(), + getScanStatus: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("autocomplete tool", () => { + it("returns formatted suggestions", async () => { + const client = createMockClient({ + autocomplete: vi.fn().mockResolvedValue({ + suggestions: [ + { + id: 1, + title: "Breaking Bad", + year: 2008, + contentType: "show", + posterUrl: null, + }, + { + id: 2, + title: "The Break-Up", + year: 2006, + contentType: "movie", + posterUrl: null, + }, + ], + }), + }); + const { server, getToolHandler } = createMockServer(); + registerAutocomplete(server, client); + + const handler = getToolHandler("autocomplete"); + const result = await handler({ query: "break" }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("Breaking Bad"); + expect(result.content[0].text).toContain("The Break-Up"); + expect(result.content[0].text).toContain("[show]"); + expect(result.content[0].text).toContain("[movie]"); + }); + + it("returns message when no suggestions found", async () => { + const client = createMockClient({ + autocomplete: vi.fn().mockResolvedValue({ suggestions: [] }), + }); + const { server, getToolHandler } = createMockServer(); + registerAutocomplete(server, client); + + const handler = getToolHandler("autocomplete"); + const result = await handler({ query: "zzzzz" }); + + expect(result.content[0].text).toContain("No suggestions"); + expect(result.content[0].text).toContain("search_content"); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + autocomplete: vi + .fn() + .mockRejectedValue(new ApiError(429, "Rate limited")), + }); + const { server, getToolHandler } = createMockServer(); + registerAutocomplete(server, client); + + const handler = getToolHandler("autocomplete"); + const result = await handler({ query: "test" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (429)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + autocomplete: vi.fn().mockRejectedValue(new Error("Network timeout")), + }); + const { server, getToolHandler } = createMockServer(); + registerAutocomplete(server, client); + + const handler = getToolHandler("autocomplete"); + const result = await handler({ query: "test" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Request failed: Network timeout"); + }); + + it("handles non-Error throw", async () => { + const client = createMockClient({ + autocomplete: vi.fn().mockRejectedValue("string error"), + }); + const { server, getToolHandler } = createMockServer(); + registerAutocomplete(server, client); + + const handler = getToolHandler("autocomplete"); + const result = await handler({ query: "test" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/tests/tools/get-popular.test.ts b/tests/tools/get-popular.test.ts index b5246c2..01ba99b 100644 --- a/tests/tools/get-popular.test.ts +++ b/tests/tools/get-popular.test.ts @@ -47,12 +47,12 @@ describe("get_popular tool", () => { expect(result.content[0].text).toContain("200 clicks"); }); - it("defaults limit to 10", async () => { + it("defaults limit to 12", async () => { const getPopularMock = vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, - pageSize: 10, + pageSize: 12, }); const client = createMockClient({ getPopular: getPopularMock }); const { server, getToolHandler } = createMockServer(); @@ -61,7 +61,7 @@ describe("get_popular tool", () => { const handler = getToolHandler("get_popular"); await handler({}); - expect(getPopularMock).toHaveBeenCalledWith(10, undefined); + expect(getPopularMock).toHaveBeenCalledWith(12, undefined, undefined); }); it("returns isError on ApiError", async () => { diff --git a/tests/tools/get-recent.test.ts b/tests/tools/get-recent.test.ts index dde0b1b..18aba78 100644 --- a/tests/tools/get-recent.test.ts +++ b/tests/tools/get-recent.test.ts @@ -47,12 +47,12 @@ describe("get_recent tool", () => { expect(result.content[0].text).toContain("[show]"); }); - it("defaults limit to 10", async () => { + it("defaults limit to 12", async () => { const getRecentMock = vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, - pageSize: 10, + pageSize: 12, }); const client = createMockClient({ getRecent: getRecentMock }); const { server, getToolHandler } = createMockServer(); @@ -61,7 +61,7 @@ describe("get_recent tool", () => { const handler = getToolHandler("get_recent"); await handler({}); - expect(getRecentMock).toHaveBeenCalledWith(10, undefined); + expect(getRecentMock).toHaveBeenCalledWith(12, undefined, undefined); }); it("returns isError on ApiError", async () => { diff --git a/tests/tools/scan-request.test.ts b/tests/tools/scan-request.test.ts new file mode 100644 index 0000000..cef9947 --- /dev/null +++ b/tests/tools/scan-request.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerScanRequest } from "../../src/tools/scan-request.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + autocomplete: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + track: vi.fn(), + submitScanRequest: vi.fn(), + getScanStatus: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("submit_scan_request tool", () => { + it("submits scan request successfully", async () => { + const submitMock = vi.fn().mockResolvedValue({ + status: "pending", + createdAt: "2026-01-01T00:00:00Z", + }); + const client = createMockClient({ submitScanRequest: submitMock }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("submit_scan_request"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + email: "test@example.com", + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("Scan request submitted"); + expect(result.content[0].text).toContain("pending"); + expect(submitMock).toHaveBeenCalledWith( + "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + "test@example.com", + ); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + submitScanRequest: vi + .fn() + .mockRejectedValue(new ApiError(429, "Rate limited")), + }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("submit_scan_request"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + email: "test@example.com", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (429)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + submitScanRequest: vi.fn().mockRejectedValue(new Error("Timeout")), + }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("submit_scan_request"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + email: "test@example.com", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Request failed: Timeout"); + }); + + it("returns isError on non-Error throw", async () => { + const client = createMockClient({ + submitScanRequest: vi.fn().mockRejectedValue("string error"), + }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("submit_scan_request"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + email: "test@example.com", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); + +describe("get_scan_status tool", () => { + it("returns scan status", async () => { + const statusMock = vi.fn().mockResolvedValue({ + status: "completed", + source: "scan_request", + createdAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:05:00Z", + }); + const client = createMockClient({ getScanStatus: statusMock }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("get_scan_status"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("completed"); + expect(result.content[0].text).toContain("scan_request"); + }); + + it("returns minimal status when fields are missing", async () => { + const statusMock = vi.fn().mockResolvedValue({ + status: "not_scanned", + }); + const client = createMockClient({ getScanStatus: statusMock }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("get_scan_status"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + }); + + expect(result.content[0].text).toContain("not_scanned"); + expect(result.content[0].text).not.toContain("Source:"); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + getScanStatus: vi + .fn() + .mockRejectedValue(new ApiError(404, "Not found")), + }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("get_scan_status"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (404)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + getScanStatus: vi.fn().mockRejectedValue(new Error("DNS failure")), + }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("get_scan_status"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("DNS failure"); + }); + + it("returns isError on non-Error throw", async () => { + const client = createMockClient({ + getScanStatus: vi.fn().mockRejectedValue(42), + }); + const { server, getToolHandler } = createMockServer(); + registerScanRequest(server, client); + + const handler = getToolHandler("get_scan_status"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/tests/tools/search-content.test.ts b/tests/tools/search-content.test.ts index 58b7573..a232f8c 100644 --- a/tests/tools/search-content.test.ts +++ b/tests/tools/search-content.test.ts @@ -38,6 +38,7 @@ describe("search_content tool", () => { genres: ["Action"], ratingImdb: "8.8", ratingTmdb: "8.4", + contentUrl: null, hasTorrents: true, torrents: [], }, @@ -79,6 +80,12 @@ describe("search_content tool", () => { min_rating: 7, quality: "1080p", language: "es", + audio: "atmos", + hdr: "dolby_vision", + availability: "available", + locale: "es", + season: 1, + episode: 5, sort: "seeders", page: 2, limit: 15, @@ -94,6 +101,12 @@ describe("search_content tool", () => { min_rating: 7, quality: "1080p", language: "es", + audio: "atmos", + hdr: "dolby_vision", + availability: "available", + locale: "es", + season: 1, + episode: 5, sort: "seeders", page: 2, limit: 15, @@ -101,11 +114,11 @@ describe("search_content tool", () => { }); }); - it("defaults limit to 10", async () => { + it("defaults limit to 20", async () => { const searchMock = vi.fn().mockResolvedValue({ total: 0, page: 1, - pageSize: 10, + pageSize: 20, results: [], }); const client = createMockClient({ search: searchMock }); @@ -116,7 +129,7 @@ describe("search_content tool", () => { await handler({ query: "test" }); expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ limit: 10 }), + expect.objectContaining({ limit: 20 }), ); }); diff --git a/tests/tools/track-interaction.test.ts b/tests/tools/track-interaction.test.ts new file mode 100644 index 0000000..b529c9c --- /dev/null +++ b/tests/tools/track-interaction.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerTrackInteraction } from "../../src/tools/track-interaction.js"; +import { TorrentClawClient, ApiError } from "../../src/api-client.js"; + +function createMockClient(overrides: Partial = {}) { + return { + search: vi.fn(), + autocomplete: vi.fn(), + getPopular: vi.fn(), + getRecent: vi.fn(), + getWatchProviders: vi.fn(), + getCredits: vi.fn(), + getStats: vi.fn(), + getTorrentDownloadUrl: vi.fn(), + track: vi.fn(), + submitScanRequest: vi.fn(), + getScanStatus: vi.fn(), + ...overrides, + } as unknown as TorrentClawClient; +} + +describe("track_interaction tool", () => { + it("tracks magnet interaction successfully", async () => { + const trackMock = vi.fn().mockResolvedValue({ ok: true }); + const client = createMockClient({ track: trackMock }); + const { server, getToolHandler } = createMockServer(); + registerTrackInteraction(server, client); + + const handler = getToolHandler("track_interaction"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + action: "magnet", + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("Tracked magnet"); + expect(trackMock).toHaveBeenCalledWith( + "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + "magnet", + ); + }); + + it("lowercases info_hash", async () => { + const trackMock = vi.fn().mockResolvedValue({ ok: true }); + const client = createMockClient({ track: trackMock }); + const { server, getToolHandler } = createMockServer(); + registerTrackInteraction(server, client); + + const handler = getToolHandler("track_interaction"); + await handler({ + info_hash: "AAF1E71C0A0E3B1C0F1A2B3C4D5E6F7A8B9C0D1E", + action: "copy", + }); + + expect(trackMock).toHaveBeenCalledWith( + "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + "copy", + ); + }); + + it("returns isError on ApiError", async () => { + const client = createMockClient({ + track: vi.fn().mockRejectedValue(new ApiError(500, "Server error")), + }); + const { server, getToolHandler } = createMockServer(); + registerTrackInteraction(server, client); + + const handler = getToolHandler("track_interaction"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + action: "torrent_download", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("TorrentClaw API error (500)"); + }); + + it("returns isError on generic error", async () => { + const client = createMockClient({ + track: vi.fn().mockRejectedValue(new Error("Network failure")), + }); + const { server, getToolHandler } = createMockServer(); + registerTrackInteraction(server, client); + + const handler = getToolHandler("track_interaction"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + action: "magnet", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Request failed: Network failure"); + }); + + it("returns isError on non-Error throw", async () => { + const client = createMockClient({ + track: vi.fn().mockRejectedValue("string error"), + }); + const { server, getToolHandler } = createMockServer(); + registerTrackInteraction(server, client); + + const handler = getToolHandler("track_interaction"); + const result = await handler({ + info_hash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", + action: "magnet", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3f497ad..4346965 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,10 +8,10 @@ export default defineConfig({ include: ["src/**/*.ts"], exclude: ["src/index.ts"], thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, + lines: 90, + functions: 90, + branches: 90, + statements: 90, }, }, },