@powerfm/libretime-mcp 0.1.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ // Active upload implementation — uses /rest/media (legacy LibreTime PHP endpoint).
2
+ //
3
+ // Background: /api/v2/files (DRF) creates a DB record but never writes to disk or queues
4
+ // the analyzer. /rest/media is the only endpoint that triggers the full import workflow.
5
+ // This file stays active until LibreTime wires the analyzer in the DRF endpoint.
6
+ //
7
+ // When that upstream fix lands, swap index.ts to import from upload_file.ts instead.
8
+ // See upload_file.ts for the DRF implementation kept ready for that switch.
9
+ import { z } from 'zod';
10
+ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from '@modelcontextprotocol/ext-apps/server';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { libreGet, libreRestMedia } from '../../libretime.js';
14
+ import { toolText } from '../../tool-response.js';
15
+ import { LibreFileSchema, LibrarySchema } from './types.js';
16
+ // When running via tsx (dev): __dirname is src/tools/files/ → walk up to project root, then into dist/
17
+ // When running compiled: __dirname is dist/tools/files/ → walk up two levels to dist/
18
+ const HTML_PATH = import.meta.filename.endsWith('.ts')
19
+ ? path.join(import.meta.dirname, '../../../dist/apps/upload-file.html')
20
+ : path.join(import.meta.dirname, '../../apps/upload-file.html');
21
+ const resourceUri = 'ui://libretime/upload-file.html';
22
+ const metadataFields = {
23
+ track_title: z.string().optional().describe('Track title'),
24
+ artist_name: z.string().optional().describe('Artist name'),
25
+ album_title: z.string().optional().describe('Album title'),
26
+ genre: z.string().optional().describe('Genre'),
27
+ library: z.number().optional().describe('Track type / library ID'),
28
+ };
29
+ const MIME_MAP = {
30
+ mp3: 'audio/mpeg',
31
+ flac: 'audio/flac',
32
+ wav: 'audio/wav',
33
+ ogg: 'audio/ogg',
34
+ aac: 'audio/aac',
35
+ m4a: 'audio/mp4',
36
+ opus: 'audio/opus',
37
+ };
38
+ function inferMime(filePath) {
39
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
40
+ return MIME_MAP[ext] ?? 'audio/mpeg';
41
+ }
42
+ function appendMetadata(formData, fields) {
43
+ if (fields.track_title)
44
+ formData.append('track_title', fields.track_title);
45
+ if (fields.artist_name)
46
+ formData.append('artist_name', fields.artist_name);
47
+ if (fields.album_title)
48
+ formData.append('album_title', fields.album_title);
49
+ if (fields.genre)
50
+ formData.append('genre', fields.genre);
51
+ if (fields.library)
52
+ formData.append('library', String(fields.library));
53
+ }
54
+ export function register(server, uploadUrl, uploadToken) {
55
+ registerAppTool(server, 'upload_file', {
56
+ description: 'Upload an audio file to the LibreTime media library. Provide a URL to fetch the file from, or a local file path when running in stdio mode. When neither is given, opens a file picker. Optionally pre-fill metadata such as track title, artist, album, and genre.',
57
+ inputSchema: {
58
+ url: z.string().optional().describe('Publicly accessible URL of the audio file to upload'),
59
+ file_path: z.string().optional().describe('Absolute path to a local audio file — use when running stdio mode'),
60
+ ...metadataFields,
61
+ },
62
+ _meta: { ui: { resourceUri } },
63
+ }, async ({ url, file_path, track_title, artist_name, album_title, genre, library }) => {
64
+ const meta = { track_title, artist_name, album_title, genre, library };
65
+ // Priority 1: local file path (stdio mode — server runs on the user's machine)
66
+ if (file_path) {
67
+ let fileBuffer;
68
+ try {
69
+ fileBuffer = await fs.readFile(file_path);
70
+ }
71
+ catch (err) {
72
+ return toolText({
73
+ status: 'error',
74
+ reason: `Could not read file: ${err instanceof Error ? err.message : String(err)}`,
75
+ });
76
+ }
77
+ const fileName = path.basename(file_path);
78
+ const mime = inferMime(file_path);
79
+ const formData = new FormData();
80
+ formData.append('file', new Blob([new Uint8Array(fileBuffer)], { type: mime }), fileName);
81
+ appendMetadata(formData, meta);
82
+ try {
83
+ const raw = await libreRestMedia(formData);
84
+ const parsed = LibreFileSchema.safeParse(raw);
85
+ return toolText({ status: 'success', file: parsed.success ? parsed.data : raw });
86
+ }
87
+ catch (err) {
88
+ return toolText({
89
+ status: 'error',
90
+ reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
91
+ });
92
+ }
93
+ }
94
+ // Priority 2: remote URL — fetch then forward to LibreTime
95
+ if (url) {
96
+ let fileResponse;
97
+ try {
98
+ fileResponse = await fetch(url);
99
+ if (!fileResponse.ok) {
100
+ return toolText({
101
+ status: 'error',
102
+ reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}`,
103
+ });
104
+ }
105
+ }
106
+ catch (err) {
107
+ return toolText({
108
+ status: 'error',
109
+ reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}`,
110
+ });
111
+ }
112
+ const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
113
+ const blob = await fileResponse.blob();
114
+ const formData = new FormData();
115
+ formData.append('file', new Blob([await blob.arrayBuffer()], { type: blob.type || inferMime(fileName) }), fileName);
116
+ appendMetadata(formData, meta);
117
+ try {
118
+ const raw = await libreRestMedia(formData);
119
+ const parsed = LibreFileSchema.safeParse(raw);
120
+ return toolText({ status: 'success', file: parsed.success ? parsed.data : raw });
121
+ }
122
+ catch (err) {
123
+ return toolText({
124
+ status: 'error',
125
+ reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
126
+ });
127
+ }
128
+ }
129
+ // Priority 3: no input — hand off to the file picker UI
130
+ let libraries = [];
131
+ try {
132
+ const raw = await libreGet('/api/v2/libraries');
133
+ libraries = LibrarySchema.array().parse(raw);
134
+ }
135
+ catch {
136
+ // Non-fatal — UI will just show no dropdown
137
+ }
138
+ return toolText({
139
+ status: uploadUrl ? 'upload_ready' : 'upload_required',
140
+ upload_url: uploadUrl ?? null,
141
+ upload_token: uploadToken ?? null,
142
+ libraries,
143
+ });
144
+ });
145
+ registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
146
+ const html = await fs.readFile(HTML_PATH, 'utf-8');
147
+ return {
148
+ contents: [
149
+ {
150
+ uri: resourceUri,
151
+ mimeType: RESOURCE_MIME_TYPE,
152
+ text: html,
153
+ ...(uploadUrl && {
154
+ _meta: { ui: { csp: { connectDomains: [uploadUrl] } } },
155
+ }),
156
+ },
157
+ ],
158
+ };
159
+ });
160
+ }
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@powerfm/libretime-mcp",
3
- "version": "0.1.8",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",
7
7
  "description": "MCP server for LibreTime radio station — connect Claude to your broadcast schedule, media library, and analytics",
