@ssemble/mcp-server 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Ssemble Templates</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
11
+ background: var(--color-background-primary, #1a1a2e);
12
+ color: var(--color-text-primary, #e0e0e0);
13
+ padding: 16px;
14
+ }
15
+ h2 { font-size: 18px; margin-bottom: 16px; color: var(--color-text-primary, #fff); }
16
+ .grid {
17
+ display: grid;
18
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
19
+ gap: 12px;
20
+ }
21
+ .card {
22
+ background: var(--color-background-secondary, #16213e);
23
+ border-radius: var(--border-radius-md, 8px);
24
+ overflow: hidden;
25
+ cursor: pointer;
26
+ border: 2px solid transparent;
27
+ transition: border-color 0.2s, transform 0.15s;
28
+ }
29
+ .card:hover { border-color: var(--color-border-primary, #4a6cf7); transform: translateY(-2px); }
30
+ .card.selected { border-color: var(--color-border-primary, #4a6cf7); box-shadow: 0 0 12px rgba(74,108,247,0.3); }
31
+ .card.default { border-color: #f7c94a; }
32
+ .card img {
33
+ width: 100%;
34
+ aspect-ratio: 9/16;
35
+ object-fit: cover;
36
+ display: block;
37
+ background: var(--color-background-tertiary, #0f3460);
38
+ }
39
+ .card .placeholder {
40
+ width: 100%;
41
+ aspect-ratio: 9/16;
42
+ background: var(--color-background-tertiary, #0f3460);
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ color: var(--color-text-secondary, #555);
47
+ font-size: 12px;
48
+ }
49
+ .card-label {
50
+ padding: 8px;
51
+ font-size: 13px;
52
+ text-align: center;
53
+ white-space: nowrap;
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ }
57
+ .badge {
58
+ display: inline-block;
59
+ font-size: 10px;
60
+ background: #f7c94a;
61
+ color: #1a1a2e;
62
+ padding: 2px 6px;
63
+ border-radius: 4px;
64
+ margin-left: 4px;
65
+ font-weight: 600;
66
+ }
67
+ .loading { text-align: center; padding: 40px; color: var(--color-text-secondary, #888); }
68
+ .selected-info {
69
+ margin-top: 16px;
70
+ padding: 12px;
71
+ background: var(--color-background-secondary, #16213e);
72
+ border-radius: var(--border-radius-md, 8px);
73
+ font-size: 13px;
74
+ }
75
+ .selected-info code {
76
+ background: var(--color-background-tertiary, #0f3460);
77
+ padding: 2px 6px;
78
+ border-radius: 4px;
79
+ font-family: var(--font-mono, monospace);
80
+ }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div id="app"><div class="loading">Loading templates...</div></div>
85
+ <script type="module" src="./templates-app.js"></script>
86
+ </body>
87
+ </html>
@@ -0,0 +1,74 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ const appEl = document.getElementById('app');
4
+ const app = new App({ name: 'Ssemble Templates', version: '1.0.0' });
5
+
6
+ let selectedTemplateId = null;
7
+ let cachedData = null;
8
+
9
+ function render(data) {
10
+ if (!data || !data.templates) {
11
+ appEl.innerHTML = '<div class="loading">No template data received.</div>';
12
+ return;
13
+ }
14
+
15
+ cachedData = data;
16
+ const { templates, defaultTemplateId } = data;
17
+
18
+ let html = `<h2>Caption Templates (${templates.length})</h2>`;
19
+ html += '<div class="grid">';
20
+
21
+ templates.forEach((t) => {
22
+ const isDefault = t.templateId === defaultTemplateId;
23
+ const isSelected = t.templateId === selectedTemplateId;
24
+ const classes = ['card'];
25
+ if (isDefault && !isSelected) classes.push('default');
26
+ if (isSelected) classes.push('selected');
27
+
28
+ const imgSrc = t.preview || t.gif || '';
29
+ const imgTag = imgSrc
30
+ ? `<img src="${imgSrc}" alt="${t.displayName}" loading="lazy" />`
31
+ : `<div class="placeholder">No preview</div>`;
32
+
33
+ html += `<div class="${classes.join(' ')}" data-id="${t.templateId}" data-name="${t.name}">
34
+ ${imgTag}
35
+ <div class="card-label">
36
+ ${t.displayName}${isDefault ? '<span class="badge">default</span>' : ''}
37
+ </div>
38
+ </div>`;
39
+ });
40
+
41
+ html += '</div>';
42
+
43
+ if (selectedTemplateId) {
44
+ const sel = templates.find((t) => t.templateId === selectedTemplateId);
45
+ if (sel) {
46
+ html += `<div class="selected-info">
47
+ Selected: <strong>${sel.displayName}</strong> — templateId: <code>${sel.templateId}</code>
48
+ </div>`;
49
+ }
50
+ }
51
+
52
+ appEl.innerHTML = html;
53
+
54
+ appEl.querySelectorAll('.card').forEach((card) => {
55
+ card.addEventListener('click', () => {
56
+ selectedTemplateId = card.dataset.id;
57
+ render(cachedData);
58
+ app.updateModelContext({
59
+ text: `User selected template: ${card.dataset.name} (templateId: ${card.dataset.id}). Use this templateId when creating shorts.`,
60
+ });
61
+ });
62
+ });
63
+ }
64
+
65
+ app.ontoolresult = (result) => {
66
+ const data = result.structuredContent || null;
67
+ if (data) {
68
+ render(data);
69
+ } else {
70
+ appEl.innerHTML = '<div class="loading">No structured data available.</div>';
71
+ }
72
+ };
73
+
74
+ app.connect();
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ import { registerListMemeHooks } from './tools/list-meme-hooks.js';
19
19
 
20
20
  // Resources & Prompts
21
21
  import { registerResources } from './resources/static-data.js';
22
+ import { registerAppResources } from './resources/app-resources.js';
22
23
  import { registerPrompts } from './prompts/workflows.js';
23
24
 
24
25
  export function createSsembleMcpServer(apiKey, baseUrl) {
@@ -44,6 +45,7 @@ export function createSsembleMcpServer(apiKey, baseUrl) {
44
45
 
45
46
  // Register resources
46
47
  registerResources(server);
48
+ registerAppResources(server);
47
49
 
48
50
  // Register prompts
49
51
  registerPrompts(server);
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Registers MCP App UI resources for interactive HTML views.
3
+ */
4
+
5
+ import { registerAppResource } from '@modelcontextprotocol/ext-apps/server';
6
+ import { readFileSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const DIST_DIR = join(__dirname, '..', 'apps', 'dist');
12
+
13
+ function loadHtml(filename) {
14
+ return readFileSync(join(DIST_DIR, filename), 'utf-8');
15
+ }
16
+
17
+ const CDN_DOMAINS = ['https://cf.ssemble.com', 'https://*.ssemble.com'];
18
+
19
+ export function registerAppResources(server) {
20
+ registerAppResource(
21
+ server,
22
+ 'Templates Browser',
23
+ 'ui://ssemble/templates-app.html',
24
+ {
25
+ description: 'Interactive grid of caption style templates with GIF previews',
26
+ _meta: {
27
+ ui: {
28
+ csp: {
29
+ resourceDomains: CDN_DOMAINS,
30
+ connectDomains: CDN_DOMAINS,
31
+ },
32
+ },
33
+ },
34
+ },
35
+ async () => ({
36
+ contents: [
37
+ {
38
+ uri: 'ui://ssemble/templates-app.html',
39
+ mimeType: 'text/html;profile=mcp-app',
40
+ text: loadHtml('templates-app.html'),
41
+ },
42
+ ],
43
+ })
44
+ );
45
+
46
+ registerAppResource(
47
+ server,
48
+ 'Music Player',
49
+ 'ui://ssemble/music-app.html',
50
+ {
51
+ description: 'Interactive music player with audio preview and track selection',
52
+ _meta: {
53
+ ui: {
54
+ csp: {
55
+ resourceDomains: CDN_DOMAINS,
56
+ connectDomains: CDN_DOMAINS,
57
+ },
58
+ },
59
+ },
60
+ },
61
+ async () => ({
62
+ contents: [
63
+ {
64
+ uri: 'ui://ssemble/music-app.html',
65
+ mimeType: 'text/html;profile=mcp-app',
66
+ text: loadHtml('music-app.html'),
67
+ },
68
+ ],
69
+ })
70
+ );
71
+
72
+ registerAppResource(
73
+ server,
74
+ 'Game Videos Gallery',
75
+ 'ui://ssemble/game-videos-app.html',
76
+ {
77
+ description: 'Interactive gallery of gameplay overlay videos with preview images',
78
+ _meta: {
79
+ ui: {
80
+ csp: {
81
+ resourceDomains: CDN_DOMAINS,
82
+ connectDomains: CDN_DOMAINS,
83
+ },
84
+ },
85
+ },
86
+ },
87
+ async () => ({
88
+ contents: [
89
+ {
90
+ uri: 'ui://ssemble/game-videos-app.html',
91
+ mimeType: 'text/html;profile=mcp-app',
92
+ text: loadHtml('game-videos-app.html'),
93
+ },
94
+ ],
95
+ })
96
+ );
97
+
98
+ registerAppResource(
99
+ server,
100
+ 'Meme Hooks Gallery',
101
+ 'ui://ssemble/meme-hooks-app.html',
102
+ {
103
+ description: 'Interactive gallery of meme hook clips with preview images',
104
+ _meta: {
105
+ ui: {
106
+ csp: {
107
+ resourceDomains: CDN_DOMAINS,
108
+ connectDomains: CDN_DOMAINS,
109
+ },
110
+ },
111
+ },
112
+ },
113
+ async () => ({
114
+ contents: [
115
+ {
116
+ uri: 'ui://ssemble/meme-hooks-app.html',
117
+ mimeType: 'text/html;profile=mcp-app',
118
+ text: loadHtml('meme-hooks-app.html'),
119
+ },
120
+ ],
121
+ })
122
+ );
123
+
124
+ registerAppResource(
125
+ server,
126
+ 'Shorts Viewer',
127
+ 'ui://ssemble/shorts-app.html',
128
+ {
129
+ description: 'Interactive viewer for generated short clips with video player and viral scores',
130
+ _meta: {
131
+ ui: {
132
+ csp: {
133
+ resourceDomains: CDN_DOMAINS,
134
+ connectDomains: CDN_DOMAINS,
135
+ },
136
+ },
137
+ },
138
+ },
139
+ async () => ({
140
+ contents: [
141
+ {
142
+ uri: 'ui://ssemble/shorts-app.html',
143
+ mimeType: 'text/html;profile=mcp-app',
144
+ text: loadHtml('shorts-app.html'),
145
+ },
146
+ ],
147
+ })
148
+ );
149
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
2
3
  import { formatShortsResponse, formatError } from '../utils/format.js';
3
4
 
4
5
  const schema = {
@@ -6,14 +7,45 @@ const schema = {
6
7
  };
7
8
 
8
9
  export function registerGetShorts(server, client) {
9
- server.tool(
10
+ registerAppTool(
11
+ server,
10
12
  'get_shorts',
11
- 'Retrieve all generated short clips for a completed request. Returns video URLs, AI-generated titles, descriptions, viral scores, and durations sorted by viral score.',
12
- schema,
13
+ {
14
+ title: 'Get Shorts',
15
+ description:
16
+ 'Retrieve all generated short clips for a completed request. Returns video URLs, AI-generated titles, descriptions, viral scores, and durations sorted by viral score.',
17
+ inputSchema: schema,
18
+ _meta: {
19
+ ui: { resourceUri: 'ui://ssemble/shorts-app.html' },
20
+ },
21
+ },
13
22
  async ({ requestId }) => {
14
23
  try {
15
24
  const result = await client.getShorts(requestId);
16
- return { content: [{ type: 'text', text: formatShortsResponse(result) }] };
25
+ const textFallback = formatShortsResponse(result);
26
+ const d = result.data;
27
+
28
+ const structuredContent = {
29
+ title: d.title || '',
30
+ url: d.url || '',
31
+ start: d.start,
32
+ end: d.end,
33
+ language: d.language || '',
34
+ template: d.template || '',
35
+ shorts: (d.shorts || []).map((s) => ({
36
+ title: s.title || '',
37
+ description: s.description || '',
38
+ duration: s.duration || 0,
39
+ viral_score: s.viral_score != null ? s.viral_score : 0,
40
+ video_url: s.video_url || '',
41
+ error: !!s.error,
42
+ })),
43
+ };
44
+
45
+ return {
46
+ content: [{ type: 'text', text: textFallback }],
47
+ structuredContent,
48
+ };
17
49
  } catch (error) {
18
50
  return { content: [{ type: 'text', text: formatError(error) }], isError: true };
19
51
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
2
3
  import { formatPaginatedAssets, formatError } from '../utils/format.js';
3
4
 
4
5
  const schema = {
@@ -7,14 +8,43 @@ const schema = {
7
8
  };
8
9
 
9
10
  export function registerListGameVideos(server, client) {
10
- server.tool(
11
+ registerAppTool(
12
+ server,
11
13
  'list_game_videos',
12
- 'List available gameplay videos for split-screen overlays (content top, game bottom). Use the exact gameVideoName when creating shorts.',
13
- schema,
14
+ {
15
+ title: 'List Game Videos',
16
+ description:
17
+ 'List available gameplay videos for split-screen overlays (content top, game bottom). Use the exact gameVideoName when creating shorts.',
18
+ inputSchema: schema,
19
+ _meta: {
20
+ ui: { resourceUri: 'ui://ssemble/game-videos-app.html' },
21
+ },
22
+ },
14
23
  async (params) => {
15
24
  try {
16
25
  const result = await client.listGameVideos(params);
17
- return { content: [{ type: 'text', text: formatPaginatedAssets(result, 'gameVideos', 'Gameplay Overlays') }] };
26
+ const textFallback = formatPaginatedAssets(result, 'gameVideos', 'Gameplay Overlays');
27
+
28
+ const items = result.data?.gameVideos || [];
29
+ const pagination = result.data?.pagination || {};
30
+ const structuredContent = {
31
+ gameVideos: items.map((g) => ({
32
+ name: g.name,
33
+ preview: g.preview || '',
34
+ video: g.video || '',
35
+ video_duration: g.video_duration || 0,
36
+ })),
37
+ pagination: {
38
+ page: pagination.page || 1,
39
+ totalPages: pagination.totalPages || 1,
40
+ totalCount: pagination.totalCount || items.length,
41
+ },
42
+ };
43
+
44
+ return {
45
+ content: [{ type: 'text', text: textFallback }],
46
+ structuredContent,
47
+ };
18
48
  } catch (error) {
19
49
  return { content: [{ type: 'text', text: formatError(error) }], isError: true };
20
50
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
2
3
  import { formatPaginatedAssets, formatError } from '../utils/format.js';
3
4
 
4
5
  const schema = {
@@ -7,14 +8,43 @@ const schema = {
7
8
  };
8
9
 
9
10
  export function registerListMemeHooks(server, client) {
10
- server.tool(
11
+ registerAppTool(
12
+ server,
11
13
  'list_meme_hooks',
12
- 'List available meme hook clips (2-5 second attention grabbers prepended to shorts). Use the exact memeHookName when creating shorts.',
13
- schema,
14
+ {
15
+ title: 'List Meme Hooks',
16
+ description:
17
+ 'List available meme hook clips (2-5 second attention grabbers prepended to shorts). Use the exact memeHookName when creating shorts.',
18
+ inputSchema: schema,
19
+ _meta: {
20
+ ui: { resourceUri: 'ui://ssemble/meme-hooks-app.html' },
21
+ },
22
+ },
14
23
  async (params) => {
15
24
  try {
16
25
  const result = await client.listMemeHooks(params);
17
- return { content: [{ type: 'text', text: formatPaginatedAssets(result, 'memeHooks', 'Meme Hooks') }] };
26
+ const textFallback = formatPaginatedAssets(result, 'memeHooks', 'Meme Hooks');
27
+
28
+ const items = result.data?.memeHooks || [];
29
+ const pagination = result.data?.pagination || {};
30
+ const structuredContent = {
31
+ memeHooks: items.map((h) => ({
32
+ name: h.name,
33
+ preview: h.preview || '',
34
+ video: h.video || '',
35
+ video_duration: h.video_duration || 0,
36
+ })),
37
+ pagination: {
38
+ page: pagination.page || 1,
39
+ totalPages: pagination.totalPages || 1,
40
+ totalCount: pagination.totalCount || items.length,
41
+ },
42
+ };
43
+
44
+ return {
45
+ content: [{ type: 'text', text: textFallback }],
46
+ structuredContent,
47
+ };
18
48
  } catch (error) {
19
49
  return { content: [{ type: 'text', text: formatError(error) }], isError: true };
20
50
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
2
3
  import { formatPaginatedAssets, formatError } from '../utils/format.js';
3
4
 
4
5
  const schema = {
@@ -7,14 +8,43 @@ const schema = {
7
8
  };
8
9
 
9
10
  export function registerListMusic(server, client) {
10
- server.tool(
11
+ registerAppTool(
12
+ server,
11
13
  'list_music',
12
- 'List available background music tracks with names and durations. Use the exact musicName when creating shorts.',
13
- schema,
14
+ {
15
+ title: 'List Music',
16
+ description:
17
+ 'List available background music tracks with names and durations. Use the exact musicName when creating shorts.',
18
+ inputSchema: schema,
19
+ _meta: {
20
+ ui: { resourceUri: 'ui://ssemble/music-app.html' },
21
+ },
22
+ },
14
23
  async (params) => {
15
24
  try {
16
25
  const result = await client.listMusic(params);
17
- return { content: [{ type: 'text', text: formatPaginatedAssets(result, 'music', 'Background Music') }] };
26
+ const textFallback = formatPaginatedAssets(result, 'music', 'Background Music');
27
+
28
+ // Structured content for the MCP App
29
+ const items = result.data?.music || [];
30
+ const pagination = result.data?.pagination || {};
31
+ const structuredContent = {
32
+ music: items.map((t) => ({
33
+ name: t.name,
34
+ duration: t.duration,
35
+ url: t.url || '',
36
+ })),
37
+ pagination: {
38
+ page: pagination.page || 1,
39
+ totalPages: pagination.totalPages || 1,
40
+ totalCount: pagination.totalCount || items.length,
41
+ },
42
+ };
43
+
44
+ return {
45
+ content: [{ type: 'text', text: textFallback }],
46
+ structuredContent,
47
+ };
18
48
  } catch (error) {
19
49
  return { content: [{ type: 'text', text: formatError(error) }], isError: true };
20
50
  }
@@ -1,14 +1,73 @@
1
- import { formatTemplatesResponse, formatError } from '../utils/format.js';
1
+ import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
2
+ import { readFileSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { formatError, formatRateLimits } from '../utils/format.js';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const THUMB_DIR = join(__dirname, '..', 'assets', 'thumbnails');
9
+
10
+ function loadThumbnail(templateName) {
11
+ try {
12
+ const data = readFileSync(join(THUMB_DIR, `${templateName}.jpg`));
13
+ return { data: data.toString('base64'), mimeType: 'image/jpeg' };
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
2
18
 
3
19
  export function registerListTemplates(server, client) {
4
- server.tool(
20
+ registerAppTool(
21
+ server,
5
22
  'list_templates',
6
- 'List all available caption style templates with IDs and names. Use the templateId when creating shorts.',
7
- {},
23
+ {
24
+ title: 'List Templates',
25
+ description:
26
+ 'List all available caption style templates with preview thumbnails, names, and IDs. Use the templateId when creating shorts.',
27
+ _meta: {
28
+ ui: { resourceUri: 'ui://ssemble/templates-app.html' },
29
+ },
30
+ },
8
31
  async () => {
9
32
  try {
10
33
  const result = await client.listTemplates();
11
- return { content: [{ type: 'text', text: formatTemplatesResponse(result) }] };
34
+ const d = result.data;
35
+ const templates = d.templates || [];
36
+
37
+ // Text + image content for non-app clients (Claude Code, Cursor, etc.)
38
+ const content = [];
39
+ content.push({
40
+ type: 'text',
41
+ text: `## Caption Templates (${templates.length} available)\n`,
42
+ });
43
+
44
+ templates.forEach((t) => {
45
+ const isDefault = t.templateId === d.defaultTemplateId;
46
+ const thumb = loadThumbnail(t.name);
47
+ if (thumb) {
48
+ content.push({ type: 'image', data: thumb.data, mimeType: thumb.mimeType });
49
+ }
50
+ const label = `**${t.displayName || t.name}**${isDefault ? ' _(default)_' : ''}`;
51
+ content.push({ type: 'text', text: label });
52
+ });
53
+
54
+ let footer = `\nUse the \`templateId\` field when calling \`create_short\` to pick a style.`;
55
+ footer += formatRateLimits(result.rateLimits);
56
+ content.push({ type: 'text', text: footer });
57
+
58
+ // Structured content for the MCP App
59
+ const structuredContent = {
60
+ templates: templates.map((t) => ({
61
+ templateId: t.templateId,
62
+ name: t.name,
63
+ displayName: t.displayName || t.name,
64
+ gif: t.gif || '',
65
+ preview: t.preview || '',
66
+ })),
67
+ defaultTemplateId: d.defaultTemplateId,
68
+ };
69
+
70
+ return { content, structuredContent };
12
71
  } catch (error) {
13
72
  return { content: [{ type: 'text', text: formatError(error) }], isError: true };
14
73
  }
@@ -144,14 +144,18 @@ export function formatPaginatedAssets(result, assetKey, assetLabel) {
144
144
  return `No ${assetLabel} found.${formatRateLimits(result.rateLimits)}`;
145
145
  }
146
146
 
147
+ const hasUrl = items.some((item) => item.url);
148
+ const hasDuration = items[0]?.duration != null;
149
+
147
150
  let text = `## ${assetLabel} (Page ${p.page}/${p.totalPages}, ${p.totalCount} total)\n\n`;
148
- text += `| # | Name |${items[0]?.duration != null ? ' Duration |' : ''}\n`;
149
- text += `|---|------|${items[0]?.duration != null ? '----------|' : ''}\n`;
151
+ text += `| # | Name |${hasDuration ? ' Duration |' : ''}${hasUrl ? ' Listen |' : ''}\n`;
152
+ text += `|---|------|${hasDuration ? '----------|' : ''}${hasUrl ? '--------|' : ''}\n`;
150
153
 
151
154
  items.forEach((item, i) => {
152
155
  const num = (p.page - 1) * p.limit + i + 1;
153
- const durationCol = item.duration != null ? ` ${item.duration}s |` : '';
154
- text += `| ${num} | ${item.name} |${durationCol}\n`;
156
+ const durationCol = hasDuration ? ` ${Math.round(item.duration)}s |` : '';
157
+ const listenCol = hasUrl ? ` ${item.url ? `[▶ Play](${item.url})` : ''} |` : '';
158
+ text += `| ${num} | ${item.name} |${durationCol}${listenCol}\n`;
155
159
  });
156
160
 
157
161
  if (p.totalPages > 1) {