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
| Tool | Description |
|------|-------------|
| `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_recent` | Get recently added content |
| `get_watch_providers` | Streaming availability by country (Netflix, Disney+, etc.) |
| `get_credits` | Cast and director for a title |
| `get_torrent_url` | Get .torrent file download URL from info hash |
| Tool | Description |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `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_recent` | Get recently added content |
| `get_watch_providers` | Streaming availability by country (Netflix, Disney+, etc.) |
| `get_credits` | Cast and director for a title |
| `get_torrent_url` | Get .torrent file download URL from info hash |
## Resources
| URI | Description |
|-----|-------------|
| URI | Description |
| --------------------- | ----------------------------------------------------- |
| `torrentclaw://stats` | Catalog statistics (content/torrent counts by source) |
## Prompts
| Prompt | Description |
|--------|-------------|
| `search_movie` | Search for a movie by title and get torrents + streaming |
| `search_show` | Search for a TV show by title and get torrents |
| `whats_new` | Discover recently added movies and TV shows |
| `where_to_watch` | Find where to stream, rent, or buy a title |
| Prompt | Description |
| ---------------- | -------------------------------------------------------- |
| `search_movie` | Search for a movie by title and get torrents + streaming |
| `search_show` | Search for a TV show by title and get torrents |
| `whats_new` | Discover recently added movies and TV shows |
| `where_to_watch` | Find where to stream, rent, or buy a title |
## Configuration
@ -89,10 +89,10 @@ Point to your own TorrentClaw instance:
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `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) |
| Variable | Default | Description |
| --------------------------- | ------------------------- | ---------------------------------------------------------------------- |
| `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) |
## Development

17
package-lock.json generated
View file

@ -18,6 +18,7 @@
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^4.0.18",
"prettier": "^3.8.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^4.0.0"
@ -2231,6 +2232,22 @@
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",

View file

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

View file

@ -91,9 +91,7 @@ function formatResult(
`Stream: ${r.streaming.flatrate.map((p) => p.name).join(", ")}`,
);
if (r.streaming.free.length > 0)
providers.push(
`Free: ${r.streaming.free.map((p) => p.name).join(", ")}`,
);
providers.push(`Free: ${r.streaming.free.map((p) => p.name).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 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));
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 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));
return [header, hint, "", ...items].join("\n");
}

View file

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

View file

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

View file

@ -28,10 +28,7 @@ export function registerGetRecent(
},
async (params) => {
try {
const data = await client.getRecent(
params.limit ?? 10,
params.page,
);
const data = await client.getRecent(params.limit ?? 10, params.page);
return {
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",
)
.default("US")
.describe(
"ISO 3166-1 country code (e.g. US, ES, GB, DE). Default: US",
),
.describe("ISO 3166-1 country code (e.g. US, ES, GB, DE). Default: US"),
},
async (params) => {
try {

View file

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

View file

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

View file

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

View file

@ -36,7 +36,8 @@ describe("formatSearchResults", () => {
title: "Inception",
titleOriginal: "Inception",
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",
backdropUrl: null,
genres: ["Action", "Science Fiction"],
@ -52,7 +53,8 @@ describe("formatSearchResults", () => {
sizeBytes: "2147483648",
seeders: 847,
leechers: 23,
magnetUrl: "magnet:?xt=urn:btih:aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
magnetUrl:
"magnet:?xt=urn:btih:aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
source: "yts",
qualityScore: 85,
uploadedAt: "2024-03-15T12:00:00Z",
@ -561,7 +563,12 @@ describe("formatSearchResults", () => {
describe("formatPopularResults", () => {
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");
});
@ -593,7 +600,12 @@ describe("formatPopularResults", () => {
describe("formatRecentResults", () => {
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");
});

View file

@ -9,14 +9,38 @@ describe("formatWatchProviders", () => {
country: "ES",
providers: {
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: [
{ providerId: 2, name: "Apple TV", logo: null, link: null, displayPriority: 1 },
{
providerId: 2,
name: "Apple TV",
logo: null,
link: null,
displayPriority: 1,
},
],
buy: [
{ providerId: 3, name: "Google Play", logo: null, link: null, displayPriority: 1 },
{
providerId: 3,
name: "Google Play",
logo: null,
link: null,
displayPriority: 1,
},
],
free: [],
},
@ -66,8 +90,20 @@ describe("formatWatchProviders", () => {
country: "GB",
providers: {
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: [],
buy: [],
@ -89,7 +125,13 @@ describe("formatWatchProviders", () => {
rent: [],
buy: [],
free: [
{ providerId: 100, name: "Tubi", logo: null, link: null, displayPriority: 1 },
{
providerId: 100,
name: "Tubi",
logo: null,
link: null,
displayPriority: 1,
},
],
},
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 { registerPrompts } from "../src/prompts.js";
type PromptHandler = (
params: Record<string, string>,
) => { messages: { role: string; content: { type: string; text: string } }[] };
type PromptHandler = (params: Record<string, string>) => {
messages: { role: string; content: { type: string; text: string } }[];
};
describe("registerPrompts", () => {
function createMockServer() {

View file

@ -55,7 +55,9 @@ describe("get_credits tool", () => {
it("returns isError on ApiError", async () => {
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();
registerGetCredits(server, client);

View file

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

View file

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