diff --git a/bun.lockb b/bun.lockb index 2853a9b..f04e7de 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a514ea5..a2285ec 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "license": "AGPL-3.0", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "bun run --watch src/index.ts" + "dev": "bun run --watch src/index.ts", + "mcp": "bun run src/mcp.ts" }, "devDependencies": { "@types/bun": "^1.3.1" @@ -15,6 +16,7 @@ }, "dependencies": { "@elysiajs/static": "^1.4.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/crypto-js": "^4.2.2", "@types/html-minifier-next": "^2.1.0", "@types/js-yaml": "^4.0.9", diff --git a/src/index.ts b/src/index.ts index 3f05452..1075c8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import transparency from '@/router/transparency' import video from '@/router/video' import websocket from '@/router/websocket' import html from '@/router/html' +import mcp from '@/router/mcp' const app = new Elysia() app.use(latest) @@ -15,8 +16,9 @@ app.use(transparency) app.use(video) app.use(websocket) app.use(html) -app.onRequest(({ set, url }: any) => { - set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + url.split('/').at(-1) +app.use(mcp) +app.onRequest(({ set, request }: { set: { headers: Record }, request: Request }) => { + set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + request.url.split('/').at(-1) }) process.on('uncaughtException', err => { diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..33d26cc --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,115 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { addToSizeWhitelist, archiveVideo, getVideoMetadata } from '@/utils/archive' + +const server = new Server( + { + name: 'preservetube-mcp', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } +) + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'add_to_size_whitelist', + description: 'Add a YouTube video ID to the size whitelist in preservetube-metadata config.json to bypass max video size limits.', + inputSchema: { + type: 'object', + properties: { + videoId: { + type: 'string', + description: 'YouTube video ID (11 characters) or full YouTube URL' + } + }, + required: ['videoId'] + } + }, + { + name: 'archive_video', + description: 'Archive a YouTube video by ID or URL on PreserveTube.', + inputSchema: { + type: 'object', + properties: { + videoId: { + type: 'string', + description: 'YouTube video ID (11 characters) or full YouTube URL' + } + }, + required: ['videoId'] + } + }, + { + name: 'get_video_metadata', + description: 'Fetch video metadata including title, duration/length, channel, publish date, and preserved database record if archived.', + inputSchema: { + type: 'object', + properties: { + videoId: { + type: 'string', + description: 'YouTube video ID (11 characters) or full YouTube URL' + } + }, + required: ['videoId'] + } + } + ] +})) + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + const inputArg = args && typeof args === 'object' && ('videoId' in args || 'url' in args) + ? String((args as Record).videoId || (args as Record).url || '') + : '' + + if (name === 'add_to_size_whitelist') { + const result = await addToSizeWhitelist(inputArg) + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + if (name === 'archive_video') { + const result = await archiveVideo(inputArg) + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + if (name === 'get_video_metadata') { + const result = await getVideoMetadata(inputArg) + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + throw new Error(`Unknown tool: ${name}`) +}) + +async function run() { + const transport = new StdioServerTransport() + await server.connect(transport) +} + +run().catch(console.error) diff --git a/src/router/mcp.ts b/src/router/mcp.ts new file mode 100644 index 0000000..7b8b9b1 --- /dev/null +++ b/src/router/mcp.ts @@ -0,0 +1,177 @@ +import { Elysia, t } from 'elysia' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { addToSizeWhitelist, archiveVideo, getVideoMetadata } from '@/utils/archive' + +function isAuthorized(headers: Record): boolean { + const authHeader = headers['authorization'] || headers['Authorization'] + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : authHeader + const secret = process.env.MCP_SECRET || process.env.MCP_KEY + + if (!secret || !token) return false + return token === secret +} + +function createMcpServer() { + const server = new Server( + { name: 'preservetube-mcp', version: '1.0.0' }, + { capabilities: { tools: {} } } + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'add_to_size_whitelist', + description: 'Add a YouTube video ID to the size whitelist in preservetube-metadata config.json', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'archive_video', + description: 'Archive a YouTube video on PreserveTube by video ID or URL', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'get_video_metadata', + description: 'Fetch video metadata including title, duration/length, channel, publish date, and preserved database record if archived.', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + } + ] + })) + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params + const inputArg = String((args as Record)?.videoId || (args as Record)?.url || '') + + if (name === 'add_to_size_whitelist') { + const result = await addToSizeWhitelist(inputArg) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + + if (name === 'archive_video') { + const result = await archiveVideo(inputArg) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + + if (name === 'get_video_metadata') { + const result = await getVideoMetadata(inputArg) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + + throw new Error(`Unknown tool: ${name}`) + }) + + return server +} + +async function handleMcpStreamRequest(request: Request): Promise { + const transport = new WebStandardStreamableHTTPServerTransport() + const mcpServer = createMcpServer() + await mcpServer.connect(transport) + return await transport.handleRequest(request) +} + +const app = new Elysia({ prefix: '/api/mcp' }) + +app.onBeforeHandle(({ headers, set }) => { + if (!isAuthorized(headers as Record)) { + set.status = 401 + return { success: false, message: 'Unauthorized: Invalid or missing Bearer authorization token.' } + } +}) + +app.get('/tools', () => { + return { + tools: [ + { + name: 'add_to_size_whitelist', + description: 'Add a YouTube video ID to the size whitelist in preservetube-metadata config.json', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'archive_video', + description: 'Archive a YouTube video on PreserveTube by video ID or URL', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'get_video_metadata', + description: 'Fetch video metadata including title, duration/length, channel, publish date, and preserved database record if archived.', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + } + ] + } +}) + +app.post('/call', async ({ body }: { body: { name: string, arguments?: { videoId?: string, url?: string } } }) => { + const { name, arguments: args } = body + const inputArg = String(args?.videoId || args?.url || '') + + if (name === 'add_to_size_whitelist') { + return await addToSizeWhitelist(inputArg) + } + + if (name === 'archive_video') { + return await archiveVideo(inputArg) + } + + if (name === 'get_video_metadata') { + return await getVideoMetadata(inputArg) + } + + return { success: false, message: `Unknown tool: ${name}` } +}, { + body: t.Object({ + name: t.String(), + arguments: t.Optional(t.Object({ + videoId: t.Optional(t.String()), + url: t.Optional(t.String()) + })) + }) +}) + +app.all('/', async ({ request }) => { + return await handleMcpStreamRequest(request) +}) + +app.all('/*', async ({ request }) => { + return await handleMcpStreamRequest(request) +}) + +export default app +export { isAuthorized } diff --git a/src/utils/archive.ts b/src/utils/archive.ts new file mode 100644 index 0000000..b94f041 --- /dev/null +++ b/src/utils/archive.ts @@ -0,0 +1,196 @@ +import * as fs from 'node:fs' +import { db } from '@/utils/database' +import { validateVideo } from '@/utils/regex' +import { createDatabaseVideo } from '@/utils/common' +import { downloadVideo } from '@/utils/download' +import { uploadVideo } from '@/utils/upload' +import { getChannel, getVideo } from '@/utils/metadata' +import redis from '@/utils/redis' + +function extractVideoId(input: string): string | null { + if (!input) return null + const trimmed = input.trim() + if (/^[\w\-_]{11}$/.test(trimmed)) return trimmed + const validated = validateVideo(trimmed) + if (validated && /^[\w\-_]{11}$/.test(validated)) return validated + const match = trimmed.match(/[\w\-_]{11}/) + return match ? match[0] : null +} + +async function archiveVideo(input: string) { + const videoId = extractVideoId(input) + if (!videoId) { + return { success: false, message: 'Invalid video URL or ID.' } + } + + const existing = await db.selectFrom('videos') + .select(['id', 'deletion_stage', 'title']) + .where('id', '=', videoId) + .executeTakeFirst() + + if (existing) { + if (existing.deletion_stage !== null) { + await db.updateTable('videos') + .set({ deletion_stage: null }) + .where('id', '=', videoId) + .execute() + + await redis.del(`watch:${videoId}:html`) + await redis.del('deletion:html') + + return { + success: true, + message: `Video '${existing.title || videoId}' (${videoId}) restored from deletion stage '${existing.deletion_stage}'.`, + videoId + } + } + + return { + success: true, + message: `Video '${existing.title || videoId}' (${videoId}) is already archived.`, + videoId + } + } + + if (await redis.get(`blacklist:${videoId}`)) { + return { success: false, message: 'This video is blacklisted.' } + } + + if (await redis.get(`save:${videoId}`)) { + return { success: false, message: 'Someone is currently archiving or downloading this video.' } + } + + await redis.set(`save:${videoId}`, 'downloading', 'EX', 300) + + try { + const data = await getVideo(videoId) + if (data.error) { + return { success: false, message: `Unable to retrieve video info from YouTube: ${data.error}` } + } + + const channelData = await getChannel(data.videoDetails.channelId) + if (channelData.error) { + return { success: false, message: `Unable to retrieve channel info from YouTube: ${channelData.error}` } + } + + const wsMock = { + send: (msg: string) => console.log(`[Archive ${videoId}] ${msg}`) + } + + const downloadResult = await downloadVideo(wsMock, videoId) + if (downloadResult.fail) { + return { success: false, message: `Download failed: ${downloadResult.message}` } + } + + let filePath = fs.readdirSync('./videos/').find(f => f.includes(`${videoId}.`)) + if (!filePath) { + return { success: false, message: `Downloaded video file for ${videoId} not found.` } + } + + filePath = './videos/' + filePath + const videoUrl = await uploadVideo(filePath) + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + + const uploaded = await createDatabaseVideo(videoId, videoUrl, { data, channelData }) + if (uploaded !== 'success') { + return { success: false, message: `Failed to create database record: ${JSON.stringify(uploaded)}` } + } + + return { + success: true, + message: `Successfully archived video '${data.videoDetails.title}' (${videoId}).`, + videoId, + title: data.videoDetails.title, + watchUrl: `https://preservetube.com/watch?v=${videoId}` + } + } catch (error: unknown) { + const err = error as Error + return { success: false, message: `Archiving failed: ${err.message}` } + } finally { + await redis.del(`save:${videoId}`) + } +} + +async function addToSizeWhitelist(input: string) { + const videoId = extractVideoId(input) + if (!videoId) { + return { success: false, message: 'Invalid video URL or ID.' } + } + + const servers = [process.env.METADATA, process.env.ALTERNATIVE_METADATA].filter(Boolean) as string[] + + const results = await Promise.all(servers.map(async (serverHost) => { + try { + const res = await fetch(`${serverHost}/whitelist`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: videoId }) + }) + + if (!res.ok) { + return { server: serverHost, success: false, message: `Metadata service returned status ${res.status}: ${await res.text()}` } + } + + const data = await res.json() as { success: boolean, message: string, whitelist?: string[] } + return { server: serverHost, ...data } + } catch (error: unknown) { + const err = error as Error + return { server: serverHost, success: false, message: `Failed to connect: ${err.message}` } + } + })) + + const allSuccessful = results.length > 0 && results.every(r => r.success) + return { + success: allSuccessful, + message: allSuccessful + ? `Successfully added video ${videoId} to size whitelist on all metadata servers (${results.map(r => r.server).join(', ')}).` + : `Whitelisting video ${videoId} completed with issues on some servers: ${JSON.stringify(results)}`, + results + } +} + +async function getVideoMetadata(input: string) { + const videoId = extractVideoId(input) + if (!videoId) { + return { success: false, message: 'Invalid video URL or ID.' } + } + + const dbVideo = await db.selectFrom('videos') + .selectAll() + .where('id', '=', videoId) + .executeTakeFirst() + + let fileInfo = null + if (dbVideo && dbVideo.deletion_stage === 'cold_storage') { + fileInfo = await db.selectFrom('files') + .selectAll() + .where('videoId', '=', videoId) + .executeTakeFirst() || null + } + + const metadata = await getVideo(videoId) + const ytDetails = metadata && !metadata.error ? { + title: metadata.videoDetails?.title, + lengthSeconds: metadata.videoDetails?.lengthSeconds ? parseInt(metadata.videoDetails.lengthSeconds, 10) : null, + channel: metadata.videoDetails?.author, + channelId: metadata.videoDetails?.channelId, + viewCount: metadata.videoDetails?.viewCount, + isLive: metadata.videoDetails?.isLive || false, + published: metadata.microformat?.playerMicroformatRenderer?.publishDate?.slice(0, 10) || null, + description: metadata.microformat?.playerMicroformatRenderer?.description?.simpleText || null + } : null + + return { + success: true, + videoId, + isArchived: Boolean(dbVideo), + watchUrl: `https://preservetube.com/watch?v=${videoId}`, + databaseRecord: dbVideo ? { + ...dbVideo, + coldStorageFile: fileInfo + } : null, + youtubeMetadata: ytDetails + } +} + +export { archiveVideo, addToSizeWhitelist, getVideoMetadata, extractVideoId } diff --git a/src/utils/redis.ts b/src/utils/redis.ts index deb6fcd..191daf1 100644 --- a/src/utils/redis.ts +++ b/src/utils/redis.ts @@ -22,6 +22,6 @@ setInterval(async () => { console.log(`deleted file ${f} because there is no active download of it`) } }) -}, 5 * 60000) +}, 5 * 60000).unref() export default redis \ No newline at end of file