feat: add response cache and compact output mode
Response cache (api-client.ts):
- In-memory TTL cache (5 min default) avoids duplicate API calls
when LLMs repeat the same tool call within a conversation.
- Cache key = full URL with params; only 200 OK responses are cached.
- Exposed via client.cache for manual clear() if needed.
- Configurable TTL via TorrentClawClient constructor.
Compact mode (search_content tool):
- New compact boolean parameter (default: false).
- When true, magnet links use short form magnet:?xt=urn:btih:{hash}
(~60 chars) instead of full magnets with trackers (~300 chars).
- Short magnets are still valid and clickable (DHT peer discovery).
- Also generates a magnet from info_hash even when API returns null.
- Saves ~10K chars per typical search (5 torrents x 10 results).
Tests: 100 total (15 new), all passing.
This commit is contained in:
parent
e011c0f63e
commit
bf459740fe
5 changed files with 349 additions and 9 deletions
|
|
@ -26,6 +26,44 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class ResponseCache {
|
||||
private store = new Map<string, CacheEntry<unknown>>();
|
||||
private ttl: number;
|
||||
|
||||
constructor(ttl = DEFAULT_CACHE_TTL) {
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
get<T>(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<T>(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<T>(
|
||||
|
|
@ -63,6 +103,10 @@ export class TorrentClawClient {
|
|||
}
|
||||
}
|
||||
|
||||
const cacheKey = url.toString();
|
||||
const cached = this.cache.get<T>(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<T>;
|
||||
const data = (await response.json()) as T;
|
||||
this.cache.set(cacheKey, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async search(params: SearchParams): Promise<SearchResponse> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
143
tests/cache.test.ts
Normal file
143
tests/cache.test.ts
Normal file
|
|
@ -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<typeof vi.fn>).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<typeof vi.fn>)
|
||||
.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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue