diff --git a/src/api-client.ts b/src/api-client.ts index 2e69a41..cc26462 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -26,6 +26,44 @@ export class ApiError extends Error { } } +interface CacheEntry { + data: T; + expiresAt: number; +} + +const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +export class ResponseCache { + private store = new Map>(); + private ttl: number; + + constructor(ttl = DEFAULT_CACHE_TTL) { + this.ttl = ttl; + } + + get(key: string): T | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.data as T; + } + + set(key: string, data: T): void { + this.store.set(key, { data, expiresAt: Date.now() + this.ttl }); + } + + clear(): void { + this.store.clear(); + } + + get size(): number { + return this.store.size; + } +} + export interface SearchParams { query: string; type?: string; @@ -44,10 +82,12 @@ export interface SearchParams { export class TorrentClawClient { private baseUrl: string; private userAgent: string; + readonly cache: ResponseCache; - constructor() { + constructor(cacheTtl?: number) { this.baseUrl = config.apiUrl; this.userAgent = `torrentclaw-mcp/${config.version}`; + this.cache = new ResponseCache(cacheTtl); } private async request( @@ -63,6 +103,10 @@ export class TorrentClawClient { } } + const cacheKey = url.toString(); + const cached = this.cache.get(cacheKey); + if (cached !== undefined) return cached; + const response = await fetch(url.toString(), { headers: { "User-Agent": this.userAgent, @@ -83,7 +127,9 @@ export class TorrentClawClient { throw new ApiError(response.status, body); } - return response.json() as Promise; + const data = (await response.json()) as T; + this.cache.set(cacheKey, data); + return data; } async search(params: SearchParams): Promise { diff --git a/src/formatters/content.ts b/src/formatters/content.ts index 2d05833..c3f03bd 100644 --- a/src/formatters/content.ts +++ b/src/formatters/content.ts @@ -29,7 +29,7 @@ function truncate(text: string, max: number): string { return text.slice(0, max - 3) + "..."; } -function formatTorrent(t: TorrentInfo): string { +function formatTorrent(t: TorrentInfo, compact?: boolean): string { const parts: string[] = []; if (t.quality) parts.push(t.quality); if (t.sourceType) parts.push(t.sourceType); @@ -43,11 +43,24 @@ function formatTorrent(t: TorrentInfo): string { let line = ` - ${label} (${size}) | ${seeds}`; if (score) line += ` | ${score}`; line += `\n Info hash: ${t.infoHash}`; - if (t.magnetUrl) line += `\n Magnet: ${t.magnetUrl}`; + 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}`; + } return line; } -function formatResult(r: SearchResult, index: number): string { +export interface FormatOptions { + compact?: boolean; +} + +function formatResult( + r: SearchResult, + index: number, + opts?: FormatOptions, +): string { const lines: string[] = []; const yearStr = r.year ? ` (${r.year})` : ""; lines.push(`${index}. ${r.title}${yearStr} [${r.contentType}]`); @@ -65,7 +78,7 @@ function formatResult(r: SearchResult, index: number): string { .slice(0, 5); lines.push(` Torrents (${r.torrents.length} total, top ${top.length}):`); for (const t of top) { - lines.push(formatTorrent(t)); + lines.push(formatTorrent(t, opts?.compact)); } } else { lines.push(" No torrents available"); @@ -92,13 +105,16 @@ function formatResult(r: SearchResult, index: number): string { return lines.join("\n"); } -export function formatSearchResults(data: SearchResponse): string { +export function formatSearchResults( + data: SearchResponse, + opts?: FormatOptions, +): string { if (data.results.length === 0) { 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 results = data.results.map((r, i) => formatResult(r, i + 1)); + const results = data.results.map((r, i) => formatResult(r, i + 1, opts)); return [header, "", ...results].join("\n"); } diff --git a/src/tools/search-content.ts b/src/tools/search-content.ts index 44be362..8c95d57 100644 --- a/src/tools/search-content.ts +++ b/src/tools/search-content.ts @@ -94,6 +94,12 @@ export function registerSearchContent( .describe( "ISO 3166-1 country code for streaming availability (e.g. US, ES, GB, DE). If provided, results include which streaming services offer each title. If omitted, no streaming data is returned.", ), + compact: z + .boolean() + .default(false) + .describe( + "When true, returns shorter magnet links (hash only, no trackers) to reduce output size. Magnets are still clickable. Recommended for large result sets or when context window is limited.", + ), }, async (params) => { try { @@ -111,7 +117,14 @@ export function registerSearchContent( limit: params.limit ?? 10, country: params.country, }); - return { content: [{ type: "text", text: formatSearchResults(data) }] }; + return { + content: [ + { + type: "text", + text: formatSearchResults(data, { compact: params.compact }), + }, + ], + }; } catch (error) { const message = error instanceof ApiError diff --git a/tests/cache.test.ts b/tests/cache.test.ts new file mode 100644 index 0000000..87c11f1 --- /dev/null +++ b/tests/cache.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ResponseCache, TorrentClawClient } from "../src/api-client.js"; + +describe("ResponseCache", () => { + it("returns undefined for missing keys", () => { + const cache = new ResponseCache(); + expect(cache.get("missing")).toBeUndefined(); + }); + + it("stores and retrieves values", () => { + const cache = new ResponseCache(); + cache.set("key1", { data: "hello" }); + expect(cache.get("key1")).toEqual({ data: "hello" }); + }); + + it("expires entries after TTL", () => { + const cache = new ResponseCache(100); // 100ms TTL + cache.set("key1", "value"); + + // Advance time past TTL + vi.useFakeTimers(); + vi.advanceTimersByTime(150); + + expect(cache.get("key1")).toBeUndefined(); + // Expired entry should be removed from store + expect(cache.size).toBe(0); + + vi.useRealTimers(); + }); + + it("returns value before TTL expires", () => { + vi.useFakeTimers(); + const cache = new ResponseCache(1000); // 1s TTL + cache.set("key1", "value"); + + vi.advanceTimersByTime(500); + expect(cache.get("key1")).toBe("value"); + + vi.useRealTimers(); + }); + + it("clears all entries", () => { + const cache = new ResponseCache(); + cache.set("a", 1); + cache.set("b", 2); + expect(cache.size).toBe(2); + + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.get("a")).toBeUndefined(); + }); + + it("overwrites existing keys", () => { + const cache = new ResponseCache(); + cache.set("key1", "old"); + cache.set("key1", "new"); + expect(cache.get("key1")).toBe("new"); + expect(cache.size).toBe(1); + }); +}); + +describe("TorrentClawClient cache integration", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockFetch(body: unknown, status = 200) { + (globalThis.fetch as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + } + + it("caches search results on second call", async () => { + const responseData = { total: 1, page: 1, pageSize: 10, results: [] }; + mockFetch(responseData); + + const client = new TorrentClawClient(); + const result1 = await client.search({ query: "inception" }); + const result2 = await client.search({ query: "inception" }); + + // Only one fetch call — second was served from cache + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(result1).toEqual(responseData); + expect(result2).toEqual(responseData); + }); + + it("does not cache different queries", async () => { + const data1 = { total: 1, page: 1, pageSize: 10, results: [] }; + const data2 = { total: 2, page: 1, pageSize: 10, results: [] }; + mockFetch(data1); + mockFetch(data2); + + const client = new TorrentClawClient(); + await client.search({ query: "inception" }); + await client.search({ query: "matrix" }); + + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it("does not cache error responses", async () => { + (globalThis.fetch as ReturnType) + .mockResolvedValueOnce(new Response("Server error", { status: 500 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ total: 0, page: 1, pageSize: 10, results: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const client = new TorrentClawClient(); + + // First call fails + await expect(client.search({ query: "test" })).rejects.toThrow(); + + // Second call should hit the API again (not cached) + const result = await client.search({ query: "test" }); + expect(result.total).toBe(0); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it("cache can be cleared manually", async () => { + const data = { total: 1, page: 1, pageSize: 10, results: [] }; + mockFetch(data); + mockFetch(data); + + const client = new TorrentClawClient(); + await client.search({ query: "test" }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + client.cache.clear(); + await client.search({ query: "test" }); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/formatters/content.test.ts b/tests/formatters/content.test.ts index 42665ea..0621200 100644 --- a/tests/formatters/content.test.ts +++ b/tests/formatters/content.test.ts @@ -396,6 +396,128 @@ describe("formatSearchResults", () => { expect(text).toContain("10.0 GB"); }); + it("compact mode uses short magnet links", () => { + const hash = "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e"; + const fullMagnet = `magnet:?xt=urn:btih:${hash}&dn=Inception&tr=udp://tracker.example.com:6969&tr=udp://tracker2.example.com:6969`; + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 42, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "Inception", + titleOriginal: null, + year: 2010, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: true, + torrents: [ + { + infoHash: hash, + quality: "1080p", + codec: null, + sourceType: null, + sizeBytes: "2147483648", + seeders: 100, + leechers: 5, + magnetUrl: fullMagnet, + source: "yts", + qualityScore: 85, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + }, + ], + }, + ], + }; + + const compact = formatSearchResults(response, { compact: true }); + const full = formatSearchResults(response); + + // Compact: short magnet with just the hash + expect(compact).toContain(`magnet:?xt=urn:btih:${hash}`); + // Compact: no tracker URLs + expect(compact).not.toContain("tracker.example.com"); + // Full: includes the full magnet URL with trackers + expect(full).toContain(fullMagnet); + // Compact output should be shorter + expect(compact.length).toBeLessThan(full.length); + // Both include the info hash + expect(compact).toContain(`Info hash: ${hash}`); + expect(full).toContain(`Info hash: ${hash}`); + }); + + it("compact mode generates magnet even when magnetUrl is null", () => { + const hash = "b".repeat(40); + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: null, + tmdbId: null, + contentType: "movie", + title: "No Magnet", + titleOriginal: null, + year: 2024, + overview: null, + posterUrl: null, + backdropUrl: null, + genres: null, + ratingImdb: null, + ratingTmdb: null, + hasTorrents: true, + torrents: [ + { + infoHash: hash, + quality: "720p", + codec: null, + sourceType: null, + sizeBytes: "1073741824", + seeders: 10, + leechers: 0, + magnetUrl: null, + source: "test", + qualityScore: 50, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + }, + ], + }, + ], + }; + + const compact = formatSearchResults(response, { compact: true }); + const full = formatSearchResults(response); + + // Compact always generates a magnet from info_hash + expect(compact).toContain(`magnet:?xt=urn:btih:${hash}`); + // Full mode: no magnet when magnetUrl is null + expect(full).not.toContain("Magnet:"); + }); + it("shows streaming info when available", () => { const response: SearchResponse = { total: 1,