feat: expand API coverage with new tools, params, and 90% test threshold
This commit is contained in:
parent
8bb8e5507e
commit
fa913d1561
21 changed files with 1573 additions and 88 deletions
|
|
@ -6,6 +6,9 @@ import type {
|
||||||
WatchProvidersResponse,
|
WatchProvidersResponse,
|
||||||
CreditsResponse,
|
CreditsResponse,
|
||||||
StatsResponse,
|
StatsResponse,
|
||||||
|
AutocompleteResponse,
|
||||||
|
TrackResponse,
|
||||||
|
ScanRequestResponse,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|
@ -15,6 +18,8 @@ export class ApiError extends Error {
|
||||||
) {
|
) {
|
||||||
const messages: Record<number, string> = {
|
const messages: Record<number, string> = {
|
||||||
400: "Bad request — check that all parameters are valid.",
|
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.",
|
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.",
|
429: "Rate limit exceeded. Wait 10-30 seconds before retrying.",
|
||||||
500: "TorrentClaw server error. Try again in a moment.",
|
500: "TorrentClaw server error. Try again in a moment.",
|
||||||
|
|
@ -32,13 +37,16 @@ interface CacheEntry<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const DEFAULT_CACHE_MAX_SIZE = 200;
|
||||||
|
|
||||||
export class ResponseCache {
|
export class ResponseCache {
|
||||||
private store = new Map<string, CacheEntry<unknown>>();
|
private store = new Map<string, CacheEntry<unknown>>();
|
||||||
private ttl: number;
|
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.ttl = ttl;
|
||||||
|
this.maxSize = maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T>(key: string): T | undefined {
|
get<T>(key: string): T | undefined {
|
||||||
|
|
@ -48,10 +56,21 @@ export class ResponseCache {
|
||||||
this.store.delete(key);
|
this.store.delete(key);
|
||||||
return undefined;
|
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;
|
return entry.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
set<T>(key: string, data: T): void {
|
set<T>(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 });
|
this.store.set(key, { data, expiresAt: Date.now() + this.ttl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +92,12 @@ export interface SearchParams {
|
||||||
min_rating?: number;
|
min_rating?: number;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
audio?: string;
|
||||||
|
hdr?: string;
|
||||||
|
availability?: string;
|
||||||
|
locale?: string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
@ -82,14 +107,59 @@ export interface SearchParams {
|
||||||
export class TorrentClawClient {
|
export class TorrentClawClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private userAgent: string;
|
private userAgent: string;
|
||||||
|
private apiKey: string | undefined;
|
||||||
readonly cache: ResponseCache;
|
readonly cache: ResponseCache;
|
||||||
|
|
||||||
constructor(cacheTtl?: number) {
|
constructor(cacheTtl?: number) {
|
||||||
this.baseUrl = config.apiUrl;
|
this.baseUrl = config.apiUrl;
|
||||||
this.userAgent = `torrentclaw-mcp/${config.version}`;
|
this.userAgent = `torrentclaw-mcp/${config.version}`;
|
||||||
|
this.apiKey = config.apiKey;
|
||||||
this.cache = new ResponseCache(cacheTtl);
|
this.cache = new ResponseCache(cacheTtl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"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<never> {
|
||||||
|
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<Response> {
|
||||||
|
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<T>(
|
private async request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
params?: Record<string, string | number | undefined>,
|
params?: Record<string, string | number | undefined>,
|
||||||
|
|
@ -107,24 +177,12 @@ export class TorrentClawClient {
|
||||||
const cached = this.cache.get<T>(cacheKey);
|
const cached = this.cache.get<T>(cacheKey);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
const response = await this.fetchWithRetry(url.toString(), {
|
||||||
headers: {
|
headers: this.buildHeaders(),
|
||||||
"User-Agent": this.userAgent,
|
|
||||||
Accept: "application/json",
|
|
||||||
"X-Search-Source": "mcp",
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Only expose body for 4xx (client errors); omit for 5xx (may leak internals)
|
await this.handleErrorResponse(response);
|
||||||
let body = "";
|
|
||||||
if (response.status >= 400 && response.status < 500) {
|
|
||||||
try {
|
|
||||||
body = (await response.text()).slice(0, 200);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
throw new ApiError(response.status, body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as T;
|
const data = (await response.json()) as T;
|
||||||
|
|
@ -132,6 +190,27 @@ export class TorrentClawClient {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async postRequest<T>(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<SearchResponse> {
|
async search(params: SearchParams): Promise<SearchResponse> {
|
||||||
return this.request<SearchResponse>("/api/v1/search", {
|
return this.request<SearchResponse>("/api/v1/search", {
|
||||||
q: params.query,
|
q: params.query,
|
||||||
|
|
@ -142,6 +221,12 @@ export class TorrentClawClient {
|
||||||
min_rating: params.min_rating,
|
min_rating: params.min_rating,
|
||||||
quality: params.quality,
|
quality: params.quality,
|
||||||
lang: params.language,
|
lang: params.language,
|
||||||
|
audio: params.audio,
|
||||||
|
hdr: params.hdr,
|
||||||
|
availability: params.availability,
|
||||||
|
locale: params.locale,
|
||||||
|
season: params.season,
|
||||||
|
episode: params.episode,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
page: params.page,
|
page: params.page,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
|
|
@ -149,12 +234,34 @@ export class TorrentClawClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPopular(limit?: number, page?: number): Promise<PopularResponse> {
|
async autocomplete(query: string): Promise<AutocompleteResponse> {
|
||||||
return this.request<PopularResponse>("/api/v1/popular", { limit, page });
|
return this.request<AutocompleteResponse>("/api/v1/autocomplete", {
|
||||||
|
q: query,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecent(limit?: number, page?: number): Promise<RecentResponse> {
|
async getPopular(
|
||||||
return this.request<RecentResponse>("/api/v1/recent", { limit, page });
|
limit?: number,
|
||||||
|
page?: number,
|
||||||
|
locale?: string,
|
||||||
|
): Promise<PopularResponse> {
|
||||||
|
return this.request<PopularResponse>("/api/v1/popular", {
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecent(
|
||||||
|
limit?: number,
|
||||||
|
page?: number,
|
||||||
|
locale?: string,
|
||||||
|
): Promise<RecentResponse> {
|
||||||
|
return this.request<RecentResponse>("/api/v1/recent", {
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWatchProviders(
|
async getWatchProviders(
|
||||||
|
|
@ -177,6 +284,33 @@ export class TorrentClawClient {
|
||||||
return this.request<StatsResponse>("/api/v1/stats");
|
return this.request<StatsResponse>("/api/v1/stats");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async track(
|
||||||
|
infoHash: string,
|
||||||
|
action: "magnet" | "torrent_download" | "copy",
|
||||||
|
): Promise<TrackResponse> {
|
||||||
|
return this.postRequest<TrackResponse>("/api/v1/track", {
|
||||||
|
infoHash,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitScanRequest(
|
||||||
|
infoHash: string,
|
||||||
|
email: string,
|
||||||
|
): Promise<ScanRequestResponse> {
|
||||||
|
return this.postRequest<ScanRequestResponse>("/api/v1/scan-request", {
|
||||||
|
infoHash,
|
||||||
|
email,
|
||||||
|
website: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScanStatus(infoHash: string): Promise<ScanRequestResponse> {
|
||||||
|
return this.request<ScanRequestResponse>(
|
||||||
|
`/api/v1/scan-request/${infoHash}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getTorrentDownloadUrl(infoHash: string): string {
|
getTorrentDownloadUrl(infoHash: string): string {
|
||||||
return `${this.baseUrl}/api/v1/torrent/${infoHash}`;
|
return `${this.baseUrl}/api/v1/torrent/${infoHash}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,11 @@ export function validateApiUrl(raw: string): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowPrivate = process.env.TORRENTCLAW_ALLOW_PRIVATE === "true";
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||||
if (!allowPrivate) {
|
if (PRIVATE_IP_PATTERNS.some((re) => re.test(hostname))) {
|
||||||
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
throw new Error(
|
||||||
if (PRIVATE_IP_PATTERNS.some((re) => re.test(hostname))) {
|
`Invalid TORRENTCLAW_API_URL: private/reserved addresses not allowed`,
|
||||||
throw new Error(
|
);
|
||||||
`Invalid TORRENTCLAW_API_URL: private/reserved addresses not allowed. Set TORRENTCLAW_ALLOW_PRIVATE=true for self-hosted setups.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return raw;
|
return raw;
|
||||||
|
|
@ -41,5 +38,6 @@ export const config = {
|
||||||
apiUrl: validateApiUrl(
|
apiUrl: validateApiUrl(
|
||||||
process.env.TORRENTCLAW_API_URL || "https://torrentclaw.com",
|
process.env.TORRENTCLAW_API_URL || "https://torrentclaw.com",
|
||||||
),
|
),
|
||||||
|
apiKey: process.env.TORRENTCLAW_API_KEY || undefined,
|
||||||
version: process.env.npm_package_version || "1.0.0",
|
version: process.env.npm_package_version || "1.0.0",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,42 @@ function formatTorrent(t: TorrentInfo, compact?: boolean): string {
|
||||||
|
|
||||||
let line = ` - ${label} (${size}) | ${seeds}`;
|
let line = ` - ${label} (${size}) | ${seeds}`;
|
||||||
if (score) line += ` | ${score}`;
|
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}`;
|
line += `\n Info hash: ${t.infoHash}`;
|
||||||
if (compact) {
|
if (compact) {
|
||||||
// Short magnet (hash only, no trackers) — still clickable, saves ~200 chars per torrent
|
|
||||||
line += `\n Magnet: magnet:?xt=urn:btih:${t.infoHash}`;
|
line += `\n Magnet: magnet:?xt=urn:btih:${t.infoHash}`;
|
||||||
} else if (t.magnetUrl) {
|
} else if (t.magnetUrl) {
|
||||||
line += `\n Magnet: ${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;
|
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})`,
|
` 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.imdbId) lines.push(` IMDb: ${r.imdbId}`);
|
||||||
|
if (r.contentUrl) lines.push(` URL: ${r.contentUrl}`);
|
||||||
|
|
||||||
return lines.join("\n");
|
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.";
|
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));
|
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 {
|
function formatPopularItem(item: PopularItem, index: number): string {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import { registerGetRecent } from "./tools/get-recent.js";
|
||||||
import { registerGetWatchProviders } from "./tools/get-watch-providers.js";
|
import { registerGetWatchProviders } from "./tools/get-watch-providers.js";
|
||||||
import { registerGetCredits } from "./tools/get-credits.js";
|
import { registerGetCredits } from "./tools/get-credits.js";
|
||||||
import { registerGetTorrentUrl } from "./tools/get-torrent-url.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 { registerStatsResource } from "./resources/stats.js";
|
||||||
import { registerPrompts } from "./prompts.js";
|
import { registerPrompts } from "./prompts.js";
|
||||||
|
|
||||||
|
|
@ -18,16 +21,19 @@ const server = new McpServer({
|
||||||
name: "torrentclaw",
|
name: "torrentclaw",
|
||||||
version: config.version,
|
version: config.version,
|
||||||
description:
|
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
|
// Register tools
|
||||||
registerSearchContent(server, client);
|
registerSearchContent(server, client);
|
||||||
|
registerAutocomplete(server, client);
|
||||||
registerGetPopular(server, client);
|
registerGetPopular(server, client);
|
||||||
registerGetRecent(server, client);
|
registerGetRecent(server, client);
|
||||||
registerGetWatchProviders(server, client);
|
registerGetWatchProviders(server, client);
|
||||||
registerGetCredits(server, client);
|
registerGetCredits(server, client);
|
||||||
registerGetTorrentUrl(server, client);
|
registerGetTorrentUrl(server, client);
|
||||||
|
registerTrackInteraction(server, client);
|
||||||
|
registerScanRequest(server, client);
|
||||||
|
|
||||||
// Register resources
|
// Register resources
|
||||||
registerStatsResource(server, client);
|
registerStatsResource(server, client);
|
||||||
|
|
|
||||||
60
src/tools/autocomplete.ts
Normal file
60
src/tools/autocomplete.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,17 +18,28 @@ export function registerGetPopular(
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(24)
|
.max(24)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Number of items (default: 10)"),
|
.describe("Number of items (default: 12)"),
|
||||||
page: z
|
page: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Page number (default: 1)"),
|
.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) => {
|
async (params) => {
|
||||||
try {
|
try {
|
||||||
const data = await client.getPopular(params.limit ?? 10, params.page);
|
const data = await client.getPopular(
|
||||||
|
params.limit ?? 12,
|
||||||
|
params.page,
|
||||||
|
params.locale,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: formatPopularResults(data) }],
|
content: [{ type: "text", text: formatPopularResults(data) }],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,28 @@ export function registerGetRecent(
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(24)
|
.max(24)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Number of items (default: 10)"),
|
.describe("Number of items (default: 12)"),
|
||||||
page: z
|
page: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Page number (default: 1)"),
|
.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) => {
|
async (params) => {
|
||||||
try {
|
try {
|
||||||
const data = await client.getRecent(params.limit ?? 10, params.page);
|
const data = await client.getRecent(
|
||||||
|
params.limit ?? 12,
|
||||||
|
params.page,
|
||||||
|
params.locale,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: formatRecentResults(data) }],
|
content: [{ type: "text", text: formatRecentResults(data) }],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
77
src/tools/scan-request.ts
Normal file
77
src/tools/scan-request.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ export function registerSearchContent(
|
||||||
): void {
|
): void {
|
||||||
server.tool(
|
server.tool(
|
||||||
"search_content",
|
"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
|
query: z
|
||||||
.string()
|
.string()
|
||||||
|
|
@ -21,7 +21,7 @@ export function registerSearchContent(
|
||||||
"Query contains invalid control characters",
|
"Query contains invalid control characters",
|
||||||
)
|
)
|
||||||
.describe(
|
.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
|
type: z
|
||||||
.enum(["movie", "show"])
|
.enum(["movie", "show"])
|
||||||
|
|
@ -62,10 +62,59 @@ export function registerSearchContent(
|
||||||
.describe("Filter torrents by resolution"),
|
.describe("Filter torrents by resolution"),
|
||||||
language: z
|
language: z
|
||||||
.string()
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^[a-z]{2}$/,
|
||||||
|
"Must be a lowercase 2-letter ISO 639-1 language code",
|
||||||
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"ISO 639-1 language code to filter torrents (e.g. 'en' for English, 'es' for Spanish, 'fr' for French). Lowercase 2-letter code.",
|
"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
|
sort: z
|
||||||
.enum(["relevance", "seeders", "year", "rating", "added"])
|
.enum(["relevance", "seeders", "year", "rating", "added"])
|
||||||
.default("relevance")
|
.default("relevance")
|
||||||
|
|
@ -74,16 +123,16 @@ export function registerSearchContent(
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(1000)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Page number (default: 1)"),
|
.describe("Page number (default: 1, max: 1000)"),
|
||||||
limit: z
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(20)
|
.max(50)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Results per page (default: 10, max: 20)"),
|
.describe("Results per page (default: 20, max: 50)"),
|
||||||
country: z
|
country: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
|
|
@ -112,9 +161,15 @@ export function registerSearchContent(
|
||||||
min_rating: params.min_rating,
|
min_rating: params.min_rating,
|
||||||
quality: params.quality,
|
quality: params.quality,
|
||||||
language: params.language,
|
language: params.language,
|
||||||
|
audio: params.audio,
|
||||||
|
hdr: params.hdr,
|
||||||
|
availability: params.availability,
|
||||||
|
locale: params.locale,
|
||||||
|
season: params.season,
|
||||||
|
episode: params.episode,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
page: params.page,
|
page: params.page,
|
||||||
limit: params.limit ?? 10,
|
limit: params.limit ?? 20,
|
||||||
country: params.country,
|
country: params.country,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
44
src/tools/track-interaction.ts
Normal file
44
src/tools/track-interaction.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/types.ts
63
src/types.ts
|
|
@ -1,7 +1,32 @@
|
||||||
// Response types mirrored from TorrentClaw API (src/types/api.ts)
|
// 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 {
|
export interface TorrentInfo {
|
||||||
infoHash: string;
|
infoHash: string;
|
||||||
|
rawTitle: string | null;
|
||||||
quality: string | null;
|
quality: string | null;
|
||||||
codec: string | null;
|
codec: string | null;
|
||||||
sourceType: string | null;
|
sourceType: string | null;
|
||||||
|
|
@ -9,6 +34,7 @@ export interface TorrentInfo {
|
||||||
seeders: number;
|
seeders: number;
|
||||||
leechers: number;
|
leechers: number;
|
||||||
magnetUrl: string | null;
|
magnetUrl: string | null;
|
||||||
|
torrentUrl: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
qualityScore: number | null;
|
qualityScore: number | null;
|
||||||
uploadedAt: string | null;
|
uploadedAt: string | null;
|
||||||
|
|
@ -19,6 +45,12 @@ export interface TorrentInfo {
|
||||||
isProper: boolean | null;
|
isProper: boolean | null;
|
||||||
isRepack: boolean | null;
|
isRepack: boolean | null;
|
||||||
isRemastered: 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 {
|
export interface StreamingProviderItem {
|
||||||
|
|
@ -49,6 +81,7 @@ export interface SearchResult {
|
||||||
genres: string[] | null;
|
genres: string[] | null;
|
||||||
ratingImdb: string | null;
|
ratingImdb: string | null;
|
||||||
ratingTmdb: string | null;
|
ratingTmdb: string | null;
|
||||||
|
contentUrl: string | null;
|
||||||
hasTorrents: boolean;
|
hasTorrents: boolean;
|
||||||
torrents: TorrentInfo[];
|
torrents: TorrentInfo[];
|
||||||
streaming?: StreamingInfo;
|
streaming?: StreamingInfo;
|
||||||
|
|
@ -58,9 +91,39 @@ export interface SearchResponse {
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
parsedSeason?: number;
|
||||||
|
parsedEpisode?: number;
|
||||||
results: SearchResult[];
|
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 {
|
export interface PopularItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,10 @@ describe("TorrentClawClient", () => {
|
||||||
await expect(client.search({ query: "test" })).rejects.toThrow(ApiError);
|
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);
|
mockFetchError("Too many requests", 429);
|
||||||
|
|
||||||
const client = new TorrentClawClient();
|
const client = new TorrentClawClient();
|
||||||
|
|
@ -119,6 +122,28 @@ describe("TorrentClawClient", () => {
|
||||||
expect(calledUrl).toContain("page=2");
|
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<typeof vi.fn>;
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||||
|
expect(calledUrl).toContain("locale=fr");
|
||||||
|
});
|
||||||
|
|
||||||
it("calls correct endpoint for watch providers", async () => {
|
it("calls correct endpoint for watch providers", async () => {
|
||||||
mockFetch({
|
mockFetch({
|
||||||
contentId: 42,
|
contentId: 42,
|
||||||
|
|
@ -216,4 +241,101 @@ describe("TorrentClawClient", () => {
|
||||||
expect((e as ApiError).body).toBe("");
|
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<typeof vi.fn>;
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
const options = fetchMock.mock.calls[0][1] as RequestInit;
|
||||||
|
const headers = options.headers as Record<string, string>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,7 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { validateApiUrl } from "../src/config.js";
|
import { validateApiUrl } from "../src/config.js";
|
||||||
|
|
||||||
describe("validateApiUrl", () => {
|
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", () => {
|
it("accepts valid https URL", () => {
|
||||||
expect(validateApiUrl("https://torrentclaw.com")).toBe(
|
expect(validateApiUrl("https://torrentclaw.com")).toBe(
|
||||||
"https://torrentclaw.com",
|
"https://torrentclaw.com",
|
||||||
|
|
@ -91,21 +78,4 @@ describe("validateApiUrl", () => {
|
||||||
it("rejects IPv6 loopback ::1", () => {
|
it("rejects IPv6 loopback ::1", () => {
|
||||||
expect(() => validateApiUrl("http://[::1]")).toThrow("private/reserved");
|
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,468 @@ describe("formatSearchResults", () => {
|
||||||
expect(full).not.toContain("Magnet:");
|
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", () => {
|
it("shows streaming info when available", () => {
|
||||||
const response: SearchResponse = {
|
const response: SearchResponse = {
|
||||||
total: 1,
|
total: 1,
|
||||||
|
|
|
||||||
115
tests/tools/autocomplete.test.ts
Normal file
115
tests/tools/autocomplete.test.ts
Normal file
|
|
@ -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<TorrentClawClient> = {}) {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -47,12 +47,12 @@ describe("get_popular tool", () => {
|
||||||
expect(result.content[0].text).toContain("200 clicks");
|
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({
|
const getPopularMock = vi.fn().mockResolvedValue({
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 12,
|
||||||
});
|
});
|
||||||
const client = createMockClient({ getPopular: getPopularMock });
|
const client = createMockClient({ getPopular: getPopularMock });
|
||||||
const { server, getToolHandler } = createMockServer();
|
const { server, getToolHandler } = createMockServer();
|
||||||
|
|
@ -61,7 +61,7 @@ describe("get_popular tool", () => {
|
||||||
const handler = getToolHandler("get_popular");
|
const handler = getToolHandler("get_popular");
|
||||||
await handler({});
|
await handler({});
|
||||||
|
|
||||||
expect(getPopularMock).toHaveBeenCalledWith(10, undefined);
|
expect(getPopularMock).toHaveBeenCalledWith(12, undefined, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns isError on ApiError", async () => {
|
it("returns isError on ApiError", async () => {
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,12 @@ describe("get_recent tool", () => {
|
||||||
expect(result.content[0].text).toContain("[show]");
|
expect(result.content[0].text).toContain("[show]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults limit to 10", async () => {
|
it("defaults limit to 12", async () => {
|
||||||
const getRecentMock = vi.fn().mockResolvedValue({
|
const getRecentMock = vi.fn().mockResolvedValue({
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 12,
|
||||||
});
|
});
|
||||||
const client = createMockClient({ getRecent: getRecentMock });
|
const client = createMockClient({ getRecent: getRecentMock });
|
||||||
const { server, getToolHandler } = createMockServer();
|
const { server, getToolHandler } = createMockServer();
|
||||||
|
|
@ -61,7 +61,7 @@ describe("get_recent tool", () => {
|
||||||
const handler = getToolHandler("get_recent");
|
const handler = getToolHandler("get_recent");
|
||||||
await handler({});
|
await handler({});
|
||||||
|
|
||||||
expect(getRecentMock).toHaveBeenCalledWith(10, undefined);
|
expect(getRecentMock).toHaveBeenCalledWith(12, undefined, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns isError on ApiError", async () => {
|
it("returns isError on ApiError", async () => {
|
||||||
|
|
|
||||||
190
tests/tools/scan-request.test.ts
Normal file
190
tests/tools/scan-request.test.ts
Normal file
|
|
@ -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<TorrentClawClient> = {}) {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -38,6 +38,7 @@ describe("search_content tool", () => {
|
||||||
genres: ["Action"],
|
genres: ["Action"],
|
||||||
ratingImdb: "8.8",
|
ratingImdb: "8.8",
|
||||||
ratingTmdb: "8.4",
|
ratingTmdb: "8.4",
|
||||||
|
contentUrl: null,
|
||||||
hasTorrents: true,
|
hasTorrents: true,
|
||||||
torrents: [],
|
torrents: [],
|
||||||
},
|
},
|
||||||
|
|
@ -79,6 +80,12 @@ describe("search_content tool", () => {
|
||||||
min_rating: 7,
|
min_rating: 7,
|
||||||
quality: "1080p",
|
quality: "1080p",
|
||||||
language: "es",
|
language: "es",
|
||||||
|
audio: "atmos",
|
||||||
|
hdr: "dolby_vision",
|
||||||
|
availability: "available",
|
||||||
|
locale: "es",
|
||||||
|
season: 1,
|
||||||
|
episode: 5,
|
||||||
sort: "seeders",
|
sort: "seeders",
|
||||||
page: 2,
|
page: 2,
|
||||||
limit: 15,
|
limit: 15,
|
||||||
|
|
@ -94,6 +101,12 @@ describe("search_content tool", () => {
|
||||||
min_rating: 7,
|
min_rating: 7,
|
||||||
quality: "1080p",
|
quality: "1080p",
|
||||||
language: "es",
|
language: "es",
|
||||||
|
audio: "atmos",
|
||||||
|
hdr: "dolby_vision",
|
||||||
|
availability: "available",
|
||||||
|
locale: "es",
|
||||||
|
season: 1,
|
||||||
|
episode: 5,
|
||||||
sort: "seeders",
|
sort: "seeders",
|
||||||
page: 2,
|
page: 2,
|
||||||
limit: 15,
|
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({
|
const searchMock = vi.fn().mockResolvedValue({
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 20,
|
||||||
results: [],
|
results: [],
|
||||||
});
|
});
|
||||||
const client = createMockClient({ search: searchMock });
|
const client = createMockClient({ search: searchMock });
|
||||||
|
|
@ -116,7 +129,7 @@ describe("search_content tool", () => {
|
||||||
await handler({ query: "test" });
|
await handler({ query: "test" });
|
||||||
|
|
||||||
expect(searchMock).toHaveBeenCalledWith(
|
expect(searchMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ limit: 10 }),
|
expect.objectContaining({ limit: 20 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
112
tests/tools/track-interaction.test.ts
Normal file
112
tests/tools/track-interaction.test.ts
Normal file
|
|
@ -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<TorrentClawClient> = {}) {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,10 +8,10 @@ export default defineConfig({
|
||||||
include: ["src/**/*.ts"],
|
include: ["src/**/*.ts"],
|
||||||
exclude: ["src/index.ts"],
|
exclude: ["src/index.ts"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 80,
|
lines: 90,
|
||||||
functions: 80,
|
functions: 90,
|
||||||
branches: 80,
|
branches: 90,
|
||||||
statements: 80,
|
statements: 90,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue