style: add prettier and format codebase

This commit is contained in:
Deivid Soto 2026-02-12 15:22:11 +01:00
parent bf459740fe
commit 2f58ac7bf8
18 changed files with 151 additions and 70 deletions

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 80
}

View file

@ -12,29 +12,29 @@ No API key required.
## Available Tools ## Available Tools
| Tool | Description | | Tool | Description |
|------|-------------| | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `search_content` | Search movies/shows with filters (query, type, genre, year, rating, quality, language, sort). Returns content with torrents and magnet links. | | `search_content` | Search movies/shows with filters (query, type, genre, year, rating, quality, language, sort). Returns content with torrents and magnet links. |
| `get_popular` | Get popular content ranked by user clicks | | `get_popular` | Get popular content ranked by user clicks |
| `get_recent` | Get recently added content | | `get_recent` | Get recently added content |
| `get_watch_providers` | Streaming availability by country (Netflix, Disney+, etc.) | | `get_watch_providers` | Streaming availability by country (Netflix, Disney+, etc.) |
| `get_credits` | Cast and director for a title | | `get_credits` | Cast and director for a title |
| `get_torrent_url` | Get .torrent file download URL from info hash | | `get_torrent_url` | Get .torrent file download URL from info hash |
## Resources ## Resources
| URI | Description | | URI | Description |
|-----|-------------| | --------------------- | ----------------------------------------------------- |
| `torrentclaw://stats` | Catalog statistics (content/torrent counts by source) | | `torrentclaw://stats` | Catalog statistics (content/torrent counts by source) |
## Prompts ## Prompts
| Prompt | Description | | Prompt | Description |
|--------|-------------| | ---------------- | -------------------------------------------------------- |
| `search_movie` | Search for a movie by title and get torrents + streaming | | `search_movie` | Search for a movie by title and get torrents + streaming |
| `search_show` | Search for a TV show by title and get torrents | | `search_show` | Search for a TV show by title and get torrents |
| `whats_new` | Discover recently added movies and TV shows | | `whats_new` | Discover recently added movies and TV shows |
| `where_to_watch` | Find where to stream, rent, or buy a title | | `where_to_watch` | Find where to stream, rent, or buy a title |
## Configuration ## Configuration
@ -89,10 +89,10 @@ Point to your own TorrentClaw instance:
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| | --------------------------- | ------------------------- | ---------------------------------------------------------------------- |
| `TORRENTCLAW_API_URL` | `https://torrentclaw.com` | Base URL of the TorrentClaw API | | `TORRENTCLAW_API_URL` | `https://torrentclaw.com` | Base URL of the TorrentClaw API |
| `TORRENTCLAW_ALLOW_PRIVATE` | `false` | Set to `true` to allow private/localhost URLs (for self-hosted setups) | | `TORRENTCLAW_ALLOW_PRIVATE` | `false` | Set to `true` to allow private/localhost URLs (for self-hosted setups) |
## Development ## Development

17
package-lock.json generated
View file

@ -18,6 +18,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"prettier": "^3.8.1",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^4.0.0" "vitest": "^4.0.0"
@ -2231,6 +2232,22 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",

View file

@ -51,6 +51,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"prettier": "^3.8.1",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^4.0.0" "vitest": "^4.0.0"

View file

