diff --git a/.github/workflows/ci.yml b/.forgejo/workflows/ci.yml similarity index 68% rename from .github/workflows/ci.yml rename to .forgejo/workflows/ci.yml index c764d29..c1f6f9e 100644 --- a/.github/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -12,33 +12,33 @@ permissions: jobs: lint-commits: name: Lint commits - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/node:22 if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: "22" - - name: Install dependencies run: npm ci - name: Validate conventional commits - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + run: | + npx commitlint \ + --from ${{ github.event.pull_request.base.sha }} \ + --to ${{ github.event.pull_request.head.sha }} \ + --verbose build-and-test: name: Build & test - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/node:22 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "22" - - name: Install dependencies run: npm ci diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..79737aa --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: docker + container: + image: docker.io/library/node:22 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + # Set authToken explicitly — actions/setup-node was the only consumer + # of registry-url + NODE_AUTH_TOKEN, and we dropped it for Forgejo + # compat. The literal npmjs registry stays the same. + cat > ~/.npmrc </dev/null || echo "") + if [ -n "$prev" ]; then + notes=$(git log --pretty=format:'- %s' "${prev}..${TAG}") + else + notes=$(git log --pretty=format:'- %s' "${TAG}") + fi + body=$(jq -n --arg t "$TAG" --arg n "$notes" \ + '{tag_name:$t, name:$t, body:$n, draft:false, prerelease:false}') + curl -sSf -X POST "$FORGEJO_API/repos/$REPO/releases" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$body" >/dev/null || \ + echo "Release may already exist for $TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 8a489af..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - registry-url: "https://registry.npmjs.org" - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Test - run: npm test - - - name: Publish to npm - run: npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: GitHub Release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e758a9..6b33366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [0.2.2](https://github.com/torrentclaw/torrentclaw-mcp/compare/v0.2.1...v0.2.2) (2026-02-16) + +### Features + +- add glama.json and smithery.yaml for directory listings ([06865a2](https://github.com/torrentclaw/torrentclaw-mcp/commit/06865a2abda420567af7fb3d5046b29ea7de6060)) + +### Bug Fixes + +- update qs to 6.15.0 to resolve CVE denial of service vulnerability ([657910a](https://github.com/torrentclaw/torrentclaw-mcp/commit/657910ad357f14f651d1af1afec0c0cda64513f5)) + +## [0.2.1](https://github.com/torrentclaw/torrentclaw-mcp/compare/v0.2.0...v0.2.1) (2026-02-12) + +## [0.2.0](https://github.com/torrentclaw/torrentclaw-mcp/compare/v0.1.0...v0.2.0) (2026-02-12) + +### Features + +- **mcp:** add season filtering and presentation guide for better UX ([7844b83](https://github.com/torrentclaw/torrentclaw-mcp/commit/7844b83eb3f49323e7b64ca4c4e09868bbce2dd9)) + ## 0.1.0 (2026-02-12) ### Features diff --git a/LICENSE b/LICENSE index 9d044e4..ee27d08 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 buryni +Copyright (c) 2026 Deivid Soto Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..2b7ca25 --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["torrentclaw", "eividsoto"] +} diff --git a/package-lock.json b/package-lock.json index e08ba81..fdc30fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "torrentclaw-mcp", - "version": "0.1.0", + "name": "@torrentclaw/mcp", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "torrentclaw-mcp", - "version": "0.1.0", + "name": "@torrentclaw/mcp", + "version": "0.2.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -5003,9 +5003,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index 5f7a310..fdece01 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "torrentclaw-mcp", - "version": "0.1.0", + "name": "@torrentclaw/mcp", + "version": "0.2.2", "description": "MCP server for TorrentClaw — search and discover movies and TV shows with torrent downloads, magnet links, streaming availability, and cast/crew metadata", "type": "module", "bin": { - "torrentclaw-mcp": "./build/index.js" + "torrentclaw-mcp": "build/index.js" }, "main": "./build/index.js", "files": [ @@ -48,7 +48,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/torrentclaw/torrentclaw-mcp" + "url": "git+https://github.com/torrentclaw/torrentclaw-mcp.git" }, "homepage": "https://github.com/torrentclaw/torrentclaw-mcp#readme", "bugs": { diff --git a/scripts/test-season-filtering.ts b/scripts/test-season-filtering.ts new file mode 100755 index 0000000..43a644c --- /dev/null +++ b/scripts/test-season-filtering.ts @@ -0,0 +1,248 @@ +#!/usr/bin/env tsx +/** + * Script de prueba para verificar el filtrado por temporada/episodio + * + * Uso: + * npx tsx scripts/test-season-filtering.ts + */ + +import { formatSearchResults } from "../src/formatters/content.js"; +import type { SearchResponse, TorrentInfo } from "../src/types.js"; + +// Colores para la consola +const colors = { + reset: "\x1b[0m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", +}; + +function log(color: keyof typeof colors, message: string) { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function header(message: string) { + console.log("\n" + "=".repeat(80)); + log("cyan", message); + console.log("=".repeat(80)); +} + +function createTorrent( + season: number | null, + episode: number | null, + quality: string, + score: number, + seeders: number, +): TorrentInfo { + return { + infoHash: "a".repeat(40), + rawTitle: null, + quality, + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "1073741824", + seeders, + leechers: 0, + magnetUrl: `magnet:?xt=urn:btih:${"a".repeat(40)}`, + torrentUrl: null, + source: "test", + qualityScore: score, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season, + episode, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }; +} + +function createResponse(torrents: TorrentInfo[]): SearchResponse { + return { + total: 1, + page: 1, + pageSize: 10, + results: [ + { + id: 1, + imdbId: "tt1234567", + tmdbId: "12345", + contentType: "show", + title: "Test Show", + titleOriginal: null, + year: 2024, + overview: + "Una serie de prueba para verificar el filtrado por temporada", + posterUrl: null, + backdropUrl: null, + genres: ["Drama", "Action"], + ratingImdb: "8.5", + ratingTmdb: "8.2", + contentUrl: "https://torrentclaw.com/shows/test-show-1", + hasTorrents: true, + torrents, + }, + ], + }; +} + +// Test 1: Caso original del bug - Temporada 4 con mejores torrents en temporadas anteriores +header("TEST 1: Búsqueda de temporada específica (caso del bug original)"); +log( + "yellow", + "Escenario: Serie con temporadas 1-4, donde T1-T3 tienen mejor calidad que T4", +); +log("yellow", "Búsqueda: season=4"); + +const test1Torrents = [ + createTorrent(1, null, "1080p", 90, 100), // Mejor score, pero T1 + createTorrent(2, null, "1080p", 85, 80), // Segundo mejor, pero T2 + createTorrent(3, null, "1080p", 80, 60), // Tercero, pero T3 + createTorrent(4, null, "720p", 70, 40), // T4 - score bajo + createTorrent(4, null, "1080p", 75, 30), // T4 - score medio +]; + +const test1Response = createResponse(test1Torrents); + +console.log("\n📌 SIN filtro (comportamiento anterior):"); +console.log(formatSearchResults(test1Response)); + +console.log("\n✅ CON filtro season=4 (comportamiento corregido):"); +console.log(formatSearchResults(test1Response, { season: 4 })); + +log("green", "✓ Debe mostrar SOLO los 2 torrents de temporada 4"); +log("green", '✓ Debe indicar "2 matching, 5 total"'); + +// Test 2: Filtrado por episodio específico +header("TEST 2: Búsqueda de episodio específico"); +log("yellow", "Escenario: Múltiples episodios de la misma temporada"); +log("yellow", "Búsqueda: season=2, episode=5"); + +const test2Torrents = [ + createTorrent(2, 3, "1080p", 90, 100), + createTorrent(2, 5, "1080p", 85, 80), // Episodio buscado + createTorrent(2, 5, "720p", 70, 60), // Episodio buscado + createTorrent(2, 7, "1080p", 88, 90), +]; + +const test2Response = createResponse(test2Torrents); + +console.log("\n✅ CON filtro season=2, episode=5:"); +console.log(formatSearchResults(test2Response, { season: 2, episode: 5 })); + +log("green", "✓ Debe mostrar SOLO los 2 torrents de S02E05"); +log("green", '✓ Debe indicar "2 matching, 4 total"'); + +// Test 3: Temporada no disponible +header("TEST 3: Temporada no disponible"); +log("yellow", "Escenario: Buscar temporada que no existe"); +log("yellow", "Búsqueda: season=10"); + +const test3Torrents = [ + createTorrent(1, null, "1080p", 90, 100), + createTorrent(2, null, "1080p", 85, 80), + createTorrent(3, null, "720p", 75, 60), +]; + +const test3Response = createResponse(test3Torrents); + +console.log("\n✅ CON filtro season=10 (no existe):"); +console.log(formatSearchResults(test3Response, { season: 10 })); + +log("green", '✓ Debe mostrar mensaje "No torrents available for season 10"'); +log("green", '✓ Debe indicar "3 torrents available for other seasons"'); + +// Test 4: Packs de temporada vs episodios individuales +header("TEST 4: Packs de temporada completa vs episodios individuales"); +log("yellow", "Escenario: Mezcla de packs completos y episodios individuales"); +log("yellow", "Búsqueda: season=1, episode=5"); + +const test4Torrents = [ + createTorrent(1, null, "1080p", 95, 100), // Pack completo T1 + createTorrent(1, 1, "1080p", 90, 80), // Episodio 1 + createTorrent(1, 5, "1080p", 85, 70), // Episodio 5 (buscado) + createTorrent(1, 5, "720p", 75, 60), // Episodio 5 (buscado) + createTorrent(1, 10, "1080p", 88, 65), // Episodio 10 +]; + +const test4Response = createResponse(test4Torrents); + +console.log("\n📌 CON filtro season=1 (solo temporada):"); +console.log(formatSearchResults(test4Response, { season: 1 })); + +log("green", "✓ Debe mostrar todos los torrents de T1 (5 torrents)"); + +console.log("\n✅ CON filtro season=1, episode=5 (específico):"); +console.log(formatSearchResults(test4Response, { season: 1, episode: 5 })); + +log("green", "✓ Debe mostrar SOLO episodios S01E05 (2 torrents)"); +log("green", "✓ NO debe mostrar el pack completo"); + +// Test 5: Más de 5 torrents de la misma temporada +header("TEST 5: Más de 5 torrents de la misma temporada"); +log("yellow", "Escenario: 8 torrents disponibles de la temporada 3"); +log("yellow", "Búsqueda: season=3"); + +const test5Torrents = [ + createTorrent(3, null, "2160p", 100, 150), + createTorrent(3, null, "1080p", 95, 140), + createTorrent(3, null, "1080p", 90, 130), + createTorrent(3, null, "720p", 85, 120), + createTorrent(3, null, "1080p", 80, 110), + createTorrent(3, null, "720p", 75, 100), + createTorrent(3, null, "480p", 70, 90), + createTorrent(3, null, "720p", 65, 80), +]; + +const test5Response = createResponse(test5Torrents); + +console.log("\n✅ CON filtro season=3:"); +console.log(formatSearchResults(test5Response, { season: 3 })); + +log("green", "✓ Debe mostrar máximo 5 torrents (los de mejor score)"); +log("green", '✓ Debe indicar "8 matching, 8 total, top 5"'); +log("green", "✓ Primer torrent debe ser 2160p (score: 100)"); + +// Test 6: Sin filtro (comportamiento original debe mantenerse) +header("TEST 6: Sin filtro de temporada (regresión)"); +log("yellow", "Escenario: Búsqueda sin especificar temporada"); +log("yellow", "Búsqueda: sin season ni episode"); + +const test6Torrents = [ + createTorrent(1, null, "1080p", 70, 50), + createTorrent(2, null, "1080p", 85, 80), + createTorrent(3, null, "2160p", 95, 100), + createTorrent(4, null, "720p", 60, 40), +]; + +const test6Response = createResponse(test6Torrents); + +console.log("\n✅ SIN filtro:"); +console.log(formatSearchResults(test6Response)); + +log("green", "✓ Debe mostrar top 4 torrents ordenados por score"); +log("green", "✓ Primero: T3 2160p (score: 95)"); +log("green", "✓ Último: T4 720p (score: 60)"); +log("green", '✓ Debe indicar "4 total, top 4"'); + +// Resumen +header("RESUMEN DE PRUEBAS"); +log("cyan", "Todos los tests manuales ejecutados."); +log( + "cyan", + "Verifica que los resultados coincidan con las expectativas marcadas con ✓", +); +console.log("\nPara ejecutar tests automatizados:"); +log("blue", " npm test -- tests/formatters/content.test.ts"); +console.log("\nPara ver cobertura completa:"); +log("blue", " npm test"); +console.log(); diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 0000000..322550e --- /dev/null +++ b/smithery.yaml @@ -0,0 +1 @@ +runtime: "typescript" diff --git a/src/formatters/content.ts b/src/formatters/content.ts index 2339259..1b42c74 100644 --- a/src/formatters/content.ts +++ b/src/formatters/content.ts @@ -83,6 +83,8 @@ function formatTorrent(t: TorrentInfo, compact?: boolean): string { export interface FormatOptions { compact?: boolean; + season?: number; + episode?: number; } function formatResult( @@ -102,12 +104,39 @@ function formatResult( } if (r.torrents.length > 0) { - const top = r.torrents - .sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0)) - .slice(0, 5); - lines.push(` Torrents (${r.torrents.length} total, top ${top.length}):`); - for (const t of top) { - lines.push(formatTorrent(t, opts?.compact)); + // Filter torrents by season/episode if specified + let filteredTorrents = r.torrents; + if (opts?.season !== undefined) { + filteredTorrents = filteredTorrents.filter( + (t) => t.season === opts.season, + ); + if (opts?.episode !== undefined) { + filteredTorrents = filteredTorrents.filter( + (t) => t.episode === opts.episode, + ); + } + } + + if (filteredTorrents.length > 0) { + const top = filteredTorrents + .sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0)) + .slice(0, 5); + const totalMsg = + filteredTorrents.length !== r.torrents.length + ? `${filteredTorrents.length} matching, ${r.torrents.length} total` + : `${r.torrents.length} total`; + lines.push(` Torrents (${totalMsg}, top ${top.length}):`); + for (const t of top) { + lines.push(formatTorrent(t, opts?.compact)); + } + } else { + const seasonEpStr = + opts?.episode !== undefined + ? `S${String(opts.season).padStart(2, "0")}E${String(opts.episode).padStart(2, "0")}` + : `season ${opts?.season}`; + lines.push( + ` No torrents available for ${seasonEpStr} (${r.torrents.length} torrents available for other seasons)`, + ); } } else { lines.push(" No torrents available"); diff --git a/src/index.ts b/src/index.ts index 7353a14..199899b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ 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 { registerPresentationGuideResource } from "./resources/presentation-guide.js"; import { registerPrompts } from "./prompts.js"; const client = new TorrentClawClient(); @@ -37,6 +38,7 @@ registerScanRequest(server, client); // Register resources registerStatsResource(server, client); +registerPresentationGuideResource(server); // Register prompts registerPrompts(server); diff --git a/src/prompts.ts b/src/prompts.ts index f6321fb..6cd4694 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -2,6 +2,57 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; export function registerPrompts(server: McpServer): void { + server.prompt( + "presentation_guide", + "Guide for presenting torrent search results in a user-friendly format", + {}, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `When presenting torrent search results to users, follow these best practices: + +1. **Magnet Links**: Always make magnet links clickable using markdown format: + - Format: [📥 Download](magnet:?xt=urn:btih:HASH...) + - Or: [🧲 Magnet Link](magnet:?xt=urn:btih:HASH...) + - Never show raw magnet URIs without making them clickable + +2. **Content URL**: Include the TorrentClaw content URL for browsing all seasons/episodes: + - Format: [🔗 View all seasons on TorrentClaw](https://torrentclaw.com/shows/...) + - This allows users to explore other seasons/episodes + +3. **Presentation Format**: Use clear, readable formatting: + - Group by episode/season for TV shows + - Show quality, size, and seeder count prominently + - Highlight torrents with active seeders + - Warn if torrents have 0 seeders + +4. **Example Format for TV Shows**: + **Entrevías - Temporada 4** + + **Episodio 1** (S04E01) + - 720p HDTV • 879 MB • 6 seeders [📥 Download](magnet:?xt=...) + + **Episodio 2** (S04E02) + - 1080p WEB-DL • 2.5 GB • 0 seeders ⚠️ [📥 Download](magnet:?xt=...) + - 720p HDTV • 976 MB • 1 seeder [📥 Download](magnet:?xt=...) + + [🔗 View all seasons on TorrentClaw](URL) + +5. **Helpful Information**: + - Recommend torrents with more seeders + - Suggest alternatives if requested season/episode has no seeders + - Offer to search for different quality if user wants + +Apply these practices to make results actionable and user-friendly.`, + }, + }, + ], + }), + ); + server.prompt( "search_movie", "Search for a movie by title and get torrent download options", @@ -12,7 +63,7 @@ export function registerPrompts(server: McpServer): void { role: "user", content: { type: "text", - text: `Search for the movie "${title}" using search_content with type="movie". Present the results showing: title, year, ratings, and the top torrents sorted by quality score with their magnet links. If results are found, also call get_watch_providers with the content_id to check streaming availability.`, + text: `Search for the movie "${title}" using search_content with type="movie". Present the results with clickable magnet links using markdown format [📥 Download](magnet:...), include the content URL for more details, and show quality/size/seeders clearly. If results are found, also call get_watch_providers with the content_id to check streaming availability.`, }, }, ], @@ -22,14 +73,23 @@ export function registerPrompts(server: McpServer): void { server.prompt( "search_show", "Search for a TV show by title and get torrent download options", - { title: z.string().describe("TV show title to search for") }, - ({ title }) => ({ + { + title: z.string().describe("TV show title to search for"), + season: z.number().optional().describe("Specific season number"), + }, + ({ title, season }) => ({ messages: [ { role: "user", content: { type: "text", - text: `Search for the TV show "${title}" using search_content with type="show". Present the results showing: title, year, ratings, and the top torrents sorted by quality score with their magnet links.`, + text: `Search for the TV show "${title}" using search_content with type="show"${season ? ` and season=${season}` : ""}. Present results grouped by episode with: +- Episode identifier (e.g., S04E01) +- Quality, size, and seeder count +- Clickable magnet links using markdown: [📥 Download](magnet:...) +- Content URL for browsing all seasons: [🔗 View all seasons](URL) +- Recommendations for torrents with most seeders +- Warnings if torrents have 0 seeders`, }, }, ], diff --git a/src/resources/presentation-guide.ts b/src/resources/presentation-guide.ts new file mode 100644 index 0000000..f109909 --- /dev/null +++ b/src/resources/presentation-guide.ts @@ -0,0 +1,137 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export function registerPresentationGuideResource(server: McpServer): void { + server.resource( + "presentation-guide", + "torrentclaw://presentation-guide", + { + description: + "Best practices for presenting torrent search results to users in a user-friendly, actionable format", + mimeType: "text/markdown", + }, + async (uri) => { + const guide = `# TorrentClaw Results Presentation Guide + +## Critical Requirements + +When presenting torrent search results to users, you MUST follow these practices to make results actionable and user-friendly: + +### 1. Clickable Magnet Links + +**ALWAYS** make magnet links clickable using markdown format: + +✅ **CORRECT**: +- [📥 Download](magnet:?xt=urn:btih:41159dc60579839533e04796df0e96bfa4864cb4&...) +- [🧲 Magnet](magnet:?xt=urn:btih:41159dc60579839533e04796df0e96bfa4864cb4&...) + +❌ **INCORRECT**: +- magnet:?xt=urn:btih:41159dc60579839533e04796df0e96bfa4864cb4 (not clickable) +- Showing only the info hash without full magnet URI + +### 2. Content URL for Browsing + +**ALWAYS** include the TorrentClaw content URL so users can explore all seasons/episodes: + +✅ **CORRECT**: +[🔗 View all seasons and episodes on TorrentClaw](https://torrentclaw.com/shows/entrevias-2022-91260) + +This allows users to: +- Browse all available seasons +- See all torrents for each episode +- Explore different quality options + +### 3. User-Friendly Presentation Format + +**For TV Shows** (especially when searching by season): + +\`\`\`markdown +### Entrevías - Temporada 4 + +**Episodio 1** (S04E01) +- 720p HDTV • 879 MB • 6 seeders • [📥 Download](magnet:?xt=urn:btih:...) + +**Episodio 2** (S04E02) +- 1080p WEB-DL • 2.5 GB • 0 seeders ⚠️ No active seeders • [📥 Download](magnet:?xt=urn:btih:...) +- 720p HDTV • 976 MB • 1 seeder • [📥 Download](magnet:?xt=urn:btih:...) + +**Episodio 3** (S04E03) +- 720p HDTV • 795 MB • 2 seeders • [📥 Download](magnet:?xt=urn:btih:...) + +[🔗 View all seasons on TorrentClaw](https://torrentclaw.com/shows/...) +\`\`\` + +**For Movies**: + +\`\`\`markdown +### Inception (2010) +IMDb: 8.8 | TMDB: 8.4 + +**Available Torrents:** + +1. **2160p BluRay** • 15.2 GB • 147 seeders • [📥 Download](magnet:?xt=...) +2. **1080p BluRay** • 2.0 GB • 847 seeders ⭐ Recommended • [📥 Download](magnet:?xt=...) +3. **720p WEB-DL** • 1.2 GB • 234 seeders • [📥 Download](magnet:?xt=...) + +[🔗 View on TorrentClaw](https://torrentclaw.com/movies/...) +\`\`\` + +### 4. Helpful User Guidance + +Provide context and recommendations: + +- ✅ Recommend torrents with most seeders +- ✅ Warn when torrents have 0 seeders: "⚠️ No active seeders" +- ✅ Mark best option: "⭐ Recommended" (based on seeders + quality) +- ✅ Suggest alternatives if requested season has no seeders +- ✅ Offer to search different quality/season + +### 5. What NOT to Do + +❌ **Never** present results in plain text tables without clickable links +❌ **Never** show truncated magnet links +❌ **Never** omit the content URL +❌ **Never** show info hashes without the full magnet URI +❌ **Never** present results without indicating seeder count + +### 6. Example of Good vs Bad Presentation + +**❌ BAD** (what user reported as not practical): +\`\`\` +┌──────────┬───────────┬────────┬───────────┬──────────────────────────────────┐ +│ Episodio │ Calidad │ Tamaño │ Seeders │ Magnet │ +├──────────┼───────────┼────────┼───────────┼──────────────────────────────────┤ +│ S04E01 │ 720p HDTV │ 879 MB │ 6 seeders │ magnet:?xt=urn:btih:41159dc... │ +└──────────┴───────────┴────────┴───────────┴──────────────────────────────────┘ +\`\`\` + +**✅ GOOD**: +\`\`\`markdown +**S04E01** +720p HDTV • 879 MB • 6 seeders • [📥 Download](magnet:?xt=urn:btih:41159dc60579839533e04796df0e96bfa4864cb4&...) + +[🔗 View all episodes on TorrentClaw](https://torrentclaw.com/shows/entrevias-2022-91260) +\`\`\` + +## Summary + +The key is to make results **actionable**: users should be able to: +1. Click magnet links to start downloading immediately +2. Click content URL to explore more options +3. Quickly identify which torrents are best (seeders) +4. Understand warnings (no seeders) + +**Remember**: You're not just displaying data, you're helping users take action. +`; + + return { + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: guide, + }, + ], + }; + }, + ); +} diff --git a/src/tools/search-content.ts b/src/tools/search-content.ts index d1772f2..d3d6a28 100644 --- a/src/tools/search-content.ts +++ b/src/tools/search-content.ts @@ -10,7 +10,7 @@ export function registerSearchContent( ): void { server.tool( "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. For TV shows, you can filter by season/episode. Season/episode can also be auto-detected from the query (e.g. 'Bluey s01e05').", + "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'). IMPORTANT: When presenting results to users, make magnet links clickable using markdown format [Download](magnet:?xt=...), include the contentUrl for browsing all seasons/episodes, and present the information in a user-friendly format rather than raw tables.", { query: z .string() @@ -176,7 +176,11 @@ export function registerSearchContent( content: [ { type: "text", - text: formatSearchResults(data, { compact: params.compact }), + text: formatSearchResults(data, { + compact: params.compact, + season: params.season, + episode: params.episode, + }), }, ], }; diff --git a/tests/formatters/content.test.ts b/tests/formatters/content.test.ts index 4206386..cf8ca08 100644 --- a/tests/formatters/content.test.ts +++ b/tests/formatters/content.test.ts @@ -745,9 +745,27 @@ describe("formatSearchResults", () => { 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 }, + { + 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, @@ -809,7 +827,13 @@ describe("formatSearchResults", () => { season: null, episode: null, audioTracks: [ - { lang: null, codec: null, channels: null, title: null, default: null }, + { + lang: null, + codec: null, + channels: null, + title: null, + default: null, + }, ], subtitleTracks: null, videoInfo: null, @@ -1021,6 +1045,346 @@ describe("formatSearchResults", () => { expect(text).toContain("Stream: Netflix, Disney+"); expect(text).toContain("Free: Tubi"); }); + + it("filters torrents by season when specified", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + 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: true, + torrents: [ + { + infoHash: "a".repeat(40), + rawTitle: null, + quality: "1080p", + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "1073741824", + seeders: 100, + leechers: 5, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: 90, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: 1, + episode: null, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + { + infoHash: "b".repeat(40), + rawTitle: null, + quality: "720p", + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "536870912", + 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: 4, + episode: null, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + { + infoHash: "c".repeat(40), + rawTitle: null, + quality: "1080p", + codec: "x265", + sourceType: "WEB-DL", + sizeBytes: "805306368", + seeders: 75, + leechers: 3, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: 85, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: 4, + episode: null, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + + // Filter by season 4 + const text = formatSearchResults(response, { season: 4 }); + expect(text).toContain("2 matching, 3 total"); + // Should show season 4 torrents (quality scores 70 and 85) + expect(text).toContain("Score: 85"); + expect(text).toContain("Score: 70"); + // Should NOT show season 1 torrent (quality score 90) + expect(text).not.toContain("Score: 90"); + }); + + it("filters torrents by season and episode when both specified", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + 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: true, + torrents: [ + { + infoHash: "a".repeat(40), + rawTitle: null, + quality: "1080p", + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "1073741824", + seeders: 100, + leechers: 5, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: 90, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: 2, + episode: 5, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + { + infoHash: "b".repeat(40), + rawTitle: null, + quality: "720p", + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "536870912", + 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: 2, + episode: 3, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + + // Filter by season 2, episode 5 + const text = formatSearchResults(response, { season: 2, episode: 5 }); + expect(text).toContain("1 matching, 2 total"); + // Should show only S02E05 torrent + expect(text).toContain("Score: 90"); + expect(text).toContain("S02E05"); + // Should NOT show S02E03 + expect(text).not.toContain("Score: 70"); + }); + + it("shows message when no torrents match the season filter", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + 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: true, + torrents: [ + { + infoHash: "a".repeat(40), + rawTitle: null, + quality: "1080p", + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "1073741824", + seeders: 100, + leechers: 5, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: 90, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: 1, + episode: null, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + + // Filter by season 4 (not available) + const text = formatSearchResults(response, { season: 4 }); + expect(text).toContain("No torrents available for season 4"); + expect(text).toContain("1 torrents available for other seasons"); + }); + + it("shows message when no torrents match the season+episode filter", () => { + const response: SearchResponse = { + total: 1, + page: 1, + pageSize: 10, + 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: true, + torrents: [ + { + infoHash: "a".repeat(40), + rawTitle: null, + quality: "1080p", + codec: "x264", + sourceType: "WEB-DL", + sizeBytes: "1073741824", + seeders: 100, + leechers: 5, + magnetUrl: null, + torrentUrl: null, + source: "test", + qualityScore: 90, + uploadedAt: null, + languages: [], + audioCodec: null, + hdrType: null, + releaseGroup: null, + isProper: null, + isRepack: null, + isRemastered: null, + season: 2, + episode: 5, + audioTracks: null, + subtitleTracks: null, + videoInfo: null, + scanStatus: null, + }, + ], + }, + ], + }; + + // Filter by S04E01 (not available) + const text = formatSearchResults(response, { season: 4, episode: 1 }); + expect(text).toContain("No torrents available for S04E01"); + expect(text).toContain("1 torrents available for other seasons"); + }); }); describe("formatPopularResults", () => { diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index 0d74126..101b5f4 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -26,10 +26,11 @@ describe("registerPrompts", () => { return { server, prompts }; } - it("registers 4 prompts", () => { + it("registers 5 prompts", () => { const { server, prompts } = createMockServer(); registerPrompts(server); - expect(prompts.size).toBe(4); + expect(prompts.size).toBe(5); + expect(prompts.has("presentation_guide")).toBe(true); expect(prompts.has("search_movie")).toBe(true); expect(prompts.has("search_show")).toBe(true); expect(prompts.has("whats_new")).toBe(true); diff --git a/tests/resources/presentation-guide.test.ts b/tests/resources/presentation-guide.test.ts new file mode 100644 index 0000000..060ef55 --- /dev/null +++ b/tests/resources/presentation-guide.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { createMockServer } from "../helpers.js"; +import { registerPresentationGuideResource } from "../../src/resources/presentation-guide.js"; + +describe("presentation-guide resource", () => { + it("returns markdown guide with best practices", async () => { + const { server, getResourceHandler } = createMockServer(); + registerPresentationGuideResource(server); + + const handler = getResourceHandler("torrentclaw://presentation-guide"); + const result = await handler({ href: "torrentclaw://presentation-guide" }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].mimeType).toBe("text/markdown"); + const text = result.contents[0].text; + + // Check for key sections + expect(text).toContain("# TorrentClaw Results Presentation Guide"); + expect(text).toContain("## Critical Requirements"); + expect(text).toContain("### 1. Clickable Magnet Links"); + expect(text).toContain("### 2. Content URL for Browsing"); + expect(text).toContain("### 3. User-Friendly Presentation Format"); + + // Check for markdown examples + expect(text).toContain("[📥 Download](magnet:"); + expect(text).toContain("[🔗 View"); + + // Check for good vs bad examples + expect(text).toContain("❌ BAD"); + expect(text).toContain("✅ GOOD"); + + // Check for warnings about seeders + expect(text).toContain("⚠️ No active seeders"); + expect(text).toContain("⭐ Recommended"); + }); + + it("provides guidance for TV shows", async () => { + const { server, getResourceHandler } = createMockServer(); + registerPresentationGuideResource(server); + + const handler = getResourceHandler("torrentclaw://presentation-guide"); + const result = await handler({ href: "torrentclaw://presentation-guide" }); + + const text = result.contents[0].text; + expect(text).toContain("**For TV Shows**"); + expect(text).toContain("S04E01"); + expect(text).toContain("Entrevías"); + }); + + it("provides guidance for movies", async () => { + const { server, getResourceHandler } = createMockServer(); + registerPresentationGuideResource(server); + + const handler = getResourceHandler("torrentclaw://presentation-guide"); + const result = await handler({ href: "torrentclaw://presentation-guide" }); + + const text = result.contents[0].text; + expect(text).toContain("**For Movies**"); + expect(text).toContain("Inception"); + expect(text).toContain("BluRay"); + }); + + it("warns against bad practices", async () => { + const { server, getResourceHandler } = createMockServer(); + registerPresentationGuideResource(server); + + const handler = getResourceHandler("torrentclaw://presentation-guide"); + const result = await handler({ href: "torrentclaw://presentation-guide" }); + + const text = result.contents[0].text; + + // Check for warnings + expect(text).toContain("### 5. What NOT to Do"); + expect(text).toContain("without clickable links"); + expect(text).toContain("truncated magnet links"); + expect(text).toContain("omit the content URL"); + }); +});