@powerfm/libretime-mcp 0.2.0 → 0.5.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.
package/README.md CHANGED
@@ -12,8 +12,11 @@ Tools are organised into subdirectories under `src/tools/` — one file per tool
12
12
 
13
13
  **Read-only (client & admin)**
14
14
  - `get_shows` — list all shows
15
+ - `get_show` — get a single show by ID
16
+ - `get_show_instances` — list scheduled show slots (filterable by show, date range)
15
17
  - `get_schedule` — broadcast schedule
16
18
  - `get_stream_state` — current on-air state
19
+ - `get_station_info` — station name, timezone, and configuration
17
20
 
18
21
  **Analytics (admin)**
19
22
  - ~~`get_listener_counts`~~ — disabled (API returns full history with no filtering, ~120k records)
@@ -21,13 +24,32 @@ Tools are organised into subdirectories under `src/tools/` — one file per tool
21
24
 
22
25
  **Media library (admin)**
23
26
  - `search_files` — search your media library
24
- - `upload_file` — upload an audio file _(stdio onlyreads from local filesystem)_
27
+ - `upload_file` — upload an audio file via drag-and-drop UI (works in both stdio and HTTP modes see [File Upload](#file-upload))
25
28
  - `update_file_metadata` — edit track metadata
26
29
  - `delete_file` — remove a file
27
30
 
31
+ **Shows & scheduling (admin)**
32
+ - `create_show` — create a new show
33
+ - `schedule_file` — schedule an uploaded file into a show instance
34
+
35
+ **Playlists (admin)**
36
+ - `get_playlists` — list all playlists
37
+ - `create_playlist` — create a new playlist
38
+ - `get_playlist_contents` — list items in a playlist
39
+ - `add_to_playlist` — add a file or stream to a playlist
40
+
28
41
  **Users (admin)**
29
- - `get_users` — list station users
30
- - `get_hosts` — list show hosts
42
+ - `get_users` — list station users (pass `include_email: true` to include email addresses, omitted by default)
43
+ - `get_hosts` — list show hosts with their show assignments
44
+
45
+ ## File Upload
46
+
47
+ `upload_file` renders a drag-and-drop UI in the Claude chat window. It works the same way in both transport modes:
48
+
49
+ - **HTTP mode** — the UI posts directly to the MCP server's `/upload` endpoint
50
+ - **stdio mode** — the admin server spins up a lightweight sidecar HTTP server (default port `4000`) that the UI posts to. The MCP protocol itself continues over stdio; the upload is a side-channel.
51
+
52
+ Both modes use the same upload token generated at startup, so there's no separate configuration needed. To change the sidecar port in stdio mode set `UPLOAD_PORT` in your environment.
31
53
 
32
54
  ## Option 1 — Claude Desktop (stdio)
33
55
 
@@ -50,13 +72,16 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
50
72
  "env": {
51
73
  "LIBRETIME_URL": "https://your-instance.example.com",
52
74
  "LIBRETIME_USER": "user",
53
- "LIBRETIME_PASS": "pass"
75
+ "LIBRETIME_PASS": "pass",
76
+ "LIBRETIME_API_KEY": "your_libretime_api_key"
54
77
  }
55
78
  }
56
79
  }
57
80
  }
58
81
  ```
59
82
 
83
+ `LIBRETIME_API_KEY` is the `general.api_key` value from your LibreTime `config.yml`. It is required for file uploads — omit it if you are using the read-only client.
84
+
60
85
  Use `libretime-mcp-client` instead of `libretime-mcp` for read-only access.
61
86
 
62
87
  ### npx (no install)
@@ -156,8 +181,16 @@ npm run build
156
181
 
157
182
  # Tests
158
183
  npm test
184
+
185
+ # MCP Inspector — browse and call tools interactively
186
+ npm run inspect:admin # stdio admin server
187
+ npm run inspect:client # stdio read-only server
188
+ npm run inspect:admin-http # HTTP admin (start the server first, then run this)
189
+ npm run inspect:client-http # HTTP read-only (start the server first, then run this)
159
190
  ```
160
191
 
192
+ The inspector opens a browser UI at `http://localhost:5173` where you can list tools, see their schemas, and call them with custom inputs.
193
+
161
194
  ## Servers
162
195
 
163
196
  | Command | Transport | Port | Access |
@@ -84,7 +84,7 @@ Boolean requesting whether a visible border and background is provided by the ho
84
84
  - omitted: host decides border`)}),L({method:B(`ui/request-display-mode`),params:L({mode:oc.describe(`The display mode being requested.`)})});var Sc=L({mode:oc.describe(`The display mode that was actually set. May differ from requested if not supported.`)}).passthrough(),Cc=R([B(`model`),B(`app`)]).describe(`Tool visibility scope - who can access the tool.`);L({resourceUri:j().optional(),visibility:I(Cc).optional().describe(`Who can access this tool. Default: ["model", "app"]
85
85
  - "model": Tool visible to and callable by the agent
86
86
  - "app": Tool callable by the app from this server only`)}),L({mimeTypes:I(j()).optional().describe('Array of supported MIME types for UI resources.\nMust include `"text/html;profile=mcp-app"` for MCP Apps support.')}),L({method:B(`ui/download-file`),params:L({contents:I(R([ns,rs])).describe(`Resource contents to download — embedded (inline data) or linked (host fetches). Uses standard MCP resource types.`)})}),L({method:B(`ui/message`),params:L({role:B(`user`).describe(`Message role, currently only "user" is supported.`),content:I(is).describe(`Message content blocks (text, image, etc.).`)})}),L({method:B(`ui/notifications/sandbox-resource-ready`),params:L({html:j().describe(`HTML content to load into the inner iframe.`),sandbox:j().optional().describe(`Optional override for the inner iframe's sandbox attribute.`),csp:dc.optional().describe(`CSP configuration from resource metadata.`),permissions:fc.optional().describe(`Sandbox permissions from resource metadata.`)})});var wc=L({method:B(`ui/notifications/tool-result`),params:ps.describe(`Standard MCP tool execution result.`)}),Tc=L({toolInfo:L({id:Pa.optional().describe(`JSON-RPC id of the tools/call request.`),tool:us.describe(`Tool definition including name, inputSchema, etc.`)}).optional().describe(`Metadata of the tool call that instantiated this App.`),theme:ac.optional().describe(`Current color theme preference.`),styles:_c.optional().describe(`Style configuration for theming the app.`),displayMode:oc.optional().describe(`How the UI is currently displayed.`),availableDisplayModes:I(oc).optional().describe(`Display modes the host supports.`),containerDimensions:R([L({height:M().describe(`Fixed container height in pixels.`)}),L({maxHeight:R([M(),Pi()]).optional().describe(`Maximum container height in pixels.`)})]).and(R([L({width:M().describe(`Fixed container width in pixels.`)}),L({maxWidth:R([M(),Pi()]).optional().describe(`Maximum container width in pixels.`)})])).optional().describe(`Container dimensions. Represents the dimensions of the iframe or other