@ -91,9 +91,7 @@ function formatResult(
`Stream: ${r.streaming.flatrate.map((p) => p.name).join(", ")}`, `Stream: ${r.streaming.flatrate.map((p) => p.name).join(", ")}`,
); );
if (r.streaming.free.length > 0) if (r.streaming.free.length > 0)
providers.push( providers.push(`Free: ${r.streaming.free.map((p) => p.name).join(", ")}`);
`Free: ${r.streaming.free.map((p) => p.name).join(", ")}`,
);
if (providers.length > 0) lines.push(` ${providers.join(" | ")}`); if (providers.length > 0) lines.push(` ${providers.join(" | ")}`);
} }
@ -130,7 +128,8 @@ export function formatPopularResults(data: PopularResponse): string {
} }
const header = `Popular content (${data.total} total, page ${data.page}):`; const header = `Popular content (${data.total} total, page ${data.page}):`;
const hint = "(Use search_content with a title to get torrents and full details)"; const hint =
"(Use search_content with a title to get torrents and full details)";
const items = data.items.map((item, i) => formatPopularItem(item, i + 1)); const items = data.items.map((item, i) => formatPopularItem(item, i + 1));
return [header, hint, "", ...items].join("\n"); return [header, hint, "", ...items].join("\n");
} }
@ -152,7 +151,8 @@ export function formatRecentResults(data: RecentResponse): string {
} }
const header = `Recently added content (${data.total} total, page ${data.page}):`; const header = `Recently added content (${data.total} total, page ${data.page}):`;
const hint = "(Use search_content with a title to get torrents and full details)"; const hint =
"(Use search_content with a title to get torrents and full details)";
const items = data.items.map((item, i) => formatRecentItem(item, i + 1)); const items = data.items.map((item, i) => formatRecentItem(item, i + 1));
return [header, hint, "", ...items].join("\n"); return [header, hint, "", ...items].join("\n");
} }

View file