8
+ "keywords": [
9
+ "mcp",
10
+ "modelcontextprotocol",
11
+ "libretime",
12
+ "radio",
13
+ "claude"
14
+ ],
8
15
  "repository": {
9
16
  "type": "git",
10
17
  "url": "git+https://github.com/nelsonra/libretime-mcp.git"
@@ -24,9 +31,11 @@
24
31
  "dev:client": "tsx watch --env-file=.env src/stdio/client.ts",
25
32
  "dev:admin": "tsx watch --env-file=.env src/stdio/admin.ts",
26
33
  "dev:client-http": "tsx watch --env-file=.env src/http/client.ts",
27
- "dev:admin-http": "tsx watch --env-file=.env src/http/admin.ts",
34
+ "dev:admin-http": "concurrently \"npm run watch:app\" \"tsx watch --env-file=.env src/http/admin.ts\"",
28
35
  "clean": "rm -rf dist",
29
- "build": "npm run clean && tsc",
36
+ "build:app": "cross-env INPUT=apps/upload-file.html vite build",
37
+ "watch:app": "cross-env NODE_ENV=development INPUT=apps/upload-file.html vite build --watch",
38
+ "build": "npm run clean && npm run build:app && tsc",
30
39
  "prepublishOnly": "npm run build && npm test",
31
40
  "publish:patch": "npm version patch && npm publish --access public",
32
41
  "publish:minor": "npm version minor && npm publish --access public",
@@ -43,17 +52,31 @@
43
52
  "test:watch": "vitest"
44
53
  },
45
54
  "dependencies": {
55
+ "@modelcontextprotocol/ext-apps": "~1.3.2",
46
56
  "@modelcontextprotocol/sdk": "^1.0.0",
47
57
  "cors": "~2.8.6",
48
58
  "express": "~5.2.1",
59
+ "express-rate-limit": "~8.3.2",
60
+ "helmet": "~8.1.0",
61
+ "morgan": "~1.10.1",
62
+ "react": "~19.2.4",
63
+ "react-dom": "~19.2.4",
49
64
  "zod": "^3.23.8"
50
65
  },
51
66
  "devDependencies": {
52
67
  "@types/cors": "~2.8.19",
53
68
  "@types/express": "~5.0.6",
69
+ "@types/morgan": "~1.9.10",
54
70
  "@types/node": "^22.0.0",
71
+ "@types/react": "~19.2.14",
72
+ "@types/react-dom": "~19.2.3",
73
+ "@vitejs/plugin-react": "~6.0.1",
74
+ "concurrently": "~9.2.1",
75
+ "cross-env": "~10.1.0",
55
76
  "tsx": "^4.19.0",
56
77
  "typescript": "^5.6.0",
78
+ "vite": "~8.0.3",
79
+ "vite-plugin-singlefile": "~2.3.2",
57
80
  "vitest": "~4.1.1"
58
81
  }
59
82
  }