87
- container holding the app. Specify either width or maxWidth, and either height or maxHeight.`),locale:j().optional().describe(`User's language and region preference in BCP 47 format.`),timeZone:j().optional().describe(`User's timezone in IANA format.`),userAgent:j().optional().describe(`Host application identifier.`),platform:R([B(`web`),B(`desktop`),B(`mobile`)]).optional().describe(`Platform type for responsive design decisions.`),deviceCapabilities:L({touch:N().optional().describe(`Whether the device supports touch input.`),hover:N().optional().describe(`Whether the device supports hover interactions.`)}).optional().describe(`Device input capabilities.`),safeAreaInsets:L({top:M().describe(`Top safe area inset in pixels.`),right:M().describe(`Right safe area inset in pixels.`),bottom:M().describe(`Bottom safe area inset in pixels.`),left:M().describe(`Left safe area inset in pixels.`)}).optional().describe(`Mobile safe area boundaries in pixels.`)}).passthrough(),Ec=L({method:B(`ui/notifications/host-context-changed`),params:Tc.describe(`Partial context update containing only changed fields.`)});L({method:B(`ui/update-model-context`),params:L({content:I(is).optional().describe(`Context content blocks (text, image, etc.).`),structuredContent:z(j(),P().describe(`Structured content for machine-readable context data.`)).optional().describe(`Structured content for machine-readable context data.`)})}),L({method:B(`ui/initialize`),params:L({appInfo:Ya.describe(`App identification (name and version).`),appCapabilities:xc.describe(`Features and capabilities this app provides.`),protocolVersion:j().describe(`Protocol version this app supports.`)})});var Dc=L({protocolVersion:j().describe(`Negotiated protocol version string (e.g., "2025-11-21").`),hostInfo:Ya.describe(`Host application identification and version.`),hostCapabilities:bc.describe(`Features and capabilities provided by the host.`),hostContext:Tc.describe(`Rich context about the host environment.`)}).passthrough(),Oc=class extends nc{_appInfo;_capabilities;options;_hostCapabilities;_hostInfo;_hostContext;constructor(e,t={},n={autoResize:!0}){super(n),this._appInfo=e,this._capabilities=t,this.options=n,this.setRequestHandler(ao,e=>(console.log(`Received ping:`,e.params),{})),this.onhostcontextchanged=()=>{}}getHostCapabilities(){return this._hostCapabilities}getHostVersion(){return this._hostInfo}getHostContext(){return this._hostContext}set ontoolinput(e){this.setNotificationHandler(pc,t=>e(t.params))}set ontoolinputpartial(e){this.setNotificationHandler(mc,t=>e(t.params))}set ontoolresult(e){this.setNotificationHandler(wc,t=>e(t.params))}set ontoolcancelled(e){this.setNotificationHandler(hc,t=>e(t.params))}set onhostcontextchanged(e){this.setNotificationHandler(Ec,t=>{this._hostContext={...this._hostContext,...t.params},e(t.params)})}set onteardown(e){this.setRequestHandler(vc,(t,n)=>e(t.params,n))}set oncalltool(e){this.setRequestHandler(hs,(t,n)=>e(t.params,n))}set onlisttools(e){this.setRequestHandler(ds,(t,n)=>e(t.params,n))}assertCapabilityForMethod(e){}assertRequestHandlerCapability(e){switch(e){case`tools/call`:case`tools/list`:if(!this._capabilities.tools)throw Error(`Client does not support tool capability (required for ${e})`);return;case`ping`:case`ui/resource-teardown`:return;default:throw Error(`No handler for method ${e} registered`)}}assertNotificationCapability(e){}assertTaskCapability(e){throw Error(`Tasks are not supported in MCP Apps`)}assertTaskHandlerCapability(e){throw Error(`Task handlers are not supported in MCP Apps`)}async callServerTool(e,t){if(typeof e==`string`)throw Error(`callServerTool() expects an object as its first argument, but received a string ("${e}"). Did you mean: callServerTool({ name: "${e}", arguments: { ... } })?`);return await this.request({method:`tools/call`,params:e},ps,t)}async readServerResource(e,t){return await this.request({method:`resources/read`,params:e},Ro,t)}async listServerResources(e,t){return await this.request({method:`resources/list`,params:e},Mo,t)}sendMessage(e,t){return this.request({method:`ui/message`,params:e},uc,t)}sendLog(e){return this.notification({method:`notifications/message`,params:e})}updateModelContext(e,t){return this.request({method:`ui/update-model-context`,params:e},Wa,t)}openLink(e,t){return this.request({method:`ui/open-link`,params:e},cc,t)}sendOpenLink=this.openLink;downloadFile(e,t){return this.request({method:`ui/download-file`,params:e},lc,t)}requestTeardown(e={}){return this.notification({method:`ui/notifications/request-teardown`,params:e})}requestDisplayMode(e,t){return this.request({method:`ui/request-display-mode`,params:e},Sc,t)}sendSizeChanged(e){return this.notification({method:`ui/notifications/size-changed`,params:e})}setupSizeChangedNotifications(){let e=!1,t=0,n=0,r=()=>{e||(e=!0,requestAnimationFrame(()=>{e=!1;let r=document.documentElement,i=r.style.height;r.style.height=`max-content`;let a=Math.ceil(r.getBoundingClientRect().height);r.style.height=i;let o=Math.ceil(window.innerWidth);(o!==t||a!==n)&&(t=o,n=a,this.sendSizeChanged({width:o,height:a}))}))};r();let i=new ResizeObserver(r);return i.observe(document.documentElement),i.observe(document.body),()=>i.disconnect()}async connect(e=new ic(window.parent,window.parent),t){if(this.transport)throw Error(`App is already connected. Call close() before connecting again.`);await super.connect(e);try{let e=await this.request({method:`ui/initialize`,params:{appCapabilities:this._capabilities,appInfo:this._appInfo,protocolVersion:rc}},Dc,t);if(e===void 0)throw Error(`Server sent invalid initialize result: ${e}`);this._hostCapabilities=e.hostCapabilities,this._hostInfo=e.hostInfo,this._hostContext=e.hostContext,await this.notification({method:`ui/notifications/initialized`}),this.options?.autoResize&&this.setupSizeChangedNotifications()}catch(e){throw this.close(),e}}};function kc({appInfo:e,capabilities:t,onAppCreated:n}){let[r,i]=(0,l.useState)(null),[a,o]=(0,l.useState)(!1),[s,c]=(0,l.useState)(null);return(0,l.useEffect)(()=>{let r=!0;async function a(){try{let a=new ic(window.parent,window.parent),s=new Oc(e,t);n?.(s),await s.connect(a),r&&(i(s),o(!0),c(null))}catch(e){r&&(i(null),o(!1),c(e instanceof Error?e:Error(`Failed to connect`)))}}return a(),()=>{r=!1}},[]),{app:r,isConnected:a,error:s}}function Ac(){let[e,t]=(0,l.useState)(`loading`),[n,r]=(0,l.useState)(`idle`),[i,a]=(0,l.useState)(null),[o,s]=(0,l.useState)(``),[c,u]=(0,l.useState)(null),[d,f]=(0,l.useState)(null),[p,m]=(0,l.useState)(null),h=(0,l.useRef)(null),[g,_]=(0,l.useState)(``),[v,y]=(0,l.useState)([]),[b,x]=(0,l.useState)(null),[S,ee]=(0,l.useState)({track_title:``,artist_name:``,album_title:``,genre:``}),{app:C,error:te}=kc({appInfo:{name:`LibreTime File Uploader`,version:`1.0.0`},capabilities:{},onAppCreated:e=>{e.ontoolinput=async e=>{let t=e.arguments;t&&ee(e=>({track_title:t.track_title??e.track_title,artist_name:t.artist_name??e.artist_name,album_title:t.album_title??e.album_title,genre:t.genre??e.genre}))},e.ontoolresult=async e=>{let n=e.content?.find(e=>e.type===`text`)?.text;if(n)try{let e=JSON.parse(n);if(e.status===`success`&&e.file){a(e.file),r(`success`);return}u(e.upload_url??null),f(e.upload_token??null),y(e.libraries??[]),t(e.upload_url?`file-picker`:`url-input`)}catch{}},e.onteardown=async()=>({}),e.onerror=console.error}}),ne=(0,l.useCallback)(e=>{let t=e.target.files?.[0]??null;if(m(t),t&&!S.track_title){let e=t.name.replace(/\.[^.]+$/,``);ee(t=>({...t,track_title:t.track_title||e}))}},[S.track_title]),re=(0,l.useCallback)(e=>t=>{ee(n=>({...n,[e]:t.target.value}))},[]),ie=(0,l.useCallback)(async()=>{if(!(!p||!c||!d)){r(`uploading`);try{let e=new FormData;e.append(`file`,p,p.name),e.append(`name`,p.name),e.append(`mime`,p.type||`audio/mpeg`),e.append(`size`,String(p.size)),e.append(`accessed`,String(Math.floor(Date.now()/1e3)));for(let[t,n]of Object.entries(S))n.trim()&&e.append(t,n.trim());b!==null&&e.append(`library`,String(b));let t=await fetch(c,{method:`POST`,headers:{"X-Upload-Token":d},body:e});if(!t.ok){let e=await t.json().catch(()=>({error:t.statusText})),n=e.detail;s(`${e.error??`Upload failed`}${n?`: ${n}`:``}`),r(`error`);return}let n=await t.json();a(n),r(`success`),await C?.updateModelContext({content:[{type:`text`,text:`File uploaded: "${n.track_title??n.name}" (ID: ${n.id})`}]})}catch(e){s(e instanceof Error?e.message:String(e)),r(`error`)}}},[p,c,d,S,b,C]),ae=(0,l.useCallback)(async()=>{if(!(!g.trim()||!C)){r(`uploading`);try{let e={url:g.trim()};for(let[t,n]of Object.entries(S))n.trim()&&(e[t]=n.trim());b!==null&&(e.library=b);let t=(await C.callServerTool({name:`upload_file`,arguments:e})).content?.find(e=>e.type===`text`)?.text??``,n=JSON.parse(t);n.status===`success`&&n.file?(a(n.file),r(`success`),await C.updateModelContext({content:[{type:`text`,text:`File uploaded: "${n.file.track_title??n.file.name}" (ID: ${n.file.id})`}]})):(s(n.reason??`Upload failed`),r(`error`))}catch(e){s(e instanceof Error?e.message:String(e)),r(`error`)}}},[g,C,S,b]),oe=(0,l.useCallback)(()=>{m(null),_(``),r(`idle`),s(``),a(null),h.current&&(h.current.value=``)},[]);return{mode:e,uploadState:n,uploadedFile:i,errorMsg:o,file:p,url:g,libraries:v,selectedLibrary:b,metadata:S,busy:n===`uploading`,app:C,error:te,fileInputRef:h,handleFileChange:ne,handleMetadataChange:re,handleFilePick:ie,handleUrl:ae,handleReset:oe,setUrl:_,setSelectedLibrary:x}}var jc=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xml:space='preserve'%20viewBox='0%200%2064%2064'%20clip-rule='evenodd'%20fill-rule='evenodd'%20image-rendering='optimizeQuality'%20shape-rendering='geometricPrecision'%20text-rendering='geometricPrecision'%3e%3cpath%20fill='%23474443'%20d='M31.993%2018.107c3.32%200%206.36%201.198%208.706%203.179l-3.438%203.444a8.687%208.687%200%200%200-10.522%200l-3.438-3.444a13.443%2013.443%200%200%201%208.692-3.179zm2.08%2026.966L35.711%2059h-7.437l1.653-13.927a13.44%2013.44%200%200%201-6.626-3.016l3.438-3.444a8.72%208.72%200%200%200%204.412%201.73v-1.73h-.84c-1.034%200-1.875-.858-1.875-1.893v-8.072c0-1.035.841-1.892%201.874-1.892h3.38c1.033%200%201.874.857%201.874%201.892v8.072c0%201.035-.841%201.893-1.874%201.893h-.841v1.73a8.72%208.72%200%200%200%204.412-1.73l3.438%203.444a13.44%2013.44%200%200%201-6.626%203.016zm.118-17.312c.399%200%20.723.31.723.71a.724.724%200%200%201-.723.724.722.722%200%200%201-.708-.724c0-.4.325-.71.708-.71zm-1.859.222c.28%200%20.502.222.502.488%200%20.28-.222.488-.502.488a.482.482%200%200%201-.487-.488c0-.266.221-.488.487-.488zm-1.55.074c.222%200%20.414.192.414.414a.413.413%200%200%201-.413.414.404.404%200%200%201-.414-.414c0-.222.177-.414.414-.414zm-1.387.103c.178%200%20.31.133.31.31a.303.303%200%200%201-.31.311.303.303%200%200%201-.31-.31c0-.178.133-.31.31-.31zm4.796%201.7c.399%200%20.723.326.723.725%200%20.384-.324.71-.723.71a.719.719%200%200%201-.708-.71c0-.4.325-.725.708-.725zm-1.859.222a.496.496%200%201%201%20.014.991.495.495%200%200%201-.014-.99zm-1.55.089c.222%200%20.414.177.414.414a.423.423%200%200%201-.413.414.413.413%200%200%201-.414-.414c0-.237.177-.414.414-.414zm-1.387.089c.178%200%20.31.147.31.325%200%20.162-.132.31-.31.31a.312.312%200%200%201-.31-.31c0-.178.133-.325.31-.325zm4.796%201.7a.725.725%200%200%201%200%201.449.722.722%200%200%201-.708-.725c0-.399.325-.724.708-.724zm-1.859.236c.28%200%20.502.222.502.488a.495.495%200%200%201-.989%200c0-.266.221-.488.487-.488zm-1.55.074c.222%200%20.414.192.414.414a.413.413%200%200%201-.413.414.404.404%200%200%201-.414-.414c0-.222.177-.414.414-.414zm-1.387.104c.178%200%20.31.133.31.31%200%20.178-.132.31-.31.31a.303.303%200%200%201-.31-.31c0-.177.133-.31.31-.31zm4.796%201.7c.399%200%20.723.325.723.71a.724.724%200%200%201-.723.724.722.722%200%200%201-.708-.724c0-.385.325-.71.708-.71zm-1.859.222a.495.495%200%201%201%20.014.99.495.495%200%200%201-.014-.99zm-1.55.074c.222%200%20.414.192.414.414a.426.426%200%200%201-.413.428.416.416%200%200%201-.414-.428c0-.222.177-.414.414-.414zm-1.387.103c.178%200%20.31.148.31.31a.315.315%200%200%201-.31.326.315.315%200%200%201-.31-.325c0-.163.133-.31.31-.31zm4.796%201.7a.725.725%200%200%201%200%201.45.722.722%200%200%201-.708-.725c0-.4.325-.725.708-.725zm-1.859.237c.28%200%20.502.222.502.488a.495.495%200%200%201-.989%200c0-.266.221-.488.487-.488zm-1.55.074c.222%200%20.414.177.414.414a.423.423%200%200%201-.413.414.413.413%200%200%201-.414-.414c0-.237.177-.414.414-.414zm-1.387.103c.178%200%20.31.133.31.31a.303.303%200%200%201-.31.311.303.303%200%200%201-.31-.31c0-.178.133-.31.31-.31zm-3.541-11.073a8.654%208.654%200%200%200-2.553%206.15c0%202.41.974%204.583%202.553%206.165l-3.424%203.43a13.53%2013.53%200%200%201-3.97-9.595c0-3.74%201.52-7.126%203.97-9.58zm15.716-3.43a13.522%2013.522%200%200%201%203.97%209.58c0%203.755-1.52%207.14-3.97%209.595l-3.424-3.43a8.689%208.689%200%200%200%202.553-6.165%208.654%208.654%200%200%200-2.553-6.15z'%20/%3e%3cpath%20fill='%23e62129'%20d='M19.317%2044.364a17.903%2017.903%200%200%201-5.254-12.7%2017.93%2017.93%200%200%201%205.254-12.7l-3.424-3.43c-4.117%204.14-6.67%209.832-6.67%2016.13a22.77%2022.77%200%200%200%206.67%2016.13z'%20/%3e%3cpath%20fill='%23e62129'%20d='M44.683%2018.965a17.916%2017.916%200%200%201%205.24%2012.7%2017.89%2017.89%200%200%201-5.24%2012.699l3.424%203.43a22.77%2022.77%200%200%200%206.67-16.13c0-6.298-2.553-11.99-6.67-16.13z'%20/%3e%3cpath%20fill='%23e62129'%20d='M51.206%2012.43a27.143%2027.143%200%200%201%207.954%2019.234c0%207.525-3.04%2014.326-7.954%2019.25l3.423%203.429A31.984%2031.984%200%200%200%2064%2031.664C64%2022.824%2060.429%2014.81%2054.63%209Z'%20/%3e%3cpath%20fill='%23e62129'%20d='M12.794%2050.913A27.152%2027.152%200%200%201%204.84%2031.664c0-7.51%203.04-14.311%207.954-19.234L9.371%209A31.975%2031.975%200%200%200%200%2031.664a31.984%2031.984%200%200%200%209.37%2022.679Z'%20/%3e%3c/svg%3e`,Mc=`data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='utf-8'?%3e%3c!--%20Generator:%20Adobe%20Illustrator%2014.0.0,%20SVG%20Export%20Plug-In%20.%20SVG%20Version:%206.00%20Build%2043363)%20--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20id='Layer_1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20x='0px'%20y='0px'%20width='166.4px'%20height='64px'%20viewBox='0%200%20166.4%2064'%20enable-background='new%200%200%20166.4%2064'%20xml:space='preserve'%3e%3cg%3e%3cg%3e%3crect%20fill='%23FFFF00'%20width='12.8'%20height='64'/%3e%3crect%20x='25.599'%20fill='%23FFFF00'%20width='12.8'%20height='38.4'/%3e%3c/g%3e%3cg%3e%3cpolygon%20fill='%23FFFF00'%20points='51.2,25.6%2051.2,64%2064,64%2064,38.4%2089.6,38.4%2089.6,25.6%20'/%3e%3crect%20x='51.2'%20fill='%23FFFF00'%20width='38.4'%20height='12.8'/%3e%3c/g%3e%3crect%20x='102.4'%20fill='%23FFFF00'%20width='12.801'%20height='64'/%3e%3crect%20x='128'%20fill='%23FFFF00'%20width='12.801'%20height='64'/%3e%3crect%20x='153.6'%20fill='%23FFFF00'%20width='12.8'%20height='64'/%3e%3c/g%3e%3c/svg%3e`,Nc=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),K=e(((e,t)=>{t.exports=Nc()}))();function Pc(){return(0,K.jsxs)(`header`,{className:d.header,children:[(0,K.jsxs)(`div`,{className:d.headerLeft,children:[(0,K.jsx)(`img`,{src:jc,alt:`LibreTime`,className:d.headerIcon}),(0,K.jsx)(`span`,{className:d.headerTitle,children:`LibreTime File Uploader`})]}),(0,K.jsxs)(`a`,{href:`https://powerfm.org`,target:`_blank`,rel:`noopener noreferrer`,className:d.headerRight,children:[(0,K.jsx)(`span`,{className:d.poweredBy,children:`powered by`}),(0,K.jsx)(`img`,{src:Mc,alt:`PowerFM`,className:d.powerFmLogo})]})]})}function Fc(){let{mode:e,uploadState:t,uploadedFile:n,errorMsg:r,file:i,url:a,libraries:o,selectedLibrary:s,metadata:c,busy:l,app:u,error:f,fileInputRef:p,handleFileChange:m,handleMetadataChange:h,handleFilePick:g,handleUrl:_,handleReset:v,setUrl:y,setSelectedLibrary:b}=Ac();return f?(0,K.jsxs)(`div`,{className:d.message,children:[`Error: `,f.message]}):!u||e===`loading`?(0,K.jsx)(`div`,{className:d.message,children:`Loading…`}):t===`success`&&n?(0,K.jsxs)(`div`,{className:d.wrapper,children:[(0,K.jsx)(Pc,{}),(0,K.jsxs)(`div`,{className:d.container,children:[(0,K.jsx)(`div`,{className:d.successIcon,children:`✓`}),(0,K.jsx)(`p`,{className:d.successLabel,children:`Uploaded successfully`}),(0,K.jsxs)(`dl`,{className:d.fileDetails,children:[(0,K.jsx)(`dt`,{children:`Title`}),(0,K.jsx)(`dd`,{children:n.track_title??n.name}),n.artist_name&&(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(`dt`,{children:`Artist`}),(0,K.jsx)(`dd`,{children:n.artist_name})]}),n.album_title&&(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(`dt`,{children:`Album`}),(0,K.jsx)(`dd`,{children:n.album_title})]}),n.genre&&(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(`dt`,{children:`Genre`}),(0,K.jsx)(`dd`,{children:n.genre})]}),(0,K.jsx)(`dt`,{children:`ID`}),(0,K.jsx)(`dd`,{children:n.id})]}),(0,K.jsx)(`button`,{className:d.button,onClick:v,children:`Upload another`})]})]}):(0,K.jsxs)(`div`,{className:d.wrapper,children:[(0,K.jsx)(Pc,{}),(0,K.jsxs)(`div`,{className:d.container,children:[e===`file-picker`?(0,K.jsxs)(`label`,{className:`${d.dropZone} ${i?d.dropZoneHasFile:``}`,children:[(0,K.jsx)(`input`,{ref:p,type:`file`,accept:`audio/*`,onChange:m,className:d.hiddenInput,disabled:l}),i?(0,K.jsx)(`span`,{className:d.fileName,children:i.name}):(0,K.jsx)(`span`,{className:d.dropHint,children:`Click to choose an audio file`})]}):(0,K.jsx)(`input`,{type:`url`,value:a,onChange:e=>y(e.target.value),placeholder:`https://example.com/show.mp3`,className:d.input,disabled:l}),o.length>0&&(0,K.jsxs)(`label`,{className:d.fieldLabel,children:[(0,K.jsx)(`span`,{className:d.fieldName,children:`Track type`}),(0,K.jsxs)(`select`,{value:s??``,onChange:e=>b(e.target.value?Number(e.target.value):null),className:d.input,disabled:l,children:[(0,K.jsx)(`option`,{value:``,children:`— Select —`}),o.filter(e=>e.enabled).map(e=>(0,K.jsx)(`option`,{value:e.id,children:e.name??e.code},e.id))]})]}),(0,K.jsx)(`div`,{className:d.fields,children:[`track_title`,`artist_name`,`album_title`,`genre`].map(e=>(0,K.jsxs)(`label`,{className:d.fieldLabel,children:[(0,K.jsx)(`span`,{className:d.fieldName,children:e.replace(`_`,` `)}),(0,K.jsx)(`input`,{type:`text`,value:c[e],onChange:h(e),className:d.input,disabled:l,placeholder:`Optional`})]},e))}),t===`error`&&(0,K.jsx)(`p`,{className:d.errorMsg,children:r}),(0,K.jsx)(`button`,{className:d.button,onClick:e===`file-picker`?g:_,disabled:l||(e===`file-picker`?!i:!a.trim()),children:l?`Uploading…`:`Upload`})]})]})}(0,u.createRoot)(document.getElementById(`root`)).render((0,K.jsx)(l.StrictMode,{children:(0,K.jsx)(Fc,{})}));</script>
87
+ container holding the app. Specify either width or maxWidth, and either height or maxHeight.`),locale:j().optional().describe(`User's language and region preference in BCP 47 format.`),timeZone:j().optional().describe(`User's timezone in IANA format.`),userAgent:j().optional().describe(`Host application identifier.`),platform:R([B(`web`),B(`desktop`),B(`mobile`)]).optional().describe(`Platform type for responsive design decisions.`),deviceCapabilities:L({touch:N().optional().describe(`Whether the device supports touch input.`),hover:N().optional().describe(`Whether the device supports hover interactions.`)}).optional().describe(`Device input capabilities.`),safeAreaInsets:L({top:M().describe(`Top safe area inset in pixels.`),right:M().describe(`Right safe area inset in pixels.`),bottom:M().describe(`Bottom safe area inset in pixels.`),left:M().describe(`Left safe area inset in pixels.`)}).optional().describe(`Mobile safe area boundaries in pixels.`)}).passthrough(),Ec=L({method:B(`ui/notifications/host-context-changed`),params:Tc.describe(`Partial context update containing only changed fields.`)});L({method:B(`ui/update-model-context`),params:L({content:I(is).optional().describe(`Context content blocks (text, image, etc.).`),structuredContent:z(j(),P().describe(`Structured content for machine-readable context data.`)).optional().describe(`Structured content for machine-readable context data.`)})}),L({method:B(`ui/initialize`),params:L({appInfo:Ya.describe(`App identification (name and version).`),appCapabilities:xc.describe(`Features and capabilities this app provides.`),protocolVersion:j().describe(`Protocol version this app supports.`)})});var Dc=L({protocolVersion:j().describe(`Negotiated protocol version string (e.g., "2025-11-21").`),hostInfo:Ya.describe(`Host application identification and version.`),hostCapabilities:bc.describe(`Features and capabilities provided by the host.`),hostContext:Tc.describe(`Rich context about the host environment.`)}).passthrough(),Oc=class extends nc{_appInfo;_capabilities;options;_hostCapabilities;_hostInfo;_hostContext;constructor(e,t={},n={autoResize:!0}){super(n),this._appInfo=e,this._capabilities=t,this.options=n,this.setRequestHandler(ao,e=>(console.log(`Received ping:`,e.params),{})),this.onhostcontextchanged=()=>{}}getHostCapabilities(){return this._hostCapabilities}getHostVersion(){return this._hostInfo}getHostContext(){return this._hostContext}set ontoolinput(e){this.setNotificationHandler(pc,t=>e(t.params))}set ontoolinputpartial(e){this.setNotificationHandler(mc,t=>e(t.params))}set ontoolresult(e){this.setNotificationHandler(wc,t=>e(t.params))}set ontoolcancelled(e){this.setNotificationHandler(hc,t=>e(t.params))}set onhostcontextchanged(e){this.setNotificationHandler(Ec,t=>{this._hostContext={...this._hostContext,...t.params},e(t.params)})}set onteardown(e){this.setRequestHandler(vc,(t,n)=>e(t.params,n))}set oncalltool(e){this.setRequestHandler(hs,(t,n)=>e(t.params,n))}set onlisttools(e){this.setRequestHandler(ds,(t,n)=>e(t.params,n))}assertCapabilityForMethod(e){}assertRequestHandlerCapability(e){switch(e){case`tools/call`:case`tools/list`:if(!this._capabilities.tools)throw Error(`Client does not support tool capability (required for ${e})`);return;case`ping`:case`ui/resource-teardown`:return;default:throw Error(`No handler for method ${e} registered`)}}assertNotificationCapability(e){}assertTaskCapability(e){throw Error(`Tasks are not supported in MCP Apps`)}assertTaskHandlerCapability(e){throw Error(`Task handlers are not supported in MCP Apps`)}async callServerTool(e,t){if(typeof e==`string`)throw Error(`callServerTool() expects an object as its first argument, but received a string ("${e}"). Did you mean: callServerTool({ name: "${e}", arguments: { ... } })?`);return await this.request({method:`tools/call`,params:e},ps,t)}async readServerResource(e,t){return await this.request({method:`resources/read`,params:e},Ro,t)}async listServerResources(e,t){return await this.request({method:`resources/list`,params:e},Mo,t)}sendMessage(e,t){return this.request({method:`ui/message`,params:e},uc,t)}sendLog(e){return this.notification({method:`notifications/message`,params:e})}updateModelContext(e,t){return this.request({method:`ui/update-model-context`,params:e},Wa,t)}openLink(e,t){return this.request({method:`ui/open-link`,params:e},cc,t)}sendOpenLink=this.openLink;downloadFile(e,t){return this.request({method:`ui/download-file`,params:e},lc,t)}requestTeardown(e={}){return this.notification({method:`ui/notifications/request-teardown`,params:e})}requestDisplayMode(e,t){return this.request({method:`ui/request-display-mode`,params:e},Sc,t)}sendSizeChanged(e){return this.notification({method:`ui/notifications/size-changed`,params:e})}setupSizeChangedNotifications(){let e=!1,t=0,n=0,r=()=>{e||(e=!0,requestAnimationFrame(()=>{e=!1;let r=document.documentElement,i=r.style.height;r.style.height=`max-content`;let a=Math.ceil(r.getBoundingClientRect().height);r.style.height=i;let o=Math.ceil(window.innerWidth);(o!==t||a!==n)&&(t=o,n=a,this.sendSizeChanged({width:o,height:a}))}))};r();let i=new ResizeObserver(r);return i.observe(document.documentElement),i.observe(document.body),()=>i.disconnect()}async connect(e=new ic(window.parent,window.parent),t){if(this.transport)throw Error(`App is already connected. Call close() before connecting again.`);await super.connect(e);try{let e=await this.request({method:`ui/initialize`,params:{appCapabilities:this._capabilities,appInfo:this._appInfo,protocolVersion:rc}},Dc,t);if(e===void 0)throw Error(`Server sent invalid initialize result: ${e}`);this._hostCapabilities=e.hostCapabilities,this._hostInfo=e.hostInfo,this._hostContext=e.hostContext,await this.notification({method:`ui/notifications/initialized`}),this.options?.autoResize&&this.setupSizeChangedNotifications()}catch(e){throw this.close(),e}}};function kc({appInfo:e,capabilities:t,onAppCreated:n}){let[r,i]=(0,l.useState)(null),[a,o]=(0,l.useState)(!1),[s,c]=(0,l.useState)(null);return(0,l.useEffect)(()=>{let r=!0;async function a(){try{let a=new ic(window.parent,window.parent),s=new Oc(e,t);n?.(s),await s.connect(a),r&&(i(s),o(!0),c(null))}catch(e){r&&(i(null),o(!1),c(e instanceof Error?e:Error(`Failed to connect`)))}}return a(),()=>{r=!1}},[]),{app:r,isConnected:a,error:s}}function Ac(){let[e,t]=(0,l.useState)(`loading`),[n,r]=(0,l.useState)(`idle`),[i,a]=(0,l.useState)(null),[o,s]=(0,l.useState)(``),[c,u]=(0,l.useState)(null),[d,f]=(0,l.useState)(null),[p,m]=(0,l.useState)(null),h=(0,l.useRef)(null),[g,_]=(0,l.useState)([]),[v,y]=(0,l.useState)(null),[b,x]=(0,l.useState)({track_title:``,artist_name:``,album_title:``,genre:``}),{app:S,error:ee}=kc({appInfo:{name:`LibreTime File Uploader`,version:`1.0.0`},capabilities:{},onAppCreated:e=>{e.ontoolinput=async e=>{let t=e.arguments;t&&x(e=>({track_title:t.track_title??e.track_title,artist_name:t.artist_name??e.artist_name,album_title:t.album_title??e.album_title,genre:t.genre??e.genre}))},e.ontoolresult=async e=>{let n=e.content?.find(e=>e.type===`text`)?.text;if(n)try{let e=JSON.parse(n);if(e.status===`success`&&e.file){a(e.file),r(`success`);return}u(e.upload_url??null),f(e.upload_token??null),_(e.libraries??[]),t(`ready`)}catch{}},e.onteardown=async()=>({}),e.onerror=console.error}}),C=(0,l.useCallback)(e=>{let t=e.target.files?.[0]??null;if(m(t),t&&!b.track_title){let e=t.name.replace(/\.[^.]+$/,``);x(t=>({...t,track_title:t.track_title||e}))}},[b.track_title]),te=(0,l.useCallback)(e=>t=>{x(n=>({...n,[e]:t.target.value}))},[]),ne=(0,l.useCallback)(async()=>{if(!(!p||!c||!d)){r(`uploading`);try{let e=new FormData;e.append(`file`,p,p.name),e.append(`name`,p.name),e.append(`mime`,p.type||`audio/mpeg`),e.append(`size`,String(p.size)),e.append(`accessed`,String(Math.floor(Date.now()/1e3)));for(let[t,n]of Object.entries(b))n.trim()&&e.append(t,n.trim());v!==null&&e.append(`library`,String(v));let t=await fetch(c,{method:`POST`,headers:{"X-Upload-Token":d},body:e});if(!t.ok){let e=await t.json().catch(()=>({error:t.statusText})),n=e.detail;s(`${e.error??`Upload failed`}${n?`: ${n}`:``}`),r(`error`);return}let n=await t.json();a(n),r(`success`),await S?.updateModelContext({content:[{type:`text`,text:`File uploaded: "${n.track_title??n.name}" (ID: ${n.id})`}]})}catch(e){s(e instanceof Error?e.message:String(e)),r(`error`)}}},[p,c,d,b,v,S]),re=(0,l.useCallback)(()=>{m(null),r(`idle`),s(``),a(null),h.current&&(h.current.value=``)},[]);return{mode:e,uploadState:n,uploadedFile:i,errorMsg:o,file:p,libraries:g,selectedLibrary:v,metadata:b,busy:n===`uploading`,app:S,error:ee,fileInputRef:h,handleFileChange:C,handleMetadataChange:te,handleFilePick:ne,handleReset:re,setSelectedLibrary:y}}var jc=`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xml:space='preserve'%20viewBox='0%200%2064%2064'%20clip-rule='evenodd'%20fill-rule='evenodd'%20image-rendering='optimizeQuality'%20shape-rendering='geometricPrecision'%20text-rendering='geometricPrecision'%3e%3cpath%20fill='%23474443'%20d='M31.993%2018.107c3.32%200%206.36%201.198%208.706%203.179l-3.438%203.444a8.687%208.687%200%200%200-10.522%200l-3.438-3.444a13.443%2013.443%200%200%201%208.692-3.179zm2.08%2026.966L35.711%2059h-7.437l1.653-13.927a13.44%2013.44%200%200%201-6.626-3.016l3.438-3.444a8.72%208.72%200%200%200%204.412%201.73v-1.73h-.84c-1.034%200-1.875-.858-1.875-1.893v-8.072c0-1.035.841-1.892%201.874-1.892h3.38c1.033%200%201.874.857%201.874%201.892v8.072c0%201.035-.841%201.893-1.874%201.893h-.841v1.73a8.72%208.72%200%200%200%204.412-1.73l3.438%203.444a13.44%2013.44%200%200%201-6.626%203.016zm.118-17.312c.399%200%20.723.31.723.71a.724.724%200%200%201-.723.724.722.722%200%200%201-.708-.724c0-.4.325-.71.708-.71zm-1.859.222c.28%200%20.502.222.502.488%200%20.28-.222.488-.502.488a.482.482%200%200%201-.487-.488c0-.266.221-.488.487-.488zm-1.55.074c.222%200%20.414.192.414.414a.413.413%200%200%201-.413.414.404.404%200%200%201-.414-.414c0-.222.177-.414.414-.414zm-1.387.103c.178%200%20.31.133.31.31a.303.303%200%200%201-.31.311.303.303%200%200%201-.31-.31c0-.178.133-.31.31-.31zm4.796%201.7c.399%200%20.723.326.723.725%200%20.384-.324.71-.723.71a.719.719%200%200%201-.708-.71c0-.4.325-.725.708-.725zm-1.859.222a.496.496%200%201%201%20.014.991.495.495%200%200%201-.014-.99zm-1.55.089c.222%200%20.414.177.414.414a.423.423%200%200%201-.413.414.413.413%200%200%201-.414-.414c0-.237.177-.414.414-.414zm-1.387.089c.178%200%20.31.147.31.325%200%20.162-.132.31-.31.31a.312.312%200%200%201-.31-.31c0-.178.133-.325.31-.325zm4.796%201.7a.725.725%200%200%201%200%201.449.722.722%200%200%201-.708-.725c0-.399.325-.724.708-.724zm-1.859.236c.28%200%20.502.222.502.488a.495.495%200%200%201-.989%200c0-.266.221-.488.487-.488zm-1.55.074c.222%200%20.414.192.414.414a.413.413%200%200%201-.413.414.404.404%200%200%201-.414-.414c0-.222.177-.414.414-.414zm-1.387.104c.178%200%20.31.133.31.31%200%20.178-.132.31-.31.31a.303.303%200%200%201-.31-.31c0-.177.133-.31.31-.31zm4.796%201.7c.399%200%20.723.325.723.71a.724.724%200%200%201-.723.724.722.722%200%200%201-.708-.724c0-.385.325-.71.708-.71zm-1.859.222a.495.495%200%201%201%20.014.99.495.495%200%200%201-.014-.99zm-1.55.074c.222%200%20.414.192.414.414a.426.426%200%200%201-.413.428.416.416%200%200%201-.414-.428c0-.222.177-.414.414-.414zm-1.387.103c.178%200%20.31.148.31.31a.315.315%200%200%201-.31.326.315.315%200%200%201-.31-.325c0-.163.133-.31.31-.31zm4.796%201.7a.725.725%200%200%201%200%201.45.722.722%200%200%201-.708-.725c0-.4.325-.725.708-.725zm-1.859.237c.28%200%20.502.222.502.488a.495.495%200%200%201-.989%200c0-.266.221-.488.487-.488zm-1.55.074c.222%200%20.414.177.414.414a.423.423%200%200%201-.413.414.413.413%200%200%201-.414-.414c0-.237.177-.414.414-.414zm-1.387.103c.178%200%20.31.133.31.31a.303.303%200%200%201-.31.311.303.303%200%200%201-.31-.31c0-.178.133-.31.31-.31zm-3.541-11.073a8.654%208.654%200%200%200-2.553%206.15c0%202.41.974%204.583%202.553%206.165l-3.424%203.43a13.53%2013.53%200%200%201-3.97-9.595c0-3.74%201.52-7.126%203.97-9.58zm15.716-3.43a13.522%2013.522%200%200%201%203.97%209.58c0%203.755-1.52%207.14-3.97%209.595l-3.424-3.43a8.689%208.689%200%200%200%202.553-6.165%208.654%208.654%200%200%200-2.553-6.15z'%20/%3e%3cpath%20fill='%23e62129'%20d='M19.317%2044.364a17.903%2017.903%200%200%201-5.254-12.7%2017.93%2017.93%200%200%201%205.254-12.7l-3.424-3.43c-4.117%204.14-6.67%209.832-6.67%2016.13a22.77%2022.77%200%200%200%206.67%2016.13z'%20/%3e%3cpath%20fill='%23e62129'%20d='M44.683%2018.965a17.916%2017.916%200%200%201%205.24%2012.7%2017.89%2017.89%200%200%201-5.24%2012.699l3.424%203.43a22.77%2022.77%200%200%200%206.67-16.13c0-6.298-2.553-11.99-6.67-16.13z'%20/%3e%3cpath%20fill='%23e62129'%20d='M51.206%2012.43a27.143%2027.143%200%200%201%207.954%2019.234c0%207.525-3.04%2014.326-7.954%2019.25l3.423%203.429A31.984%2031.984%200%200%200%2064%2031.664C64%2022.824%2060.429%2014.81%2054.63%209Z'%20/%3e%3cpath%20fill='%23e62129'%20d='M12.794%2050.913A27.152%2027.152%200%200%201%204.84%2031.664c0-7.51%203.04-14.311%207.954-19.234L9.371%209A31.975%2031.975%200%200%200%200%2031.664a31.984%2031.984%200%200%200%209.37%2022.679Z'%20/%3e%3c/svg%3e`,Mc=`data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='utf-8'?%3e%3c!--%20Generator:%20Adobe%20Illustrator%2014.0.0,%20SVG%20Export%20Plug-In%20.%20SVG%20Version:%206.00%20Build%2043363)%20--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20id='Layer_1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20x='0px'%20y='0px'%20width='166.4px'%20height='64px'%20viewBox='0%200%20166.4%2064'%20enable-background='new%200%200%20166.4%2064'%20xml:space='preserve'%3e%3cg%3e%3cg%3e%3crect%20fill='%23FFFF00'%20width='12.8'%20height='64'/%3e%3crect%20x='25.599'%20fill='%23FFFF00'%20width='12.8'%20height='38.4'/%3e%3c/g%3e%3cg%3e%3cpolygon%20fill='%23FFFF00'%20points='51.2,25.6%2051.2,64%2064,64%2064,38.4%2089.6,38.4%2089.6,25.6%20'/%3e%3crect%20x='51.2'%20fill='%23FFFF00'%20width='38.4'%20height='12.8'/%3e%3c/g%3e%3crect%20x='102.4'%20fill='%23FFFF00'%20width='12.801'%20height='64'/%3e%3crect%20x='128'%20fill='%23FFFF00'%20width='12.801'%20height='64'/%3e%3crect%20x='153.6'%20fill='%23FFFF00'%20width='12.8'%20height='64'/%3e%3c/g%3e%3c/svg%3e`,Nc=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),K=e(((e,t)=>{t.exports=Nc()}))();function Pc(){return(0,K.jsxs)(`header`,{className:d.header,children:[(0,K.jsxs)(`div`,{className:d.headerLeft,children:[(0,K.jsx)(`img`,{src:jc,alt:`LibreTime`,className:d.headerIcon}),(0,K.jsx)(`span`,{className:d.headerTitle,children:`LibreTime File Uploader`})]}),(0,K.jsxs)(`a`,{href:`https://powerfm.org`,target:`_blank`,rel:`noopener noreferrer`,className:d.headerRight,children:[(0,K.jsx)(`span`,{className:d.poweredBy,children:`powered by`}),(0,K.jsx)(`img`,{src:Mc,alt:`PowerFM`,className:d.powerFmLogo})]})]})}function Fc(){let{mode:e,uploadState:t,uploadedFile:n,errorMsg:r,file:i,libraries:a,selectedLibrary:o,metadata:s,busy:c,app:l,error:u,fileInputRef:f,handleFileChange:p,handleMetadataChange:m,handleFilePick:h,handleReset:g,setSelectedLibrary:_}=Ac();return u?(0,K.jsxs)(`div`,{className:d.message,children:[`Error: `,u.message]}):!l||e===`loading`?(0,K.jsx)(`div`,{className:d.message,children:`Loading…`}):t===`success`&&n?(0,K.jsxs)(`div`,{className:d.wrapper,children:[(0,K.jsx)(Pc,{}),(0,K.jsxs)(`div`,{className:d.container,children:[(0,K.jsx)(`div`,{className:d.successIcon,children:`✓`}),(0,K.jsx)(`p`,{className:d.successLabel,children:`Uploaded successfully`}),(0,K.jsxs)(`dl`,{className:d.fileDetails,children:[(0,K.jsx)(`dt`,{children:`Title`}),(0,K.jsx)(`dd`,{children:n.track_title??n.name}),n.artist_name&&(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(`dt`,{children:`Artist`}),(0,K.jsx)(`dd`,{children:n.artist_name})]}),n.album_title&&(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(`dt`,{children:`Album`}),(0,K.jsx)(`dd`,{children:n.album_title})]}),n.genre&&(0,K.jsxs)(K.Fragment,{children:[(0,K.jsx)(`dt`,{children:`Genre`}),(0,K.jsx)(`dd`,{children:n.genre})]}),(0,K.jsx)(`dt`,{children:`ID`}),(0,K.jsx)(`dd`,{children:n.id})]}),(0,K.jsx)(`button`,{className:d.button,onClick:g,children:`Upload another`})]})]}):(0,K.jsxs)(`div`,{className:d.wrapper,children:[(0,K.jsx)(Pc,{}),(0,K.jsxs)(`div`,{className:d.container,children:[(0,K.jsxs)(`label`,{className:`${d.dropZone} ${i?d.dropZoneHasFile:``}`,children:[(0,K.jsx)(`input`,{ref:f,type:`file`,accept:`audio/*`,onChange:p,className:d.hiddenInput,disabled:c}),i?(0,K.jsx)(`span`,{className:d.fileName,children:i.name}):(0,K.jsx)(`span`,{className:d.dropHint,children:`Click to choose an audio file`})]}),a.length>0&&(0,K.jsxs)(`label`,{className:d.fieldLabel,children:[(0,K.jsx)(`span`,{className:d.fieldName,children:`Track type`}),(0,K.jsxs)(`select`,{value:o??``,onChange:e=>_(e.target.value?Number(e.target.value):null),className:d.input,disabled:c,children:[(0,K.jsx)(`option`,{value:``,children:`— Select —`}),a.filter(e=>e.enabled).map(e=>(0,K.jsx)(`option`,{value:e.id,children:e.name??e.code},e.id))]})]}),(0,K.jsx)(`div`,{className:d.fields,children:[`track_title`,`artist_name`,`album_title`,`genre`].map(e=>(0,K.jsxs)(`label`,{className:d.fieldLabel,children:[(0,K.jsx)(`span`,{className:d.fieldName,children:e.replace(`_`,` `)}),(0,K.jsx)(`input`,{type:`text`,value:s[e],onChange:m(e),className:d.input,disabled:c,placeholder:`Optional`})]},e))}),t===`error`&&(0,K.jsx)(`p`,{className:d.errorMsg,children:r}),(0,K.jsx)(`button`,{className:d.button,onClick:h,disabled:c||!i,children:c?`Uploading…`:`Upload`})]})]})}(0,u.createRoot)(document.getElementById(`root`)).render((0,K.jsx)(l.StrictMode,{children:(0,K.jsx)(Fc,{})}));</script>
88
88
  <style rel="stylesheet" crossorigin>:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--color-text-primary:var(--lightningcss-light,#1f2937)var(--lightningcss-dark,#f3f4f6);--color-text-secondary:var(--lightningcss-light,#6b7280)var(--lightningcss-dark,#9ca3af);--color-text-inverse:var(--lightningcss-light,#f3f4f6)var(--lightningcss-dark,#1f2937);--color-background-primary:var(--lightningcss-light,#fff)var(--lightningcss-dark,#1a1a1a);--color-background-secondary:var(--lightningcss-light,#f9fafb)var(--lightningcss-dark,#2a2a2a);--color-border:var(--lightningcss-light,#e5e7eb)var(--lightningcss-dark,#374151);--color-accent:#2563eb;--color-text-on-accent:#fff;--color-success:var(--lightningcss-light,#16a34a)var(--lightningcss-dark,#4ade80);--color-error:var(--lightningcss-light,#dc2626)var(--lightningcss-dark,#f87171);--border-radius-md:6px;--border-width-regular:1px;--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-weight-normal:400;--font-weight-bold:700;--font-text-md-size:1rem;--font-text-md-line-height:1.5;--font-heading-md-size:1rem;--font-heading-md-line-height:1.4;--spacing-unit:var(--font-text-md-size);--spacing-xs:calc(var(--spacing-unit) * .25);--spacing-sm:calc(var(--spacing-unit) * .5);--spacing-md:var(--spacing-unit);--spacing-lg:calc(var(--spacing-unit) * 1.5)}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}*{box-sizing:border-box}html,body{font-family:var(--font-sans);font-size:var(--font-text-md-size);font-weight:var(--font-weight-normal);line-height:var(--font-text-md-line-height);color:var(--color-text-primary);background:var(--color-background-primary);margin:0;padding:0}._message_1x8vx_1{padding:var(--spacing-md);font-size:.875rem}._wrapper_1x8vx_6{--color-background-primary:#3d3d3d;--color-background-secondary:#2d2d2d;--color-text-primary:#e0e0e0;--color-text-secondary:#bdbdbd;--color-border:#555;--color-error:#f87171;background:#333;flex-direction:column;width:100%;min-height:100%;display:flex}._header_1x8vx_21{padding:var(--spacing-sm) var(--spacing-md);background:#2d2d2d;border-bottom:2px solid #ff0;justify-content:space-between;align-items:center;display:flex}._headerLeft_1x8vx_30{align-items:center;gap:var(--spacing-sm);display:flex}._headerIcon_1x8vx_36{flex-shrink:0;width:24px;height:24px}._headerTitle_1x8vx_42{font-size:var(--font-text-md-size);font-weight:var(--font-weight-bold);color:#fff}._headerRight_1x8vx_48{flex-direction:column;align-items:flex-end;gap:2px;text-decoration:none;display:flex}._poweredBy_1x8vx_56{text-transform:uppercase;letter-spacing:.05em;color:#999;font-size:.625rem}._powerFmLogo_1x8vx_63{width:auto;height:30px}._successLabel_1x8vx_68{color:var(--color-text-secondary);margin:0;font-size:.875rem}._container_1x8vx_74{padding:var(--spacing-md);gap:var(--spacing-md);box-sizing:border-box;flex-direction:column;width:100%;display:flex}._heading_1x8vx_83{font-size:var(--font-heading-md-size);line-height:var(--font-heading-md-line-height);font-weight:var(--font-weight-bold);margin:0}._dropZone_1x8vx_90{padding:var(--spacing-lg);border:2px dashed var(--color-border);border-radius:var(--border-radius-md);background:var(--color-background-secondary);cursor:pointer;justify-content:center;align-items:center;min-height:80px;transition:border-color .15s;display:flex}._dropZone_1x8vx_90:hover{border-color:#ff0}._dropZoneHasFile_1x8vx_107{border-style:solid;border-color:#ff0}._hiddenInput_1x8vx_112{clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}._dropHint_1x8vx_122{color:var(--color-text-secondary);font-size:.875rem}._fileName_1x8vx_127{word-break:break-all;text-align:center;font-size:.875rem}._fields_1x8vx_133{gap:var(--spacing-sm);flex-direction:column;display:flex}._fieldLabel_1x8vx_139{gap:var(--spacing-xs);flex-direction:column;display:flex}._fieldName_1x8vx_145{font-size:.75rem;font-weight:var(--font-weight-bold);text-transform:capitalize;color:var(--color-text-secondary)}._input_1x8vx_152{width:100%;padding:var(--spacing-xs) var(--spacing-sm);border:var(--border-width-regular) solid var(--color-border);border-radius:var(--border-radius-md);background:var(--color-background-primary);color:var(--color-text-primary);font-family:var(--font-sans);font-size:var(--font-text-md-size)}._input_1x8vx_152::placeholder{color:var(--color-text-secondary);opacity:1}._input_1x8vx_152:focus{border-color:#ff0;outline:none}._button_1x8vx_173{padding:var(--spacing-sm) var(--spacing-md);color:#1a1a1a;border-radius:var(--border-radius-md);font-family:var(--font-sans);font-size:var(--font-text-md-size);font-weight:var(--font-weight-bold);cursor:pointer;background:#ff0;border:none;transition:opacity .15s}._button_1x8vx_173:hover:not(:disabled){opacity:.9}._button_1x8vx_173:disabled{opacity:.5;cursor:not-allowed}._errorMsg_1x8vx_195{color:var(--color-error);margin:0;font-size:.875rem}._success_1x8vx_68{align-items:flex-start;gap:var(--spacing-md);flex-direction:column;display:flex}._successIcon_1x8vx_208{background:var(--color-success);color:#fff;width:40px;height:40px;font-size:1.25rem;font-weight:var(--font-weight-bold);border-radius:50%;justify-content:center;align-items:center;display:flex}._fileDetails_1x8vx_221{gap:var(--spacing-xs) var(--spacing-md);grid-template-columns:auto 1fr;margin:0;font-size:.875rem;display:grid}._fileDetails_1x8vx_221 dt{color:var(--color-text-secondary);font-weight:var(--font-weight-bold);text-transform:capitalize}._fileDetails_1x8vx_221 dd{margin:0}._connecting_1x8vx_239,._error_1x8vx_195{padding:var(--spacing-md);font-size:.875rem}._error_1x8vx_195{color:var(--color-error)}
89
89
  /*$vite$:1*/</style>
90
90
  </head>
@@ -9,6 +9,8 @@ import { register as registerShows } from '../tools/shows/index.js';
9
9
  import { register as registerAnalytics } from '../tools/analytics/index.js';
10
10
  import { register as registerAdmin } from '../tools/admin/index.js';
11
11
  import { register as registerFiles } from '../tools/files/index.js';
12
+ import { register as registerPlaylists } from '../tools/playlists/index.js';
13
+ import { register as registerPrompts } from '../prompts/index.js';
12
14
  import { registerUploadEndpoint } from './upload.js';
13
15
  import { createHttpServer } from './server.js';
14
16
  const PORT = parseInt(process.env.MCP_PORT ?? '3000', 10);
@@ -30,5 +32,7 @@ createHttpServer({
30
32
  registerAnalytics(server);
31
33
  registerAdmin(server);
32
34
  registerFiles(server, uploadUrl, UPLOAD_TOKEN);
35
+ registerPlaylists(server);
36
+ registerPrompts(server);
33
37
  },
34
38
  });
