vlc-files 0.0.1
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/dist/index.cjs +198 -0
- package/dist/index.js +198 -0
- package/dist/package.json +1 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +16 -0
- package/src/index.js +150 -0
- package/src/index.ts +218 -0
- package/tsconfig.json +13 -0
- package/vlc-files-0.0.1.tgz +0 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { join, sep, relative } = require("path");
|
|
3
|
+
const { readdir, stat } = require("fs/promises");
|
|
4
|
+
const { spawn } = require("child_process");
|
|
5
|
+
|
|
6
|
+
// ─── Path utilities ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function sanitize(input, root) {
|
|
9
|
+
const normalized = input.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
10
|
+
if (normalized === "" || normalized === ".") return root;
|
|
11
|
+
const sanitized = normalized.replace(/\.\./g, "").replace(/^\//, "");
|
|
12
|
+
return join(root, sanitized).replace(/\\/g, "/");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── VLC arg whitelist ────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const ALLOWED_VLC_ARGS = new Set([
|
|
18
|
+
"--play-and-pause",
|
|
19
|
+
"--play",
|
|
20
|
+
"--pause",
|
|
21
|
+
"--stop",
|
|
22
|
+
"--rate",
|
|
23
|
+
"--aspect-ratio",
|
|
24
|
+
"--audio",
|
|
25
|
+
"--volume",
|
|
26
|
+
"--zoom",
|
|
27
|
+
"--fullscreen",
|
|
28
|
+
"--no-fullscreen",
|
|
29
|
+
"--video-on-top",
|
|
30
|
+
"--video-wallpaper",
|
|
31
|
+
"--snapshot-path",
|
|
32
|
+
"--video-snapshot",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function buildCvlcArgs(raw) {
|
|
36
|
+
const filtered = (raw || "--play-and-pause").split(/\s+/).filter(Boolean).filter((a) => ALLOWED_VLC_ARGS.has(a));
|
|
37
|
+
return filtered.length > 0 ? filtered : ["--play-and-pause"];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Media kind detection ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function mediaKind(name) {
|
|
43
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
44
|
+
if (["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg"].includes(ext))
|
|
45
|
+
return "video";
|
|
46
|
+
if (["mp3", "flac", "wav", "aac", "ogg", "m4a", "wma", "ape"].includes(ext)) return "audio";
|
|
47
|
+
if (["srt", "ssa", "ass", "vtt", "sub"].includes(ext)) return "subtitle";
|
|
48
|
+
return "other";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Plugin entry ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
id: "vlc-files",
|
|
55
|
+
name: "vlc-files",
|
|
56
|
+
description: "Media library browsing and VLC playback",
|
|
57
|
+
configSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
additionalProperties: true,
|
|
60
|
+
properties: {
|
|
61
|
+
mediaRoot: { type: "string", default: "/media/raven/M&Serials" },
|
|
62
|
+
vlcArgs: { type: "string", default: "--play-and-pause" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
register(api) {
|
|
66
|
+
// ── media_list ────────────────────────────────────────────────────────────
|
|
67
|
+
api.registerTool(
|
|
68
|
+
(ctx) => {
|
|
69
|
+
const pluginConfig = (ctx.config && ctx.config.plugins && ctx.config.plugins.entries && ctx.config.plugins.entries["vlc-files"] && ctx.config.plugins.entries["vlc-files"].config) || {};
|
|
70
|
+
const root = pluginConfig.mediaRoot;
|
|
71
|
+
if (!root) {
|
|
72
|
+
return {
|
|
73
|
+
name: "media_list",
|
|
74
|
+
label: "media_list",
|
|
75
|
+
description: "List files in the media library",
|
|
76
|
+
parameters: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
path: { type: "string", description: "Relative subdirectory under mediaRoot (default: root)", default: "" },
|
|
80
|
+
includeKind: { type: "boolean", description: "Include media kind (video/audio/subtitle) and mtime for files", default: false },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
execute(id, params) {
|
|
84
|
+
return { content: [{ type: "text", text: "mediaRoot not configured" }], isError: true, details: undefined };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
name: "media_list",
|
|
90
|
+
label: "media_list",
|
|
91
|
+
description: "List files in the media library",
|
|
92
|
+
parameters: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
path: { type: "string", description: "Relative subdirectory under mediaRoot (default: root)", default: "" },
|
|
96
|
+
includeKind: { type: "boolean", description: "Include media kind (video/audio/subtitle) and mtime for files", default: false },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
async execute(id, { path: target, includeKind }) {
|
|
100
|
+
const safePath = sanitize(target || "", root);
|
|
101
|
+
if (!safePath.startsWith(root)) {
|
|
102
|
+
return { content: [{ type: "text", text: "Path escapes media root" }], isError: true, details: undefined };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const dirents = await readdir(safePath, { withFileTypes: true });
|
|
106
|
+
const entries = dirents.map((e) => {
|
|
107
|
+
const name = String(e.name);
|
|
108
|
+
const type = e.isDirectory() ? "dir" : "file";
|
|
109
|
+
const fullPath = join(safePath, name);
|
|
110
|
+
const rel = sep === "/" ? relative(root, fullPath) : relative(root, fullPath).replace(/\\/g, "/");
|
|
111
|
+
return { name, type, path: rel };
|
|
112
|
+
});
|
|
113
|
+
if (includeKind) {
|
|
114
|
+
for (const e of entries.filter((e) => e.type === "file")) {
|
|
115
|
+
try {
|
|
116
|
+
const s = await stat(join(safePath, e.name));
|
|
117
|
+
e.kind = mediaKind(e.name);
|
|
118
|
+
e.size = s.size;
|
|
119
|
+
e.modified = s.mtime.toISOString();
|
|
120
|
+
} catch (_) {
|
|
121
|
+
e.kind = "other";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
entries.sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name));
|
|
126
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, entries, path: target || "." }) }], details: undefined };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { content: [{ type: "text", text: String(err.message || err) }], isError: true, details: undefined };
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
{ names: ["media_list"] }
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// ── vlc_play ──────────────────────────────────────────────────────────────
|
|
137
|
+
api.registerTool(
|
|
138
|
+
(ctx) => {
|
|
139
|
+
const pluginConfig = (ctx.config && ctx.config.plugins && ctx.config.plugins.entries && ctx.config.plugins.entries["vlc-files"] && ctx.config.plugins.entries["vlc-files"].config) || {};
|
|
140
|
+
const root = pluginConfig.mediaRoot;
|
|
141
|
+
if (!root) {
|
|
142
|
+
return {
|
|
143
|
+
name: "vlc_play",
|
|
144
|
+
label: "vlc_play",
|
|
145
|
+
description: "Play a media file with VLC on the host",
|
|
146
|
+
parameters: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: { file: { type: "string", description: "Relative media file path (relative to mediaRoot)" } },
|
|
149
|
+
required: ["file"],
|
|
150
|
+
},
|
|
151
|
+
execute(id, params) {
|
|
152
|
+
return { content: [{ type: "text", text: "mediaRoot not configured" }], isError: true, details: undefined };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
name: "vlc_play",
|
|
158
|
+
label: "vlc_play",
|
|
159
|
+
description: "Play a media file with VLC on the host",
|
|
160
|
+
parameters: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: { file: { type: "string", description: "Relative media file path (relative to mediaRoot)" } },
|
|
163
|
+
required: ["file"],
|
|
164
|
+
},
|
|
165
|
+
async execute(id, { file }) {
|
|
166
|
+
const safePath = sanitize(file, root);
|
|
167
|
+
if (!safePath.startsWith(root)) {
|
|
168
|
+
return { content: [{ type: "text", text: "Path escapes media root" }], isError: true, details: undefined };
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const st = await stat(safePath);
|
|
172
|
+
if (!st.isFile()) {
|
|
173
|
+
return { content: [{ type: "text", text: "Not a file" }], isError: true, details: undefined };
|
|
174
|
+
}
|
|
175
|
+
} catch (_) {
|
|
176
|
+
return { content: [{ type: "text", text: "File not found" }], isError: true, details: undefined };
|
|
177
|
+
}
|
|
178
|
+
const cvlcArgs = buildCvlcArgs(pluginConfig.vlcArgs);
|
|
179
|
+
const kind = mediaKind(file);
|
|
180
|
+
const child = spawn("cvlc", [...cvlcArgs, safePath], { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
181
|
+
const stderrLines = [];
|
|
182
|
+
child.stderr && child.stderr.on("data", (chunk) => { if (stderrLines.length < 8) stderrLines.push(chunk.toString().trim()); });
|
|
183
|
+
child.unref();
|
|
184
|
+
const exitCode = await Promise.race([
|
|
185
|
+
new Promise((resolve) => child.on("exit", (code) => resolve(code || 0))),
|
|
186
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 1500)),
|
|
187
|
+
]);
|
|
188
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
189
|
+
return { content: [{ type: "text", text: `cvlc exited ${exitCode}: ${stderrLines.join("\n")}` }], isError: true, details: undefined };
|
|
190
|
+
}
|
|
191
|
+
return { content: [{ type: "text", text: `Playing: ${relative(root, safePath)}` }], details: undefined };
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
{ names: ["vlc_play"] }
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { join, sep, relative } = require("path");
|
|
3
|
+
const { readdir, stat } = require("fs/promises");
|
|
4
|
+
const { spawn } = require("child_process");
|
|
5
|
+
|
|
6
|
+
// ─── Path utilities ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function sanitize(input, root) {
|
|
9
|
+
const normalized = input.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
10
|
+
if (normalized === "" || normalized === ".") return root;
|
|
11
|
+
const sanitized = normalized.replace(/\.\./g, "").replace(/^\//, "");
|
|
12
|
+
return join(root, sanitized).replace(/\\/g, "/");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── VLC arg whitelist ────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const ALLOWED_VLC_ARGS = new Set([
|
|
18
|
+
"--play-and-pause",
|
|
19
|
+
"--play",
|
|
20
|
+
"--pause",
|
|
21
|
+
"--stop",
|
|
22
|
+
"--rate",
|
|
23
|
+
"--aspect-ratio",
|
|
24
|
+
"--audio",
|
|
25
|
+
"--volume",
|
|
26
|
+
"--zoom",
|
|
27
|
+
"--fullscreen",
|
|
28
|
+
"--no-fullscreen",
|
|
29
|
+
"--video-on-top",
|
|
30
|
+
"--video-wallpaper",
|
|
31
|
+
"--snapshot-path",
|
|
32
|
+
"--video-snapshot",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function buildCvlcArgs(raw) {
|
|
36
|
+
const filtered = (raw || "--play-and-pause").split(/\s+/).filter(Boolean).filter((a) => ALLOWED_VLC_ARGS.has(a));
|
|
37
|
+
return filtered.length > 0 ? filtered : ["--play-and-pause"];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Media kind detection ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function mediaKind(name) {
|
|
43
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
44
|
+
if (["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg"].includes(ext))
|
|
45
|
+
return "video";
|
|
46
|
+
if (["mp3", "flac", "wav", "aac", "ogg", "m4a", "wma", "ape"].includes(ext)) return "audio";
|
|
47
|
+
if (["srt", "ssa", "ass", "vtt", "sub"].includes(ext)) return "subtitle";
|
|
48
|
+
return "other";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Plugin entry ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
id: "vlc-files",
|
|
55
|
+
name: "vlc-files",
|
|
56
|
+
description: "Media library browsing and VLC playback",
|
|
57
|
+
configSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
additionalProperties: true,
|
|
60
|
+
properties: {
|
|
61
|
+
mediaRoot: { type: "string", default: "/media/raven/M&Serials" },
|
|
62
|
+
vlcArgs: { type: "string", default: "--play-and-pause" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
register(api) {
|
|
66
|
+
// ── media_list ────────────────────────────────────────────────────────────
|
|
67
|
+
api.registerTool(
|
|
68
|
+
(ctx) => {
|
|
69
|
+
const pluginConfig = (ctx.config && ctx.config.plugins && ctx.config.plugins.entries && ctx.config.plugins.entries["vlc-files"] && ctx.config.plugins.entries["vlc-files"].config) || {};
|
|
70
|
+
const root = pluginConfig.mediaRoot;
|
|
71
|
+
if (!root) {
|
|
72
|
+
return {
|
|
73
|
+
name: "media_list",
|
|
74
|
+
label: "media_list",
|
|
75
|
+
description: "List files in the media library",
|
|
76
|
+
parameters: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
path: { type: "string", description: "Relative subdirectory under mediaRoot (default: root)", default: "" },
|
|
80
|
+
includeKind: { type: "boolean", description: "Include media kind (video/audio/subtitle) and mtime for files", default: false },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
execute(id, params) {
|
|
84
|
+
return { content: [{ type: "text", text: "mediaRoot not configured" }], isError: true, details: undefined };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
name: "media_list",
|
|
90
|
+
label: "media_list",
|
|
91
|
+
description: "List files in the media library",
|
|
92
|
+
parameters: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
path: { type: "string", description: "Relative subdirectory under mediaRoot (default: root)", default: "" },
|
|
96
|
+
includeKind: { type: "boolean", description: "Include media kind (video/audio/subtitle) and mtime for files", default: false },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
async execute(id, { path: target, includeKind }) {
|
|
100
|
+
const safePath = sanitize(target || "", root);
|
|
101
|
+
if (!safePath.startsWith(root)) {
|
|
102
|
+
return { content: [{ type: "text", text: "Path escapes media root" }], isError: true, details: undefined };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const dirents = await readdir(safePath, { withFileTypes: true });
|
|
106
|
+
const entries = dirents.map((e) => {
|
|
107
|
+
const name = String(e.name);
|
|
108
|
+
const type = e.isDirectory() ? "dir" : "file";
|
|
109
|
+
const fullPath = join(safePath, name);
|
|
110
|
+
const rel = sep === "/" ? relative(root, fullPath) : relative(root, fullPath).replace(/\\/g, "/");
|
|
111
|
+
return { name, type, path: rel };
|
|
112
|
+
});
|
|
113
|
+
if (includeKind) {
|
|
114
|
+
for (const e of entries.filter((e) => e.type === "file")) {
|
|
115
|
+
try {
|
|
116
|
+
const s = await stat(join(safePath, e.name));
|
|
117
|
+
e.kind = mediaKind(e.name);
|
|
118
|
+
e.size = s.size;
|
|
119
|
+
e.modified = s.mtime.toISOString();
|
|
120
|
+
} catch (_) {
|
|
121
|
+
e.kind = "other";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
entries.sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name));
|
|
126
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, entries, path: target || "." }) }], details: undefined };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { content: [{ type: "text", text: String(err.message || err) }], isError: true, details: undefined };
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
{ names: ["media_list"] }
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// ── vlc_play ──────────────────────────────────────────────────────────────
|
|
137
|
+
api.registerTool(
|
|
138
|
+
(ctx) => {
|
|
139
|
+
const pluginConfig = (ctx.config && ctx.config.plugins && ctx.config.plugins.entries && ctx.config.plugins.entries["vlc-files"] && ctx.config.plugins.entries["vlc-files"].config) || {};
|
|
140
|
+
const root = pluginConfig.mediaRoot;
|
|
141
|
+
if (!root) {
|
|
142
|
+
return {
|
|
143
|
+
name: "vlc_play",
|
|
144
|
+
label: "vlc_play",
|
|
145
|
+
description: "Play a media file with VLC on the host",
|
|
146
|
+
parameters: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: { file: { type: "string", description: "Relative media file path (relative to mediaRoot)" } },
|
|
149
|
+
required: ["file"],
|
|
150
|
+
},
|
|
151
|
+
execute(id, params) {
|
|
152
|
+
return { content: [{ type: "text", text: "mediaRoot not configured" }], isError: true, details: undefined };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
name: "vlc_play",
|
|
158
|
+
label: "vlc_play",
|
|
159
|
+
description: "Play a media file with VLC on the host",
|
|
160
|
+
parameters: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: { file: { type: "string", description: "Relative media file path (relative to mediaRoot)" } },
|
|
163
|
+
required: ["file"],
|
|
164
|
+
},
|
|
165
|
+
async execute(id, { file }) {
|
|
166
|
+
const safePath = sanitize(file, root);
|
|
167
|
+
if (!safePath.startsWith(root)) {
|
|
168
|
+
return { content: [{ type: "text", text: "Path escapes media root" }], isError: true, details: undefined };
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const st = await stat(safePath);
|
|
172
|
+
if (!st.isFile()) {
|
|
173
|
+
return { content: [{ type: "text", text: "Not a file" }], isError: true, details: undefined };
|
|
174
|
+
}
|
|
175
|
+
} catch (_) {
|
|
176
|
+
return { content: [{ type: "text", text: "File not found" }], isError: true, details: undefined };
|
|
177
|
+
}
|
|
178
|
+
const cvlcArgs = buildCvlcArgs(pluginConfig.vlcArgs);
|
|
179
|
+
const kind = mediaKind(file);
|
|
180
|
+
const child = spawn("cvlc", [...cvlcArgs, safePath], { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
181
|
+
const stderrLines = [];
|
|
182
|
+
child.stderr && child.stderr.on("data", (chunk) => { if (stderrLines.length < 8) stderrLines.push(chunk.toString().trim()); });
|
|
183
|
+
child.unref();
|
|
184
|
+
const exitCode = await Promise.race([
|
|
185
|
+
new Promise((resolve) => child.on("exit", (code) => resolve(code || 0))),
|
|
186
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 1500)),
|
|
187
|
+
]);
|
|
188
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
189
|
+
return { content: [{ type: "text", text: `cvlc exited ${exitCode}: ${stderrLines.join("\n")}` }], isError: true, details: undefined };
|
|
190
|
+
}
|
|
191
|
+
return { content: [{ type: "text", text: `Playing: ${relative(root, safePath)}` }], details: undefined };
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
{ names: ["vlc_play"] }
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type": "commonjs"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "vlc-files",
|
|
3
|
+
"name": "vlc-files",
|
|
4
|
+
"description": "Media library browsing and VLC playback",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"contracts": {
|
|
7
|
+
"tools": ["media_list", "vlc_play"]
|
|
8
|
+
},
|
|
9
|
+
"skills": ["./skills/vlc"],
|
|
10
|
+
"configSchema": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": true,
|
|
13
|
+
"properties": {
|
|
14
|
+
"mediaRoot": { "type": "string", "default": "/media/raven/M&Serials" },
|
|
15
|
+
"vlcArgs": { "type": "string", "default": "--play-and-pause" }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vlc-files",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Media library browsing and VLC playback",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"./dist/index.js"
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/node": "^25.6.2",
|
|
13
|
+
"openclaw": "^2026.5.7",
|
|
14
|
+
"typescript": "^6.0.3"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const fs_1 = require("fs/promises");
|
|
8
|
+
const child_process_1 = require("child_process");
|
|
9
|
+
function sanitize(input, root) {
|
|
10
|
+
const normalized = input.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
11
|
+
if (normalized === "" || normalized === ".") return root;
|
|
12
|
+
const sanitized = normalized.replace(/\.\./g, "").replace(/^\//, "");
|
|
13
|
+
return (0, path_1.join)(root, sanitized).replace(/\\/g, "/");
|
|
14
|
+
}
|
|
15
|
+
const ALLOWED_VLC_ARGS = new Set([
|
|
16
|
+
"--play-and-pause", "--play", "--pause", "--stop", "--rate",
|
|
17
|
+
"--aspect-ratio", "--audio", "--volume", "--zoom", "--fullscreen",
|
|
18
|
+
"--no-fullscreen", "--video-on-top", "--video-wallpaper",
|
|
19
|
+
"--snapshot-path", "--video-snapshot",
|
|
20
|
+
]);
|
|
21
|
+
function buildCvlcArgs(raw) {
|
|
22
|
+
const filtered = raw.split(/\s+/).filter(Boolean).filter((a) => ALLOWED_VLC_ARGS.has(a));
|
|
23
|
+
return filtered.length > 0 ? filtered : ["--play-and-pause"];
|
|
24
|
+
}
|
|
25
|
+
function mediaKind(name) {
|
|
26
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
27
|
+
if (["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg"].includes(ext))
|
|
28
|
+
return "video";
|
|
29
|
+
if (["mp3", "flac", "wav", "aac", "ogg", "m4a", "wma", "ape"].includes(ext)) return "audio";
|
|
30
|
+
if (["srt", "ssa", "ass", "vtt", "sub"].includes(ext)) return "subtitle";
|
|
31
|
+
return "other";
|
|
32
|
+
}
|
|
33
|
+
const plugin = {
|
|
34
|
+
id: "vlc-files",
|
|
35
|
+
config: {
|
|
36
|
+
mediaRoot: { type: "string", required: true },
|
|
37
|
+
vlcArgs: { type: "string", default: "--play-and-pause" },
|
|
38
|
+
},
|
|
39
|
+
register(api) {
|
|
40
|
+
// dir_list
|
|
41
|
+
api.registerTool({
|
|
42
|
+
name: "dir_list",
|
|
43
|
+
description: "List files in the media library",
|
|
44
|
+
input: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
path: { type: "string", description: "Relative subdirectory under mediaRoot", default: "" },
|
|
48
|
+
includeKind: { type: "boolean", description: "Include media kind and mtime for files", default: false },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
async execute(id, { path: target, includeKind }, ctx) {
|
|
52
|
+
const root = ctx.config.mediaRoot;
|
|
53
|
+
const safePath = sanitize(target ?? "", root);
|
|
54
|
+
let dirents;
|
|
55
|
+
try {
|
|
56
|
+
dirents = await (0, fs_1.readdir)(safePath, { withFileTypes: true });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
59
|
+
}
|
|
60
|
+
const entries = dirents.map((e) => {
|
|
61
|
+
const name = e.name;
|
|
62
|
+
const type = e.isDirectory() ? "dir" : "file";
|
|
63
|
+
const rel = process.platform === "win32"
|
|
64
|
+
? (0, path_1.relative)(root, (0, path_1.join)(safePath, name)).replace(/\\/g, "/")
|
|
65
|
+
: (0, path_1.relative)(root, (0, path_1.join)(safePath, name));
|
|
66
|
+
return { name, type, path: rel };
|
|
67
|
+
});
|
|
68
|
+
if (includeKind) {
|
|
69
|
+
const fileEntries = entries.filter((e) => e.type === "file");
|
|
70
|
+
const stats = await Promise.all(fileEntries.map((e) =>
|
|
71
|
+
(0, fs_1.stat)((0, path_1.join)(safePath, e.name))
|
|
72
|
+
.then((s) => ({ e, size: s.size, modified: s.mtime.toISOString() }))
|
|
73
|
+
.catch(() => ({ e, size: 0, modified: undefined }))
|
|
74
|
+
));
|
|
75
|
+
for (const { e, size, modified } of stats) {
|
|
76
|
+
e.kind = mediaKind(e.name);
|
|
77
|
+
e.size = size;
|
|
78
|
+
e.modified = modified;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
entries.sort((a, b) =>
|
|
82
|
+
a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name)
|
|
83
|
+
);
|
|
84
|
+
return { ok: true, entries, path: target || "." };
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
// vlc_play
|
|
88
|
+
api.registerTool({
|
|
89
|
+
name: "vlc_play",
|
|
90
|
+
description: "Play a media file with VLC on the host",
|
|
91
|
+
input: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
file: { type: "string", description: "Relative media file path (relative to mediaRoot)" },
|
|
95
|
+
},
|
|
96
|
+
required: ["file"],
|
|
97
|
+
},
|
|
98
|
+
async execute(id, { file }, ctx) {
|
|
99
|
+
const root = ctx.config.mediaRoot;
|
|
100
|
+
const safePath = sanitize(file, root);
|
|
101
|
+
if (!safePath.startsWith(root)) {
|
|
102
|
+
return { ok: false, error: "Path escapes media root" };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const st = await (0, fs_1.stat)(safePath);
|
|
106
|
+
if (!st.isFile()) return { ok: false, error: "Not a file" };
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, error: "File not found" };
|
|
109
|
+
}
|
|
110
|
+
const cvlcArgs = buildCvlcArgs(ctx.config.vlcArgs ?? "--play-and-pause");
|
|
111
|
+
const kind = mediaKind(file);
|
|
112
|
+
const child = (0, child_process_1.spawn)("cvlc", [...cvlcArgs, safePath], {
|
|
113
|
+
detached: true,
|
|
114
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
115
|
+
});
|
|
116
|
+
const stderrLines = [];
|
|
117
|
+
child.stderr?.on("data", (chunk) => {
|
|
118
|
+
if (stderrLines.length < 8) stderrLines.push(chunk.toString().trim());
|
|
119
|
+
});
|
|
120
|
+
child.unref();
|
|
121
|
+
const exitCode = await Promise.race([
|
|
122
|
+
new Promise((resolve) => child.on("exit", (code) => resolve(code ?? null))),
|
|
123
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 1500)),
|
|
124
|
+
]);
|
|
125
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: `cvlc exited ${exitCode}`,
|
|
129
|
+
detail: stderrLines.join("\n") || undefined,
|
|
130
|
+
kind,
|
|
131
|
+
playing: process.platform === "win32"
|
|
132
|
+
? (0, path_1.relative)(root, safePath).replace(/\\/g, "/")
|
|
133
|
+
: (0, path_1.relative)(root, safePath),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
playing: process.platform === "win32"
|
|
139
|
+
? (0, path_1.relative)(root, safePath).replace(/\\/g, "/")
|
|
140
|
+
: (0, path_1.relative)(root, safePath),
|
|
141
|
+
args: cvlcArgs,
|
|
142
|
+
kind,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
exports.default = plugin;
|
|
149
|
+
module.exports = plugin;
|
|
150
|
+
exports.default = plugin;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { definePluginEntry, buildJsonPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
|
|
2
|
+
import { join, sep, relative } from "path";
|
|
3
|
+
import { readdir } from "fs/promises";
|
|
4
|
+
import { stat } from "fs/promises";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
|
|
7
|
+
// ─── Path utilities ───────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function sanitize(input: string, root: string): string {
|
|
10
|
+
const normalized = input.replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
11
|
+
if (normalized === "" || normalized === ".") return root;
|
|
12
|
+
const sanitized = normalized.replace(/\.\./g, "").replace(/^\//, "");
|
|
13
|
+
return join(root, sanitized).replace(/\\/g, "/");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── VLC arg whitelist ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const ALLOWED_VLC_ARGS = new Set([
|
|
19
|
+
"--play-and-pause",
|
|
20
|
+
"--play",
|
|
21
|
+
"--pause",
|
|
22
|
+
"--stop",
|
|
23
|
+
"--rate",
|
|
24
|
+
"--aspect-ratio",
|
|
25
|
+
"--audio",
|
|
26
|
+
"--volume",
|
|
27
|
+
"--zoom",
|
|
28
|
+
"--fullscreen",
|
|
29
|
+
"--no-fullscreen",
|
|
30
|
+
"--video-on-top",
|
|
31
|
+
"--video-wallpaper",
|
|
32
|
+
"--snapshot-path",
|
|
33
|
+
"--video-snapshot",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function buildCvlcArgs(raw: string): string[] {
|
|
37
|
+
const filtered = raw.split(/\s+/).filter(Boolean).filter((a) => ALLOWED_VLC_ARGS.has(a));
|
|
38
|
+
return filtered.length > 0 ? filtered : ["--play-and-pause"];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Media kind detection ─────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function mediaKind(name: string): "video" | "audio" | "subtitle" | "other" {
|
|
44
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
45
|
+
if (["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg"].includes(ext))
|
|
46
|
+
return "video";
|
|
47
|
+
if (["mp3", "flac", "wav", "aac", "ogg", "m4a", "wma", "ape"].includes(ext)) return "audio";
|
|
48
|
+
if (["srt", "ssa", "ass", "vtt", "sub"].includes(ext)) return "subtitle";
|
|
49
|
+
return "other";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Plugin entry ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
interface DirEntry {
|
|
55
|
+
name: string;
|
|
56
|
+
type: "dir" | "file";
|
|
57
|
+
path: string;
|
|
58
|
+
kind?: "video" | "audio" | "subtitle" | "other";
|
|
59
|
+
size?: number;
|
|
60
|
+
modified?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default definePluginEntry({
|
|
64
|
+
id: "vlc-files",
|
|
65
|
+
name: "vlc-files",
|
|
66
|
+
description: "Media library browsing and VLC playback",
|
|
67
|
+
configSchema: buildJsonPluginConfigSchema({
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
mediaRoot: { type: "string" },
|
|
71
|
+
vlcArgs: { type: "string", default: "--play-and-pause" },
|
|
72
|
+
},
|
|
73
|
+
required: ["mediaRoot"],
|
|
74
|
+
}),
|
|
75
|
+
register(api) {
|
|
76
|
+
// ── media_list ────────────────────────────────────────────────────────────
|
|
77
|
+
api.registerTool(
|
|
78
|
+
(ctx) => ({
|
|
79
|
+
name: "media_list",
|
|
80
|
+
label: "media_list",
|
|
81
|
+
description: "List files in the media library",
|
|
82
|
+
parameters: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
path: {
|
|
86
|
+
type: "string",
|
|
87
|
+
description: "Relative subdirectory under mediaRoot (default: root)",
|
|
88
|
+
default: "",
|
|
89
|
+
},
|
|
90
|
+
includeKind: {
|
|
91
|
+
type: "boolean",
|
|
92
|
+
description: "Include media kind (video/audio/subtitle) and mtime for files",
|
|
93
|
+
default: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
async execute(id, { path: target, includeKind }, signal) {
|
|
98
|
+
const pluginConfig = ctx.config?.plugins?.entries?.["vlc-files"]?.config as { mediaRoot?: string; vlcArgs?: string } | undefined;
|
|
99
|
+
const root = pluginConfig?.mediaRoot;
|
|
100
|
+
if (!root) return { content: [{ type: "text", text: "mediaRoot not configured" }], isError: true , details: undefined };
|
|
101
|
+
|
|
102
|
+
const safePath = sanitize(target ?? "", root);
|
|
103
|
+
if (!safePath.startsWith(root)) {
|
|
104
|
+
return { content: [{ type: "text", text: "Path escapes media root" }], isError: true , details: undefined };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let dirents: Awaited<ReturnType<typeof readdir>>;
|
|
108
|
+
try {
|
|
109
|
+
dirents = await readdir(safePath, { withFileTypes: true });
|
|
110
|
+
} catch (err: unknown) {
|
|
111
|
+
return { content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true , details: undefined };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const entries: DirEntry[] = dirents.map((e) => {
|
|
115
|
+
const name = String(e.name);
|
|
116
|
+
const type = e.isDirectory() ? "dir" : "file";
|
|
117
|
+
const fullPath = join(safePath, name);
|
|
118
|
+
const rel = sep === "/"
|
|
119
|
+
? relative(root, fullPath)
|
|
120
|
+
: relative(root, fullPath).replace(/\\/g, "/");
|
|
121
|
+
return { name, type, path: rel };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (includeKind) {
|
|
125
|
+
const fileEntries = entries.filter((e) => e.type === "file");
|
|
126
|
+
const stats = await Promise.all(
|
|
127
|
+
fileEntries.map((e) =>
|
|
128
|
+
stat(join(safePath, e.name))
|
|
129
|
+
.then((s) => ({ e, size: s.size, modified: s.mtime.toISOString() }))
|
|
130
|
+
.catch(() => ({ e, size: 0, modified: undefined }))
|
|
131
|
+
)
|
|
132
|
+
);
|
|
133
|
+
for (const { e, size, modified } of stats) {
|
|
134
|
+
e.kind = mediaKind(e.name);
|
|
135
|
+
e.size = size;
|
|
136
|
+
e.modified = modified;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
entries.sort((a, b) =>
|
|
141
|
+
a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, entries, path: target || "." }) }], details: undefined };
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
{ names: ["media_list"] }
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// ── vlc_play ──────────────────────────────────────────────────────────────
|
|
151
|
+
api.registerTool(
|
|
152
|
+
(ctx) => ({
|
|
153
|
+
name: "vlc_play",
|
|
154
|
+
label: "vlc_play",
|
|
155
|
+
description: "Play a media file with VLC on the host",
|
|
156
|
+
parameters: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: {
|
|
159
|
+
file: {
|
|
160
|
+
type: "string",
|
|
161
|
+
description: "Relative media file path (relative to mediaRoot)",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
required: ["file"],
|
|
165
|
+
},
|
|
166
|
+
async execute(id, { file }, signal) {
|
|
167
|
+
const pluginConfig = ctx.config?.plugins?.entries?.["vlc-files"]?.config as { mediaRoot?: string; vlcArgs?: string } | undefined;
|
|
168
|
+
const root = pluginConfig?.mediaRoot;
|
|
169
|
+
if (!root) return { content: [{ type: "text", text: "mediaRoot not configured" }], isError: true , details: undefined };
|
|
170
|
+
|
|
171
|
+
const safePath = sanitize(file, root);
|
|
172
|
+
if (!safePath.startsWith(root)) {
|
|
173
|
+
return { content: [{ type: "text", text: "Path escapes media root" }], isError: true , details: undefined };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const st = await stat(safePath);
|
|
178
|
+
if (!st.isFile()) return { content: [{ type: "text", text: "Not a file" }], isError: true , details: undefined };
|
|
179
|
+
} catch {
|
|
180
|
+
return { content: [{ type: "text", text: "File not found" }], isError: true , details: undefined };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const cvlcArgs = buildCvlcArgs(pluginConfig?.vlcArgs ?? "--play-and-pause");
|
|
184
|
+
const kind = mediaKind(file);
|
|
185
|
+
|
|
186
|
+
const child = spawn("cvlc", [...cvlcArgs, safePath], {
|
|
187
|
+
detached: true,
|
|
188
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const stderrLines: string[] = [];
|
|
192
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
193
|
+
if (stderrLines.length < 8) stderrLines.push(chunk.toString().trim());
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
child.unref();
|
|
197
|
+
|
|
198
|
+
const exitCode: number | null = await Promise.race<number | null>([
|
|
199
|
+
new Promise<number>((resolve) =>
|
|
200
|
+
child.on("exit", (code) => resolve(code ?? 0))
|
|
201
|
+
),
|
|
202
|
+
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1500)),
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: `cvlc exited ${exitCode}: ${stderrLines.join("\n")}` }],
|
|
208
|
+
isError: true,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { content: [{ type: "text", text: `Playing: ${relative(root, safePath)}` }], details: undefined };
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
{ names: ["vlc_play"] }
|
|
216
|
+
);
|
|
217
|
+
},
|
|
218
|
+
});
|
package/tsconfig.json
ADDED
|
Binary file
|