@ -27,9 +27,7 @@ export function formatWatchProviders(data: WatchProvidersResponse): string {
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
if (sections.length === 0) { if (sections.length === 0) {
lines.push( lines.push(` No watch providers found in ${data.country}.`);
` No watch providers found in ${data.country}.`,
);
} else { } else {
lines.push(...sections); lines.push(...sections);
} }

View file

@ -28,10 +28,7 @@ export function registerGetPopular(
}, },
async (params) => { async (params) => {
try { try {
const data = await client.getPopular( const data = await client.getPopular(params.limit ?? 10, params.page);
params.limit ?? 10,
params.page,
);
return { return {
content: [{ type: "text", text: formatPopularResults(data) }], content: [{ type: "text", text: formatPopularResults(data) }],
}; };

View file

@ -28,10 +28,7 @@ export function registerGetRecent(
}, },
async (params) => { async (params) => {
try { try {
const data = await client.getRecent( const data = await client.getRecent(params.limit ?? 10, params.page);
params.limit ?? 10,
params.page,
);
return { return {
content: [{ type: "text", text: formatRecentResults(data) }], content: [{ type: "text", text: formatRecentResults(data) }],
}; };

View file

@ -27,9 +27,7 @@ export function registerGetWatchProviders(
"Must be uppercase 2-letter ISO 3166-1 country code", "Must be uppercase 2-letter ISO 3166-1 country code",
) )
.default("US") .default("US")
.describe( .describe("ISO 3166-1 country code (e.g. US, ES, GB, DE). Default: US"),
"ISO 3166-1 country code (e.g. US, ES, GB, DE). Default: US",
),
}, },
async (params) => { async (params) => {
try { try {

View file

@ -98,7 +98,9 @@ describe("TorrentClawClient", () => {
it("constructs torrent download URL", () => { it("constructs torrent download URL", () => {
const client = new TorrentClawClient(); const client = new TorrentClawClient();
const url = client.getTorrentDownloadUrl("aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e"); const url = client.getTorrentDownloadUrl(
"aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
);
expect(url).toBe( expect(url).toBe(
"https://torrentclaw.com/api/v1/torrent/aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", "https://torrentclaw.com/api/v1/torrent/aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
); );

View file

@ -110,10 +110,13 @@ describe("TorrentClawClient cache integration", () => {
(globalThis.fetch as ReturnType<typeof vi.fn>) (globalThis.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(new Response("Server error", { status: 500 })) .mockResolvedValueOnce(new Response("Server error", { status: 500 }))
.mockResolvedValueOnce( .mockResolvedValueOnce(
new Response(JSON.stringify({ total: 0, page: 1, pageSize: 10, results: [] }), { new Response(
status: 200, JSON.stringify({ total: 0, page: 1, pageSize: 10, results: [] }),
headers: { "Content-Type": "application/json" }, {
}), status: 200,
headers: { "Content-Type": "application/json" },
},
),
); );
const client = new TorrentClawClient(); const client = new TorrentClawClient();

View file

@ -56,15 +56,11 @@ describe("validateApiUrl", () => {
}); });
it("rejects 0.0.0.0", () => { it("rejects 0.0.0.0", () => {
expect(() => validateApiUrl("http://0.0.0.0")).toThrow( expect(() => validateApiUrl("http://0.0.0.0")).toThrow("private/reserved");
"private/reserved",
);
}); });
it("rejects 10.x.x.x range", () => { it("rejects 10.x.x.x range", () => {
expect(() => validateApiUrl("http://10.0.0.1")).toThrow( expect(() => validateApiUrl("http://10.0.0.1")).toThrow("private/reserved");
"private/reserved",
);
}); });
it("rejects 172.16-31.x.x range", () => { it("rejects 172.16-31.x.x range", () => {

View file

@ -36,7 +36,8 @@ describe("formatSearchResults", () => {
title: "Inception", title: "Inception",
titleOriginal: "Inception", titleOriginal: "Inception",
year: 2010, year: 2010,
overview: "A thief who steals corporate secrets through dream-sharing technology.", overview:
"A thief who steals corporate secrets through dream-sharing technology.",
posterUrl: "https://image.tmdb.org/t/p/w500/poster.jpg", posterUrl: "https://image.tmdb.org/t/p/w500/poster.jpg",
backdropUrl: null, backdropUrl: null,
genres: ["Action", "Science Fiction"], genres: ["Action", "Science Fiction"],
@ -52,7 +53,8 @@ describe("formatSearchResults", () => {
sizeBytes: "2147483648", sizeBytes: "2147483648",
seeders: 847, seeders: 847,
leechers: 23, leechers: 23,
magnetUrl: "magnet:?xt=urn:btih:aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e", magnetUrl:
"magnet:?xt=urn:btih:aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
source: "yts", source: "yts",
qualityScore: 85, qualityScore: 85,
uploadedAt: "2024-03-15T12:00:00Z", uploadedAt: "2024-03-15T12:00:00Z",
@ -561,7 +563,12 @@ describe("formatSearchResults", () => {
describe("formatPopularResults", () => { describe("formatPopularResults", () => {
it("formats empty results", () => { it("formats empty results", () => {
const response: PopularResponse = { items: [], total: 0, page: 1, pageSize: 10 }; const response: PopularResponse = {
items: [],
total: 0,
page: 1,
pageSize: 10,
};
expect(formatPopularResults(response)).toContain("No popular content"); expect(formatPopularResults(response)).toContain("No popular content");
}); });
@ -593,7 +600,12 @@ describe("formatPopularResults", () => {
describe("formatRecentResults", () => { describe("formatRecentResults", () => {
it("formats empty results", () => { it("formats empty results", () => {
const response: RecentResponse = { items: [], total: 0, page: 1, pageSize: 10 }; const response: RecentResponse = {
items: [],
total: 0,
page: 1,
pageSize: 10,
};
expect(formatRecentResults(response)).toContain("No recent content"); expect(formatRecentResults(response)).toContain("No recent content");
}); });

View file

@ -9,14 +9,38 @@ describe("formatWatchProviders", () => {
country: "ES", country: "ES",
providers: { providers: {
flatrate: [ flatrate: [
{ providerId: 8, name: "Netflix", logo: null, link: null, displayPriority: 1 }, {
{ providerId: 337, name: "Disney+", logo: null, link: null, displayPriority: 2 }, providerId: 8,
name: "Netflix",
logo: null,
link: null,
displayPriority: 1,
},
{
providerId: 337,
name: "Disney+",
logo: null,
link: null,
displayPriority: 2,
},
], ],
rent: [ rent: [
{ providerId: 2, name: "Apple TV", logo: null, link: null, displayPriority: 1 }, {
providerId: 2,
name: "Apple TV",
logo: null,
link: null,
displayPriority: 1,
},
], ],
buy: [ buy: [
{ providerId: 3, name: "Google Play", logo: null, link: null, displayPriority: 1 }, {
providerId: 3,
name: "Google Play",
logo: null,
link: null,
displayPriority: 1,
},
], ],
free: [], free: [],
}, },
@ -66,8 +90,20 @@ describe("formatWatchProviders", () => {
country: "GB", country: "GB",
providers: { providers: {
flatrate: [ flatrate: [
{ providerId: 2, name: "Second", logo: null, link: null, displayPriority: 20 }, {
{ providerId: 1, name: "First", logo: null, link: null, displayPriority: 1 }, providerId: 2,
name: "Second",
logo: null,
link: null,
displayPriority: 20,
},
{
providerId: 1,
name: "First",
logo: null,
link: null,
displayPriority: 1,
},
], ],
rent: [], rent: [],
buy: [], buy: [],
@ -89,7 +125,13 @@ describe("formatWatchProviders", () => {
rent: [], rent: [],
buy: [], buy: [],
free: [ free: [
{ providerId: 100, name: "Tubi", logo: null, link: null, displayPriority: 1 }, {
providerId: 100,
name: "Tubi",
logo: null,
link: null,
displayPriority: 1,
},
], ],
}, },
attribution: "JustWatch", attribution: "JustWatch",

View file

@ -2,9 +2,9 @@ import { describe, it, expect, vi } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerPrompts } from "../src/prompts.js"; import { registerPrompts } from "../src/prompts.js";
type PromptHandler = ( type PromptHandler = (params: Record<string, string>) => {
params: Record<string, string>, messages: { role: string; content: { type: string; text: string } }[];
) => { messages: { role: string; content: { type: string; text: string } }[] }; };
describe("registerPrompts", () => { describe("registerPrompts", () => {
function createMockServer() { function createMockServer() {

View file

@ -55,7 +55,9 @@ describe("get_credits tool", () => {
it("returns isError on ApiError", async () => { it("returns isError on ApiError", async () => {
const client = createMockClient({ const client = createMockClient({
getCredits: vi.fn().mockRejectedValue(new ApiError(503, "TMDB unavailable")), getCredits: vi
.fn()
.mockRejectedValue(new ApiError(503, "TMDB unavailable")),
}); });
const { server, getToolHandler } = createMockServer(); const { server, getToolHandler } = createMockServer();
registerGetCredits(server, client); registerGetCredits(server, client);

View file

@ -14,8 +14,7 @@ function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
getTorrentDownloadUrl: vi getTorrentDownloadUrl: vi
.fn() .fn()
.mockImplementation( .mockImplementation(
(hash: string) => (hash: string) => `https://torrentclaw.com/api/v1/torrent/${hash}`,
`https://torrentclaw.com/api/v1/torrent/${hash}`,
), ),
...overrides, ...overrides,
} as unknown as TorrentClawClient; } as unknown as TorrentClawClient;

View file

@ -24,7 +24,13 @@ describe("get_watch_providers tool", () => {
country: "ES", country: "ES",
providers: { providers: {
flatrate: [ flatrate: [
{ providerId: 8, name: "Netflix", logo: null, link: null, displayPriority: 1 }, {
providerId: 8,
name: "Netflix",
logo: null,
link: null,
displayPriority: 1,
},
], ],
rent: [], rent: [],
buy: [], buy: [],
@ -64,7 +70,9 @@ describe("get_watch_providers tool", () => {
it("returns isError on ApiError", async () => { it("returns isError on ApiError", async () => {
const client = createMockClient({ const client = createMockClient({
getWatchProviders: vi.fn().mockRejectedValue(new ApiError(404, "Not found")), getWatchProviders: vi
.fn()
.mockRejectedValue(new ApiError(404, "Not found")),
}); });
const { server, getToolHandler } = createMockServer(); const { server, getToolHandler } = createMockServer();
registerGetWatchProviders(server, client); registerGetWatchProviders(server, client);
@ -78,7 +86,9 @@ describe("get_watch_providers tool", () => {
it("returns isError on generic error", async () => { it("returns isError on generic error", async () => {
const client = createMockClient({ const client = createMockClient({
getWatchProviders: vi.fn().mockRejectedValue(new Error("Connection refused")), getWatchProviders: vi
.fn()
.mockRejectedValue(new Error("Connection refused")),
}); });
const { server, getToolHandler } = createMockServer(); const { server, getToolHandler } = createMockServer();
registerGetWatchProviders(server, client); registerGetWatchProviders(server, client);