@@ -5,11 +5,13 @@
5
5
  // Intended for network clients. For Claude Desktop use stdio/client.ts.
6
6
  import '../env.js';
7
7
  import { register as registerShows } from '../tools/shows/index.js';
8
+ import { register as registerPrompts } from '../prompts/index.js';
8
9
  import { createHttpServer } from './server.js';
9
10
  createHttpServer({
10
11
  name: 'libretime-mcp-client',
11
12
  defaultPort: 3001,
12
13
  register: (server) => {
13
14
  registerShows(server);
15
+ registerPrompts(server);
14
16
  },
15
17
  });
@@ -30,10 +30,12 @@ export function registerUploadEndpoint(app, publicUrl, uploadToken) {
30
30
  return;
31
31
  }
32
32
  const BASE_URL = process.env.LIBRETIME_URL ?? '';
33
- const USER = process.env.LIBRETIME_USER ?? '';
34
- const PASS = process.env.LIBRETIME_PASS ?? '';
35
- const libreAuth = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
36
- const libreUrl = new URL('/api/v2/files', BASE_URL).toString();
33
+ // /rest/media is the legacy PHP upload endpoint — the only path that writes
34
+ // files to disk and queues the analyzer. Auth uses the LibreTime API key
35
+ // (from config.yml general.api_key) as the Basic Auth username with no password.
36
+ const API_KEY = process.env.LIBRETIME_API_KEY ?? '';
37
+ const libreAuth = 'Basic ' + Buffer.from(`${API_KEY}:`).toString('base64');
38
+ const libreUrl = new URL('/rest/media', BASE_URL).toString();
37
39
  try {
38
40
  const body = await new Promise((resolve, reject) => {
39
41
  const chunks = [];
package/dist/libretime.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const BASE_URL = process.env.LIBRETIME_URL ?? '';
2
2
  const USER = process.env.LIBRETIME_USER ?? '';
3
3
  const PASS = process.env.LIBRETIME_PASS ?? '';
4
+ const API_KEY = process.env.LIBRETIME_API_KEY ?? '';
4
5
  // Basic Auth header value, base64-encoded as the HTTP spec requires
5
6
  const authHeader = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
6
7
  /**
@@ -46,8 +47,11 @@ export async function librePost(path, body) {
46
47
  return response.json();
47
48
  }
48
49
  /**
49
- * Make an authenticated multipart POST request to the LibreTime API.
50
- * Used for file uploads. Returns the parsed JSON response as unknown.
50
+ * BENCHED: multipart POST to /api/v2/files (DRF).
51
+ * Creates a DB record but never writes to disk or queues the analyzer —
52
+ * filepath stays null and import_status stays 1 forever.
53
+ * Kept here for when the DRF endpoint gets the analyzer wiring fixed upstream.
54
+ * See: PLAN.md → Open source contributions → LibreTime file upload
51
55
  */
52
56
  export async function libreUpload(path, formData) {
53
57
  const url = new URL(path, BASE_URL);
@@ -56,8 +60,6 @@ export async function libreUpload(path, formData) {
56
60
  headers: {
57
61
  Authorization: authHeader,
58
62
  Accept: 'application/json',
59
- // Note: do NOT set Content-Type here — fetch sets it automatically
60
- // with the correct multipart boundary when given a FormData body
61
63
  },
62
64
  body: formData,
63
65
  });
@@ -66,6 +68,32 @@ export async function libreUpload(path, formData) {
66
68
  }
67
69
  return response.json();
68
70
  }
71
+ /**
72
+ * Upload a file to /rest/media — the legacy PHP endpoint that triggers the full
73
+ * import workflow (writes to disk, queues the analyzer). The DRF /api/v2/files
74
+ * endpoint creates a DB record but never writes to disk, so this is the only
75
+ * working upload path.
76
+ *
77
+ * Auth uses LIBRETIME_API_KEY (general.api_key from LibreTime config.yml) as
78
+ * Basic Auth username with no password, which is what /rest/media expects.
79
+ */
80
+ export async function libreRestMedia(formData) {
81
+ const url = new URL('/rest/media', BASE_URL);
82
+ const apiKeyAuth = 'Basic ' + Buffer.from(`${API_KEY}:`).toString('base64');
83
+ const response = await fetch(url.toString(), {
84
+ method: 'POST',
85
+ headers: {
86
+ Authorization: apiKeyAuth,
87
+ Accept: 'application/json',
88
+ // Do NOT set Content-Type — fetch sets it with the correct multipart boundary
89
+ },
90
+ body: formData,
91
+ });
92
+ if (!response.ok) {
93
+ throw new Error(`LibreTime upload error: ${response.status} ${response.statusText} — ${url.toString()}`);
94
+ }
95
+ return response.json();
96
+ }
69
97
  /**
70
98
  * Make an authenticated PATCH request to the LibreTime API with a JSON body.
71
99
  * Returns the parsed JSON response as unknown — callers are responsible for validation.
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+ export function register(server) {
3
+ server.registerPrompt('on_air_now', {
4
+ description: 'Check what is currently on air and what is coming up next',
5
+ }, () => ({
6
+ messages: [{
7
+ role: 'user',
8
+ content: {
9
+ type: 'text',
10
+ text: 'What is currently on air right now, and what shows are coming up next? Use get_stream_state for the live status and get_schedule to check today\'s schedule.',
11
+ },
12
+ }],
13
+ }));
14
+ server.registerPrompt('station_status', {
15
+ description: 'Full station overview — stream state, current schedule, and configuration',
16
+ }, () => ({
17
+ messages: [{
18
+ role: 'user',
19
+ content: {
20
+ type: 'text',
21
+ text: 'Give me a full status overview of the station. Check get_stream_state for what is on air, get_schedule for the upcoming schedule, and get_station_info for the station configuration.',
22
+ },
23
+ }],
24
+ }));
25
+ server.registerPrompt('show_overview', {
26
+ description: 'Get a full overview of a specific show including upcoming instances',
27
+ argsSchema: {
28
+ show_name: z.string().describe('Name of the show'),
29
+ },
30
+ }, ({ show_name }) => ({
31
+ messages: [{
32
+ role: 'user',
33
+ content: {
34
+ type: 'text',
35
+ text: `Give me a full overview of the show called "${show_name}". Use get_shows to find it by name, then get_show_instances to list its upcoming scheduled slots.`,
36
+ },
37
+ }],
38
+ }));
39
+ server.registerPrompt('upload_and_schedule', {
40
+ description: 'Upload an audio file and schedule it into a show',
41
+ argsSchema: {
42
+ show_name: z.string().describe('Name of the show to schedule the file into'),
43
+ },
44
+ }, ({ show_name }) => ({
45
+ messages: [{
46
+ role: 'user',
47
+ content: {
48
+ type: 'text',
49
+ text: `I want to upload an audio file and schedule it into the show called "${show_name}". Start by using upload_file to get the file from me. Once it is uploaded, use get_shows to find the show, get_show_instances to find the next available instance, then schedule_file to add it to the schedule.`,
50
+ },
51
+ }],
52
+ }));
53
+ server.registerPrompt('manage_playlist', {
54
+ description: 'View or manage playlists — list all playlists or inspect a specific one',
55
+ argsSchema: {
56
+ playlist_name: z.string().optional().describe('Name of a specific playlist to inspect. Leave blank to list all playlists.'),
57
+ },
58
+ }, ({ playlist_name }) => {
59
+ const text = playlist_name
60
+ ? `Show me the contents of the playlist called "${playlist_name}". Use get_playlists to find it by name, then get_playlist_contents to list its tracks.`
61
+ : 'List all playlists in the media library using get_playlists, and show the contents of each one using get_playlist_contents.';
62
+ return {
63
+ messages: [{ role: 'user', content: { type: 'text', text } }],
64
+ };
65
+ });
66
+ }
@@ -1,12 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'module';
3
+ import crypto from 'node:crypto';
4
+ import express from 'express';
3
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
7
  import { register as registerShows } from '../tools/shows/index.js';
6
8
  import { register as registerAnalytics } from '../tools/analytics/index.js';
7
9
  import { register as registerAdmin } from '../tools/admin/index.js';
8
10
  import { register as registerFiles } from '../tools/files/index.js';
11
+ import { register as registerPlaylists } from '../tools/playlists/index.js';
12
+ import { register as registerPrompts } from '../prompts/index.js';
13
+ import { registerUploadEndpoint } from '../http/upload.js';
9
14
  const { version } = createRequire(import.meta.url)('../../package.json');
15
+ // Spin up a local HTTP server solely for the file upload endpoint.
16
+ // The MCP protocol runs over stdio — this is a side-channel so the
17
+ // iframe UI can POST files without going through the MCP protocol.
18
+ const UPLOAD_PORT = parseInt(process.env.UPLOAD_PORT ?? '4000', 10);
19
+ const PUBLIC_URL = `http://localhost:${UPLOAD_PORT}`;
20
+ const UPLOAD_TOKEN = crypto.randomBytes(32).toString('hex');
21
+ const app = express();
22
+ const uploadUrl = registerUploadEndpoint(app, PUBLIC_URL, UPLOAD_TOKEN);
23
+ app.listen(UPLOAD_PORT, () => {
24
+ console.error(`Upload: POST /upload on port ${UPLOAD_PORT}`);
25
+ });
10
26
  const server = new McpServer({
11
27
  name: 'libretime-mcp-admin',
12
28
  version,
@@ -14,7 +30,9 @@ const server = new McpServer({
14
30
  registerShows(server);
15
31
  registerAnalytics(server);
16
32
  registerAdmin(server);
17
- registerFiles(server);
33
+ registerFiles(server, uploadUrl, UPLOAD_TOKEN);
34
+ registerPlaylists(server);
35
+ registerPrompts(server);
18
36
  const transport = new StdioServerTransport();
19
37
  await server.connect(transport);
20
38
  console.error('LibreTime MCP admin server running (shows + analytics + admin)');
@@ -3,12 +3,14 @@ import { createRequire } from 'module';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { register as registerShows } from '../tools/shows/index.js';
6
+ import { register as registerPrompts } from '../prompts/index.js';
6
7
  const { version } = createRequire(import.meta.url)('../../package.json');
7
8
  const server = new McpServer({
8
9
  name: 'libretime-mcp-client',
9
10
  version,
10
11
  });
11
12
  registerShows(server);
13
+ registerPrompts(server);
12
14
  const transport = new StdioServerTransport();
13
15
  await server.connect(transport);
14
16
  console.error('LibreTime MCP client server running (read-only: shows)');
@@ -3,9 +3,7 @@ import { libreGet } from '../../libretime.js';
3
3
  import { toolText } from '../../tool-response.js';
4
4
  import { ShowHostSchema, UserSchema } from './types.js';
5
5
  export function register(server) {
6
- server.registerTool('get_hosts', {
7
- description: 'Get all show-to-host assignments. Enriches each entry with user details so you can see which presenter hosts which show by name.',
8
- }, async () => {
6
+ server.tool('get_hosts', 'Get all show-to-host assignments. Enriches each entry with user details so you can see which presenter hosts which show by name.', {}, async () => {
9
7
  const [rawHosts, rawUsers] = await Promise.all([
10
8
  libreGet('/api/v2/show-hosts'),
11
9
  libreGet('/api/v2/users'),
@@ -3,13 +3,18 @@ import { libreGet } from '../../libretime.js';
3
3
  import { toolText } from '../../tool-response.js';
4
4
  import { UserSchema } from './types.js';
5
5
  export function register(server) {
6
- server.registerTool('get_users', {
7
- description: 'List all LibreTime users (presenters and admins). Returns id, username, name, email, and role. Roles: G = Guest, H = Host, P = Manager, A = Admin.',
8
- }, async () => {
6
+ server.tool('get_users', 'List all LibreTime users (presenters and admins). Returns id, username, name, and role. Roles: G = Guest, H = Host, P = Manager, A = Admin.', {
7
+ include_email: z.boolean().optional().describe('Include email addresses in the response (default: false)'),
8
+ }, async ({ include_email = false }) => {
9
9
  const raw = await libreGet('/api/v2/users');
10
10
  const users = z.array(UserSchema).parse(raw);
11
11
  const trimmed = users.map(({ id, username, first_name, last_name, email, role }) => ({
12
- id, username, first_name, last_name, email, role,
12
+ id,
13
+ username,
14
+ first_name,
15
+ last_name,
16
+ ...(include_email ? { email } : {}),
17
+ role,
13
18
  }));
14
19
  return toolText(trimmed);
15
20
  });
@@ -1,6 +1,10 @@
1
1
  import { register as registerGetUsers } from './get_users.js';
2
2
  import { register as registerGetHosts } from './get_hosts.js';
3
+ import { register as registerCreateShow } from '../shows/create_show.js';
4
+ import { register as registerScheduleFile } from '../shows/schedule_file.js';
3
5
  export function register(server) {
4
6
  registerGetUsers(server);
5
7
  registerGetHosts(server);
8
+ registerCreateShow(server);
9
+ registerScheduleFile(server);
6
10
  }
@@ -2,13 +2,13 @@ import { z } from 'zod';
2
2
  export const UserSchema = z.object({
3
3
  id: z.number(),
4
4
  username: z.string(),
5
- first_name: z.string(),
6
- last_name: z.string(),
7
- email: z.string(),
8
- role: z.string(),
9
- });
5
+ first_name: z.string().nullable(),
6
+ last_name: z.string().nullable(),
7
+ email: z.string().nullable(),
8
+ role: z.string().nullable(),
9
+ }).passthrough();
10
10
  export const ShowHostSchema = z.object({
11
11
  id: z.number(),
12
12
  show: z.number(),
13
13
  user: z.number(),
14
- });
14
+ }).passthrough();
@@ -1,5 +1,5 @@
1
1
  import { register as registerSearchFiles } from './search_files.js';
2
- import { register as registerUploadFile } from './upload_file.js';
2
+ import { register as registerUploadFile } from './upload_file_legacy.js';
3
3
  import { register as registerUpdateFileMetadata } from './update_file_metadata.js';
4
4
  import { register as registerDeleteFile } from './delete_file.js';
5
5
  export function register(server, uploadUrl, uploadToken) {
@@ -1,3 +1,19 @@
1
+ // ON HOLD — waiting on LibreTime to wire the analyzer in the DRF /api/v2/files endpoint.
2
+ //
3
+ // Current behaviour of /api/v2/files (POST):
4
+ // - Creates a DB record (import_status = 1)
5
+ // - Does NOT write the file to disk
6
+ // - Does NOT queue the RabbitMQ analyzer job
7
+ // - filepath stays null; import_status never transitions to 0
8
+ //
9
+ // The working upload path is in upload_file_legacy.ts (/rest/media, legacy PHP endpoint).
10
+ // index.ts imports from upload_file_legacy.ts until this is resolved upstream.
11
+ //
12
+ // To switch back once the DRF endpoint is fixed:
13
+ // 1. Update index.ts to import from './upload_file.js' instead of './upload_file_legacy.js'
14
+ // 2. Delete upload_file_legacy.ts
15
+ //
16
+ // Upstream context: PLAN.md → Open source contributions → LibreTime file upload
1
17
  import { z } from 'zod';
2
18
  import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from '@modelcontextprotocol/ext-apps/server';
3
19
  import fs from 'node:fs/promises';
@@ -5,8 +21,6 @@ import path from 'node:path';
5
21
  import { libreGet, libreUpload } from '../../libretime.js';
6
22
  import { toolText } from '../../tool-response.js';
7
23
  import { LibreFileSchema, LibrarySchema } from './types.js';
8
- // When running via tsx (dev): __dirname is src/tools/files/ → walk up to project root, then into dist/
9
- // When running compiled: __dirname is dist/tools/files/ → walk up two levels to dist/
10
24
  const HTML_PATH = import.meta.filename.endsWith('.ts')
11
25
  ? path.join(import.meta.dirname, '../../../dist/apps/upload-file.html')
12
26
  : path.join(import.meta.dirname, '../../apps/upload-file.html');
@@ -18,79 +32,131 @@ const metadataFields = {
18
32
  genre: z.string().optional().describe('Genre'),
19
33
  library: z.number().optional().describe('Track type / library ID'),
20
34
  };
35
+ const MIME_MAP = {
36
+ mp3: 'audio/mpeg',
37
+ flac: 'audio/flac',
38
+ wav: 'audio/wav',
39
+ ogg: 'audio/ogg',
40
+ aac: 'audio/aac',
41
+ m4a: 'audio/mp4',
42
+ opus: 'audio/opus',
43
+ };
44
+ function inferMime(filePath) {
45
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
46
+ return MIME_MAP[ext] ?? 'audio/mpeg';
47
+ }
48
+ function appendMetadata(formData, fields) {
49
+ if (fields.track_title)
50
+ formData.append('track_title', fields.track_title);
51
+ if (fields.artist_name)
52
+ formData.append('artist_name', fields.artist_name);
53
+ if (fields.album_title)
54
+ formData.append('album_title', fields.album_title);
55
+ if (fields.genre)
56
+ formData.append('genre', fields.genre);
57
+ if (fields.library)
58
+ formData.append('library', String(fields.library));
59
+ }
21
60
  export function register(server, uploadUrl, uploadToken) {
22
61
  registerAppTool(server, 'upload_file', {
23
- description: 'Upload an audio file to the LibreTime media library. When no URL is provided, opens a file picker. Optionally pre-fill metadata such as track title, artist, album, and genre.',
62
+ 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.',
24
63
  inputSchema: {
25
64
  url: z.string().optional().describe('Publicly accessible URL of the audio file to upload'),
65
+ file_path: z.string().optional().describe('Absolute path to a local audio file — use when running stdio mode'),
26
66
  ...metadataFields,
27
67
  },
28
68
  _meta: { ui: { resourceUri } },
29
- }, async ({ url, track_title, artist_name, album_title, genre, library }) => {
30
- if (!url) {
31
- // Fetch track type options for the UI dropdown
32
- let libraries = [];
69
+ }, async ({ url, file_path, track_title, artist_name, album_title, genre, library }) => {
70
+ const meta = { track_title, artist_name, album_title, genre, library };
71
+ // Priority 1: local file path (stdio mode — server runs on the user's machine)
72
+ if (file_path) {
73
+ let fileBuffer;
33
74
  try {
34
- const raw = await libreGet('/api/v2/libraries');
35
- libraries = LibrarySchema.array().parse(raw);
75
+ fileBuffer = await fs.readFile(file_path);
36
76
  }
37
- catch {
38
- // Non-fatal — UI will just show no dropdown
77
+ catch (err) {
78
+ return toolText({
79
+ status: 'error',
80
+ reason: `Could not read file: ${err instanceof Error ? err.message : String(err)}`,
81
+ });
39
82
  }
40
- // No URL — hand off to the UI.
41
- // If this is the HTTP server, include an upload_url so the UI can POST directly.
42
- // If this is stdio, upload_url is null and the UI will ask for a URL instead.
43
- return toolText({
44
- status: uploadUrl ? 'upload_ready' : 'upload_required',
45
- upload_url: uploadUrl ?? null,
46
- upload_token: uploadToken ?? null,
47
- libraries,
48
- });
49
- }
50
- let fileResponse;
51
- try {
52
- fileResponse = await fetch(url);
53
- if (!fileResponse.ok) {
83
+ const fileName = path.basename(file_path);
84
+ const mime = inferMime(file_path);
85
+ const formData = new FormData();
86
+ formData.append('file', new Blob([new Uint8Array(fileBuffer)], { type: mime }), fileName);
87
+ formData.append('name', fileName);
88
+ formData.append('size', String(fileBuffer.byteLength));
89
+ formData.append('mime', mime);
90
+ formData.append('accessed', String(Math.floor(Date.now() / 1000)));
91
+ appendMetadata(formData, meta);
92
+ try {
93
+ const raw = await libreUpload('/api/v2/files', formData);
94
+ const file = LibreFileSchema.parse(raw);
95
+ return toolText({ status: 'success', file });
96
+ }
97
+ catch (err) {
54
98
  return toolText({
55
99
  status: 'error',
56
- reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}`,
100
+ reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
57
101
  });
58
102
  }
59
103
  }
60
- catch (err) {
61
- return toolText({
62
- status: 'error',
63
- reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}`,
64
- });
104
+ // Priority 2: remote URL — fetch then forward to LibreTime
105
+ if (url) {
106
+ let fileResponse;
107
+ try {
108
+ fileResponse = await fetch(url);
109
+ if (!fileResponse.ok) {
110
+ return toolText({
111
+ status: 'error',
112
+ reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}`,
113
+ });
114
+ }
115
+ }
116
+ catch (err) {
117
+ return toolText({
118
+ status: 'error',
119
+ reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}`,
120
+ });
121
+ }
122
+ const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
123
+ const blob = await fileResponse.blob();
124
+ const mime = blob.type || inferMime(fileName);
125
+ const formData = new FormData();
126
+ formData.append('file', blob, fileName);
127
+ formData.append('name', fileName);
128
+ formData.append('size', String(blob.size));
129
+ formData.append('mime', mime);
130
+ formData.append('accessed', String(Math.floor(Date.now() / 1000)));
131
+ appendMetadata(formData, meta);
132
+ try {
133
+ const raw = await libreUpload('/api/v2/files', formData);
134
+ const file = LibreFileSchema.parse(raw);
135
+ return toolText({ status: 'success', file });
136
+ }
137
+ catch (err) {
138
+ return toolText({
139
+ status: 'error',
140
+ reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
141
+ });
142
+ }
65
143
  }
66
- const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
67
- const blob = await fileResponse.blob();
68
- const formData = new FormData();
69
- formData.append('file', blob, fileName);
70
- if (track_title)
71
- formData.append('track_title', track_title);
72
- if (artist_name)
73
- formData.append('artist_name', artist_name);
74
- if (album_title)
75
- formData.append('album_title', album_title);
76
- if (genre)
77
- formData.append('genre', genre);
78
- if (library)
79
- formData.append('library', String(library));
144
+ // Priority 3: no input — hand off to the file picker UI
145
+ let libraries = [];
80
146
  try {
81
- const raw = await libreUpload('/api/v2/files', formData);
82
- const file = LibreFileSchema.parse(raw);
83
- return toolText({ status: 'success', file });
147
+ const raw = await libreGet('/api/v2/libraries');
148
+ libraries = LibrarySchema.array().parse(raw);
84
149
  }
85
- catch (err) {
86
- return toolText({
87
- status: 'error',
88
- reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
89
- });
150
+ catch {
151
+ // Non-fatal — UI will just show no dropdown
90
152
  }
153
+ return toolText({
154
+ status: uploadUrl ? 'upload_ready' : 'upload_required',
155
+ upload_url: uploadUrl ?? null,
156
+ upload_token: uploadToken ?? null,
157
+ libraries,
158
+ });
91
159
  });
92
- // Serve the bundled React app HTML.
93
- // When an upload URL is configured, allow the iframe to connect to it (CSP connect-src).
94
160
  registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
95
161
  const html = await fs.readFile(HTML_PATH, 'utf-8');
96
162
  return {
@@ -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
+ }
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { librePost } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { PlaylistContentSchema } from './types.js';
5
+ export function register(server) {
6
+ server.tool('add_to_playlist', 'Add a file or stream to a playlist. Use get_playlist_contents to check the current contents and determine the next position.', {
7
+ playlist_id: z.number().describe('Playlist ID to add to'),
8
+ file_id: z.number().optional().describe('File ID to add (use for audio files)'),
9
+ stream_id: z.number().optional().describe('Stream ID to add (use for webstreams)'),
10
+ position: z.number().optional().describe('Position in the playlist (0-based). Appends to end if omitted.'),
11
+ }, async ({ playlist_id, file_id, stream_id, position }) => {
12
+ if (file_id === undefined && stream_id === undefined) {
13
+ return toolText({ status: 'error', reason: 'Either file_id or stream_id must be provided.' });
14
+ }
15
+ const kind = stream_id !== undefined ? 1 : 0;
16
+ const raw = await librePost('/api/v2/playlist-contents', {
17
+ playlist: playlist_id,
18
+ file: file_id ?? null,
19
+ stream: stream_id ?? null,
20
+ kind,
21
+ position: position ?? null,
22
+ offset: 0,
23
+ cue_in: '0:00:00.000000',
24
+ cue_out: null,
25
+ fade_in: null,
26
+ fade_out: null,
27
+ });
28
+ const content = PlaylistContentSchema.parse(raw);
29
+ return toolText({ status: 'added', content });
30
+ });
31
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ import { librePost } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { PlaylistSchema } from './types.js';
5
+ export function register(server) {
6
+ server.tool('create_playlist', 'Create a new playlist in LibreTime. Returns the playlist ID for use with add_to_playlist.', {
7
+ name: z.string().describe('Playlist name'),
8
+ description: z.string().optional().describe('Playlist description'),
9
+ }, async ({ name, description }) => {
10
+ const raw = await librePost('/api/v2/playlists', {
11
+ name,
12
+ description: description ?? null,
13
+ });
14
+ const playlist = PlaylistSchema.parse(raw);
15
+ return toolText(playlist);
16
+ });
17
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { PlaylistContentSchema } from './types.js';
5
+ export function register(server) {
6
+ server.tool('get_playlist_contents', 'List the contents of a playlist.', {
7
+ playlist_id: z.number().describe('Playlist ID'),
8
+ }, async ({ playlist_id }) => {
9
+ const raw = await libreGet('/api/v2/playlist-contents', { playlist: String(playlist_id) });
10
+ const contents = PlaylistContentSchema.array().parse(raw);
11
+ return toolText(contents);
12
+ });
13
+ }
@@ -0,0 +1,10 @@
1
+ import { libreGet } from '../../libretime.js';
2
+ import { toolText } from '../../tool-response.js';
3
+ import { PlaylistSchema } from './types.js';
4
+ export function register(server) {
5
+ server.tool('get_playlists', 'List all playlists in the LibreTime media library.', {}, async () => {
6
+ const raw = await libreGet('/api/v2/playlists');
7
+ const playlists = PlaylistSchema.array().parse(raw);
8
+ return toolText(playlists);
9
+ });
10
+ }
@@ -0,0 +1,10 @@
1
+ import { register as registerGetPlaylists } from './get_playlists.js';
2
+ import { register as registerCreatePlaylist } from './create_playlist.js';
3
+ import { register as registerGetPlaylistContents } from './get_playlist_contents.js';
4
+ import { register as registerAddToPlaylist } from './add_to_playlist.js';
5
+ export function register(server) {
6
+ registerGetPlaylists(server);
7
+ registerCreatePlaylist(server);
8
+ registerGetPlaylistContents(server);
9
+ registerAddToPlaylist(server);
10
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ export const PlaylistSchema = z.object({
3
+ id: z.number(),
4
+ name: z.string(),
5
+ description: z.string().nullable(),
6
+ length: z.string().nullable(),
7
+ owner: z.number().nullable(),
8
+ created_at: z.string().nullable(),
9
+ updated_at: z.string().nullable(),
10
+ }).passthrough();
11
+ export const PlaylistContentSchema = z.object({
12
+ id: z.number(),
13
+ kind: z.number().describe('0=File, 1=Stream, 2=Block'),
14
+ position: z.number().nullable(),
15
+ offset: z.number(),
16
+ length: z.string().nullable(),
17
+ cue_in: z.string().nullable(),
18
+ cue_out: z.string().nullable(),
19
+ fade_in: z.string().nullable(),
20
+ fade_out: z.string().nullable(),
21
+ playlist: z.number().nullable(),
22
+ file: z.number().nullable(),
23
+ stream: z.number().nullable(),
24
+ }).passthrough();
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { librePost } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ShowSchema } from './types.js';
5
+ export function register(server) {
6
+ server.tool('create_show', 'Create a new show in LibreTime. Returns the created show with its ID, which can be used to schedule instances.', {
7
+ name: z.string().describe('Show name'),
8
+ description: z.string().optional().describe('Show description'),
9
+ genre: z.string().optional().describe('Genre'),
10
+ url: z.string().optional().describe('Show website URL'),
11
+ }, async ({ name, description, genre, url }) => {
12
+ const raw = await librePost('/api/v2/shows', {
13
+ name,
14
+ description: description ?? null,
15
+ genre: genre ?? null,
16
+ url: url ?? null,
17
+ linked: false,
18
+ linkable: false,
19
+ auto_playlist_enabled: false,
20
+ auto_playlist_repeat: false,
21
+ override_intro_playlist: false,
22
+ override_outro_playlist: false,
23
+ });
24
+ const show = ShowSchema.parse(raw);
25
+ return toolText(show);
26
+ });
27
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ShowSchema } from './types.js';
5
+ export function register(server) {
6
+ server.tool('get_show', 'Get details of a single LibreTime show by ID.', { id: z.number().describe('Show ID') }, async ({ id }) => {
7
+ const raw = await libreGet(`/api/v2/shows/${id}`);
8
+ const show = ShowSchema.parse(raw);
9
+ return toolText(show);
10
+ });
11
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ShowInstanceSchema } from './types.js';
5
+ export function register(server) {
6
+ server.tool('get_show_instances', 'List scheduled instances of a show. Optionally filter by show ID or date range.', {
7
+ show_id: z.number().optional().describe('Filter by show ID'),
8
+ starts_after: z.string().optional().describe('ISO 8601 datetime — only return instances starting after this time'),
9
+ starts_before: z.string().optional().describe('ISO 8601 datetime — only return instances starting before this time'),
10
+ }, async ({ show_id, starts_after, starts_before }) => {
11
+ const params = {};
12
+ if (show_id !== undefined)
13
+ params.show = String(show_id);
14
+ if (starts_after)
15
+ params.starts_after = starts_after;
16
+ if (starts_before)
17
+ params.starts_before = starts_before;
18
+ const raw = await libreGet('/api/v2/show-instances', params);
19
+ const instances = ShowInstanceSchema.array().parse(raw);
20
+ return toolText(instances);
21
+ });
22
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ const InfoSchema = z.object({
5
+ station_name: z.string(),
6
+ }).passthrough();
7
+ export function register(server) {
8
+ server.tool('get_station_info', 'Get basic station information such as the station name.', {}, async () => {
9
+ const raw = await libreGet('/api/v2/info');
10
+ const info = InfoSchema.parse(raw);
11
+ return toolText(info);
12
+ });
13
+ }
@@ -1,8 +1,14 @@
1
1
  import { register as registerGetShows } from './get_shows.js';
2
+ import { register as registerGetShow } from './get_show.js';
3
+ import { register as registerGetShowInstances } from './get_show_instances.js';
2
4
  import { register as registerGetSchedule } from './get_schedule.js';
3
5
  import { register as registerGetStreamState } from './get_stream_state.js';
6
+ import { register as registerGetStationInfo } from './get_station_info.js';
4
7
  export function register(server) {
5
8
  registerGetShows(server);
9
+ registerGetShow(server);
10
+ registerGetShowInstances(server);
6
11
  registerGetSchedule(server);
7
12
  registerGetStreamState(server);
13
+ registerGetStationInfo(server);
8
14
  }
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ import { libreGet, librePost } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ScheduleItemSchema } from './types.js';
5
+ import { LibreFileSchema } from '../files/types.js';
6
+ // LibreTime duration format: "H:MM:SS.ffffff" → milliseconds
7
+ function parseDurationMs(length) {
8
+ if (!length)
9
+ return null;
10
+ const match = length.match(/^(\d+):(\d{2}):(\d{2})\.(\d+)$/);
11
+ if (!match)
12
+ return null;
13
+ const [, h, m, s, frac] = match;
14
+ const ms = Math.round(parseInt(frac.padEnd(3, '0').slice(0, 3)));
15
+ return (parseInt(h) * 3600 + parseInt(m) * 60 + parseInt(s)) * 1000 + ms;
16
+ }
17
+ export function register(server) {
18
+ server.tool('schedule_file', 'Schedule an uploaded file into a show instance at a specific time. ' +
19
+ 'Fetches the file duration automatically to calculate the end time. ' +
20
+ 'Use get_show_instances to find the instance ID for the show slot you want to fill.', {
21
+ instance_id: z.number().describe('Show instance ID to schedule into'),
22
+ file_id: z.number().describe('ID of the uploaded file to schedule'),
23
+ starts_at: z.string().describe('ISO 8601 datetime for when the file should start playing'),
24
+ position: z.number().optional().describe('Position in the show queue (defaults to 0)'),
25
+ }, async ({ instance_id, file_id, starts_at, position = 0 }) => {
26
+ // Fetch file to get duration for ends_at / cue_out calculation
27
+ const fileRaw = await libreGet(`/api/v2/files/${file_id}`);
28
+ const file = LibreFileSchema.passthrough().parse(fileRaw);
29
+ const length = file.length;
30
+ const durationMs = parseDurationMs(length);
31
+ if (!durationMs) {
32
+ return toolText({
33
+ status: 'error',
34
+ reason: `Could not determine file duration (length: ${length ?? 'null'}). ` +
35
+ 'The file may still be processing — check import_status and try again.',
36
+ });
37
+ }
38
+ const startsDate = new Date(starts_at);
39
+ const endsDate = new Date(startsDate.getTime() + durationMs);
40
+ const ends_at = endsDate.toISOString();
41
+ const cue_out = length;
42
+ const raw = await librePost('/api/v2/schedule', {
43
+ instance: instance_id,
44
+ file: file_id,
45
+ starts_at,
46
+ ends_at,
47
+ cue_in: '0:00:00.000000',
48
+ cue_out,
49
+ position,
50
+ position_status: 0,
51
+ broadcasted: 0,
52
+ });
53
+ const item = ScheduleItemSchema.parse(raw);
54
+ return toolText({ status: 'scheduled', item });
55
+ });
56
+ }
@@ -2,10 +2,27 @@ import { z } from 'zod';
2
2
  export const ShowSchema = z.object({
3
3
  id: z.number(),
4
4
  name: z.string(),
5
- description: z.string(),
6
- genre: z.string(),
7
- url: z.string(),
8
- });
5
+ description: z.string().nullable(),
6
+ genre: z.string().nullable(),
7
+ url: z.string().nullable(),
8
+ linked: z.boolean().optional(),
9
+ linkable: z.boolean().optional(),
10
+ auto_playlist_enabled: z.boolean().optional(),
11
+ auto_playlist_repeat: z.boolean().optional(),
12
+ override_intro_playlist: z.boolean().optional(),
13
+ override_outro_playlist: z.boolean().optional(),
14
+ }).passthrough();
15
+ export const ShowInstanceSchema = z.object({
16
+ id: z.number(),
17
+ starts_at: z.string(),
18
+ ends_at: z.string(),
19
+ filled_time: z.string().nullable(),
20
+ description: z.string().nullable(),
21
+ modified: z.boolean(),
22
+ auto_playlist_built: z.boolean(),
23
+ show: z.number(),
24
+ instance: z.number().nullable(),
25
+ }).passthrough();
9
26
  export const ScheduleItemSchema = z.object({
10
27
  id: z.number(),
11
28
  starts_at: z.string(),
@@ -13,7 +30,7 @@ export const ScheduleItemSchema = z.object({
13
30
  instance: z.number(),
14
31
  file: z.number().nullable(),
15
32
  broadcasted: z.number(),
16
- played: z.boolean(),
33
+ played: z.boolean().nullable(),
17
34
  }).passthrough();
18
35
  export const StreamStateSchema = z.object({
19
36
  input_main_connected: z.boolean(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powerfm/libretime-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",